[ Index ] |
PHP Cross Reference of Unnamed Project |
[Summary view] [Print] [Text view]
1 // This file is part of Moodle - http://moodle.org/ 2 // 3 // Moodle is free software: you can redistribute it and/or modify 4 // it under the terms of the GNU General Public License as published by 5 // the Free Software Foundation, either version 3 of the License, or 6 // (at your option) any later version. 7 // 8 // Moodle is distributed in the hope that it will be useful, 9 // but WITHOUT ANY WARRANTY; without even the implied warranty of 10 // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 11 // GNU General Public License for more details. 12 // 13 // You should have received a copy of the GNU General Public License 14 // along with Moodle. If not, see <http://www.gnu.org/licenses/>. 15 16 /** 17 * @package atto_equation 18 * @copyright 2013 Damyon Wiese <damyon@moodle.com> 19 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later 20 */ 21 22 /** 23 * Atto text editor equation plugin. 24 */ 25 26 /** 27 * Atto equation editor. 28 * 29 * @namespace M.atto_equation 30 * @class Button 31 * @extends M.editor_atto.EditorPlugin 32 */ 33 var COMPONENTNAME = 'atto_equation', 34 LOGNAME = 'atto_equation', 35 CSS = { 36 EQUATION_TEXT: 'atto_equation_equation', 37 EQUATION_PREVIEW: 'atto_equation_preview', 38 SUBMIT: 'atto_equation_submit', 39 LIBRARY: 'atto_equation_library', 40 LIBRARY_GROUPS: 'atto_equation_groups', 41 LIBRARY_GROUP_PREFIX: 'atto_equation_group' 42 }, 43 SELECTORS = { 44 LIBRARY: '.' + CSS.LIBRARY, 45 LIBRARY_GROUP: '.' + CSS.LIBRARY_GROUPS + ' > div > div', 46 EQUATION_TEXT: '.' + CSS.EQUATION_TEXT, 47 EQUATION_PREVIEW: '.' + CSS.EQUATION_PREVIEW, 48 SUBMIT: '.' + CSS.SUBMIT, 49 LIBRARY_BUTTON: '.' + CSS.LIBRARY + ' button' 50 }, 51 DELIMITERS = { 52 START: '\\(', 53 END: '\\)' 54 }, 55 TEMPLATES = { 56 FORM: '' + 57 '<form class="atto_form">' + 58 '{{{library}}}' + 59 '<label for="{{elementid}}_{{CSS.EQUATION_TEXT}}">{{{get_string "editequation" component texdocsurl}}}</label>' + 60 '<textarea class="fullwidth {{CSS.EQUATION_TEXT}}" ' + 61 'id="{{elementid}}_{{CSS.EQUATION_TEXT}}" rows="8"></textarea><br/>' + 62 '<label for="{{elementid}}_{{CSS.EQUATION_PREVIEW}}">{{get_string "preview" component}}</label>' + 63 '<div describedby="{{elementid}}_cursorinfo" class="well well-small fullwidth {{CSS.EQUATION_PREVIEW}}" ' + 64 'id="{{elementid}}_{{CSS.EQUATION_PREVIEW}}"></div>' + 65 '<div id="{{elementid}}_cursorinfo">{{get_string "cursorinfo" component}}</div>' + 66 '<div class="mdl-align">' + 67 '<br/>' + 68 '<button class="{{CSS.SUBMIT}}">{{get_string "saveequation" component}}</button>' + 69 '</div>' + 70 '</form>', 71 LIBRARY: '' + 72 '<div class="{{CSS.LIBRARY}}">' + 73 '<ul>' + 74 '{{#each library}}' + 75 '<li><a href="#{{../elementid}}_{{../CSS.LIBRARY_GROUP_PREFIX}}_{{@key}}">' + 76 '{{get_string groupname ../component}}' + 77 '</a></li>' + 78 '{{/each}}' + 79 '</ul>' + 80 '<div class="{{CSS.LIBRARY_GROUPS}}">' + 81 '{{#each library}}' + 82 '<div id="{{../elementid}}_{{../CSS.LIBRARY_GROUP_PREFIX}}_{{@key}}">' + 83 '<div role="toolbar">' + 84 '{{#split "\n" elements}}' + 85 '<button tabindex="-1" data-tex="{{this}}" aria-label="{{this}}" title="{{this}}">' + 86 '{{../../DELIMITERS.START}}{{this}}{{../../DELIMITERS.END}}' + 87 '</button>' + 88 '{{/split}}' + 89 '</div>' + 90 '</div>' + 91 '{{/each}}' + 92 '</div>' + 93 '</div>' 94 }; 95 96 Y.namespace('M.atto_equation').Button = Y.Base.create('button', Y.M.editor_atto.EditorPlugin, [], { 97 98 /** 99 * The selection object returned by the browser. 100 * 101 * @property _currentSelection 102 * @type Range 103 * @default null 104 * @private 105 */ 106 _currentSelection: null, 107 108 /** 109 * The cursor position in the equation textarea. 110 * 111 * @property _lastCursorPos 112 * @type Number 113 * @default 0 114 * @private 115 */ 116 _lastCursorPos: 0, 117 118 /** 119 * A reference to the dialogue content. 120 * 121 * @property _content 122 * @type Node 123 * @private 124 */ 125 _content: null, 126 127 /** 128 * The source equation we are editing in the text. 129 * 130 * @property _sourceEquation 131 * @type Object 132 * @private 133 */ 134 _sourceEquation: null, 135 136 /** 137 * A reference to the tab focus set on each group. 138 * 139 * The keys are the IDs of the group, the value is the Node on which the focus is set. 140 * 141 * @property _groupFocus 142 * @type Object 143 * @private 144 */ 145 _groupFocus: null, 146 147 /** 148 * Regular Expression patterns used to pick out the equations in a String. 149 * 150 * @property _equationPatterns 151 * @type Array 152 * @private 153 */ 154 _equationPatterns: [ 155 // We use space or not space because . does not match new lines. 156 // $$ blah $$. 157 /\$\$([\S\s]+?)\$\$/, 158 // E.g. "\( blah \)". 159 /\\\(([\S\s]+?)\\\)/, 160 // E.g. "\[ blah \]". 161 /\\\[([\S\s]+?)\\\]/, 162 // E.g. "[tex] blah [/tex]". 163 /\[tex\]([\S\s]+?)\[\/tex\]/ 164 ], 165 166 initializer: function() { 167 this._groupFocus = {}; 168 169 // If there is a tex filter active - enable this button. 170 if (this.get('texfilteractive')) { 171 // Add the button to the toolbar. 172 this.addButton({ 173 icon: 'e/math', 174 callback: this._displayDialogue 175 }); 176 177 // We need custom highlight logic for this button. 178 this.get('host').on('atto:selectionchanged', function() { 179 if (this._resolveEquation()) { 180 this.highlightButtons(); 181 } else { 182 this.unHighlightButtons(); 183 } 184 }, this); 185 186 // We need to convert these to a non dom node based format. 187 this.editor.all('tex').each(function(texNode) { 188 var replacement = Y.Node.create('<span>' + 189 DELIMITERS.START + ' ' + texNode.get('text') + ' ' + DELIMITERS.END + 190 '</span>'); 191 texNode.replace(replacement); 192 }); 193 } 194 195 }, 196 197 /** 198 * Display the equation editor. 199 * 200 * @method _displayDialogue 201 * @private 202 */ 203 _displayDialogue: function() { 204 this._currentSelection = this.get('host').getSelection(); 205 206 if (this._currentSelection === false) { 207 return; 208 } 209 210 // This needs to be done before the dialogue is opened because the focus will shift to the dialogue. 211 var equation = this._resolveEquation(); 212 213 var dialogue = this.getDialogue({ 214 headerContent: M.util.get_string('pluginname', COMPONENTNAME), 215 focusAfterHide: true, 216 width: 600, 217 focusOnShowSelector: SELECTORS.EQUATION_TEXT 218 }); 219 220 var content = this._getDialogueContent(); 221 dialogue.set('bodyContent', content); 222 223 var library = content.one(SELECTORS.LIBRARY); 224 225 var tabview = new Y.TabView({ 226 srcNode: library 227 }); 228 229 tabview.render(); 230 dialogue.show(); 231 // Notify the filters about the modified nodes. 232 require(['core/event'], function(event) { 233 event.notifyFilterContentUpdated(dialogue.get('boundingBox').getDOMNode()); 234 }); 235 236 if (equation) { 237 content.one(SELECTORS.EQUATION_TEXT).set('text', equation); 238 } 239 this._updatePreview(false); 240 }, 241 242 /** 243 * If there is selected text and it is part of an equation, 244 * extract the equation (and set it in the form). 245 * 246 * @method _resolveEquation 247 * @private 248 * @return {String|Boolean} The equation or false. 249 */ 250 _resolveEquation: function() { 251 252 // Find the equation in the surrounding text. 253 var selectedNode = this.get('host').getSelectionParentNode(), 254 selection = this.get('host').getSelection(), 255 text, 256 returnValue = false; 257 258 // Prevent resolving equations when we don't have focus. 259 if (!this.get('host').isActive()) { 260 return false; 261 } 262 263 // Note this is a document fragment and YUI doesn't like them. 264 if (!selectedNode) { 265 return false; 266 } 267 268 // We don't yet have a cursor selection somehow so we can't possible be resolving an equation that has selection. 269 if (!selection || selection.length === 0) { 270 return false; 271 } 272 273 this.sourceEquation = null; 274 275 selection = selection[0]; 276 277 text = Y.one(selectedNode).get('text'); 278 279 // For each of these patterns we have a RegExp which captures the inner component of the equation but also 280 // includes the delimiters. 281 // We first run the RegExp adding the global flag ("g"). This ignores the capture, instead matching the entire 282 // equation including delimiters and returning one entry per match of the whole equation. 283 // We have to deal with multiple occurences of the same equation in a String so must be able to loop on the 284 // match results. 285 Y.Array.find(this._equationPatterns, function(pattern) { 286 // For each pattern in turn, find all whole matches (including the delimiters). 287 var patternMatches = text.match(new RegExp(pattern.source, "g")); 288 289 if (patternMatches && patternMatches.length) { 290 // This pattern matches at least once. See if this pattern matches our current position. 291 // Note: We return here to break the Y.Array.find loop - any truthy return will stop any subsequent 292 // searches which is the required behaviour of this function. 293 return Y.Array.find(patternMatches, function(match) { 294 // Check each occurrence of this match. 295 var startIndex = 0; 296 while (text.indexOf(match, startIndex) !== -1) { 297 // Determine whether the cursor is in the current occurrence of this string. 298 // Note: We do not support a selection exceeding the bounds of an equation. 299 var startOuter = text.indexOf(match, startIndex), 300 endOuter = startOuter + match.length, 301 startMatch = (selection.startOffset >= startOuter && selection.startOffset < endOuter), 302 endMatch = (selection.endOffset <= endOuter && selection.endOffset > startOuter); 303 304 if (startMatch && endMatch) { 305 // This match is in our current position - fetch the innerMatch data. 306 var innerMatch = match.match(pattern); 307 if (innerMatch && innerMatch.length) { 308 // We need the start and end of the inner match for later. 309 var startInner = text.indexOf(innerMatch[1], startOuter), 310 endInner = startInner + innerMatch[1].length; 311 312 // We'll be returning the inner match for use in the editor itself. 313 returnValue = innerMatch[1]; 314 315 // Save all data for later. 316 this.sourceEquation = { 317 // Outer match data. 318 startOuterPosition: startOuter, 319 endOuterPosition: endOuter, 320 outerMatch: match, 321 322 // Inner match data. 323 startInnerPosition: startInner, 324 endInnerPosition: endInner, 325 innerMatch: innerMatch 326 }; 327 328 // This breaks out of both Y.Array.find functions. 329 return true; 330 } 331 } 332 333 // Update the startIndex to match the end of the current match so that we can continue hunting 334 // for further matches. 335 startIndex = endOuter; 336 } 337 }, this); 338 } 339 }, this); 340 341 // We trim the equation when we load it and then add spaces when we save it. 342 if (returnValue !== false) { 343 returnValue = returnValue.trim(); 344 } 345 return returnValue; 346 }, 347 348 /** 349 * Handle insertion of a new equation, or update of an existing one. 350 * 351 * @method _setEquation 352 * @param {EventFacade} e 353 * @private 354 */ 355 _setEquation: function(e) { 356 var input, 357 selectedNode, 358 text, 359 value, 360 host, 361 newText; 362 363 host = this.get('host'); 364 365 e.preventDefault(); 366 this.getDialogue({ 367 focusAfterHide: null 368 }).hide(); 369 370 input = e.currentTarget.ancestor('.atto_form').one('textarea'); 371 372 value = input.get('value'); 373 if (value !== '') { 374 host.setSelection(this._currentSelection); 375 376 if (this.sourceEquation) { 377 // Replace the equation. 378 selectedNode = Y.one(host.getSelectionParentNode()); 379 text = selectedNode.get('text'); 380 value = ' ' + value + ' '; 381 newText = text.slice(0, this.sourceEquation.startInnerPosition) + 382 value + 383 text.slice(this.sourceEquation.endInnerPosition); 384 385 selectedNode.set('text', newText); 386 } else { 387 // Insert the new equation. 388 value = DELIMITERS.START + ' ' + value + ' ' + DELIMITERS.END; 389 host.insertContentAtFocusPoint(value); 390 } 391 392 // Clean the YUI ids from the HTML. 393 this.markUpdated(); 394 } 395 }, 396 397 /** 398 * Smart throttle, only call a function every delay milli seconds, 399 * and always run the last call. Y.throttle does not work here, 400 * because it calls the function immediately, the first time, and then 401 * ignores repeated calls within X seconds. This does not guarantee 402 * that the last call will be executed (which is required here). 403 * 404 * @param {function} fn 405 * @param {Number} delay Delay in milliseconds 406 * @method _throttle 407 * @private 408 */ 409 _throttle: function(fn, delay) { 410 var timer = null; 411 return function() { 412 var context = this, args = arguments; 413 clearTimeout(timer); 414 timer = setTimeout(function() { 415 fn.apply(context, args); 416 }, delay); 417 }; 418 }, 419 420 /** 421 * Update the preview div to match the current equation. 422 * 423 * @param {EventFacade} e 424 * @method _updatePreview 425 * @private 426 */ 427 _updatePreview: function(e) { 428 var textarea = this._content.one(SELECTORS.EQUATION_TEXT), 429 equation = textarea.get('value'), 430 url, 431 currentPos = textarea.get('selectionStart'), 432 prefix = '', 433 cursorLatex = '\\Downarrow ', 434 isChar, 435 params; 436 437 if (e) { 438 e.preventDefault(); 439 } 440 441 // Move the cursor so it does not break expressions. 442 // Start at the very beginning. 443 if (!currentPos) { 444 currentPos = 0; 445 } 446 447 // First move back to the beginning of the line. 448 while (equation.charAt(currentPos) === '\\' && currentPos >= 0) { 449 currentPos -= 1; 450 } 451 isChar = /[a-zA-Z\{\}]/; 452 if (currentPos !== 0) { 453 // Now match to the end of the line. 454 while (isChar.test(equation.charAt(currentPos)) && 455 currentPos < equation.length && 456 isChar.test(equation.charAt(currentPos - 1))) { 457 currentPos += 1; 458 } 459 } 460 // Save the cursor position - for insertion from the library. 461 this._lastCursorPos = currentPos; 462 equation = prefix + equation.substring(0, currentPos) + cursorLatex + equation.substring(currentPos); 463 464 equation = DELIMITERS.START + ' ' + equation + ' ' + DELIMITERS.END; 465 // Make an ajax request to the filter. 466 url = M.cfg.wwwroot + '/lib/editor/atto/plugins/equation/ajax.php'; 467 params = { 468 sesskey: M.cfg.sesskey, 469 contextid: this.get('contextid'), 470 action: 'filtertext', 471 text: equation 472 }; 473 474 Y.io(url, { 475 context: this, 476 data: params, 477 timeout: 500, 478 on: { 479 complete: this._loadPreview 480 } 481 }); 482 }, 483 484 /** 485 * Load returned preview text into preview 486 * 487 * @param {String} id 488 * @param {EventFacade} e 489 * @method _loadPreview 490 * @private 491 */ 492 _loadPreview: function(id, preview) { 493 var previewNode = this._content.one(SELECTORS.EQUATION_PREVIEW); 494 495 if (preview.status === 200) { 496 previewNode.setHTML(preview.responseText); 497 498 // Notify the filters about the modified nodes. 499 require(['core/event'], function(event) { 500 event.notifyFilterContentUpdated(previewNode.getDOMNode()); 501 }); 502 } 503 }, 504 505 /** 506 * Return the dialogue content for the tool, attaching any required 507 * events. 508 * 509 * @method _getDialogueContent 510 * @return {Node} 511 * @private 512 */ 513 _getDialogueContent: function() { 514 var library = this._getLibraryContent(), 515 throttledUpdate = this._throttle(this._updatePreview, 500), 516 template = Y.Handlebars.compile(TEMPLATES.FORM); 517 518 this._content = Y.Node.create(template({ 519 elementid: this.get('host').get('elementid'), 520 component: COMPONENTNAME, 521 library: library, 522 texdocsurl: this.get('texdocsurl'), 523 CSS: CSS 524 })); 525 526 // Sets the default focus. 527 this._content.all(SELECTORS.LIBRARY_GROUP).each(function(group) { 528 // The first button gets the focus. 529 this._setGroupTabFocus(group, group.one('button')); 530 // Sometimes the filter adds an anchor in the button, no tabindex on that. 531 group.all('button a').setAttribute('tabindex', '-1'); 532 }, this); 533 534 // Keyboard navigation in groups. 535 this._content.delegate('key', this._groupNavigation, 'down:37,39', SELECTORS.LIBRARY_BUTTON, this); 536 537 this._content.one(SELECTORS.SUBMIT).on('click', this._setEquation, this); 538 this._content.one(SELECTORS.EQUATION_TEXT).on('valuechange', throttledUpdate, this); 539 this._content.one(SELECTORS.EQUATION_TEXT).on('mouseup', throttledUpdate, this); 540 this._content.one(SELECTORS.EQUATION_TEXT).on('keyup', throttledUpdate, this); 541 this._content.delegate('click', this._selectLibraryItem, SELECTORS.LIBRARY_BUTTON, this); 542 543 return this._content; 544 }, 545 546 /** 547 * Callback handling the keyboard navigation in the groups of the library. 548 * 549 * @param {EventFacade} e The event. 550 * @method _groupNavigation 551 * @private 552 */ 553 _groupNavigation: function(e) { 554 e.preventDefault(); 555 556 var current = e.currentTarget, 557 parent = current.get('parentNode'), // This must be the <div> containing all the buttons of the group. 558 buttons = parent.all('button'), 559 direction = e.keyCode !== 37 ? 1 : -1, 560 index = buttons.indexOf(current), 561 nextButton; 562 563 if (index < 0) { 564 Y.log('Unable to find the current button in the list of buttons', 'debug', LOGNAME); 565 index = 0; 566 } 567 568 index += direction; 569 if (index < 0) { 570 index = buttons.size() - 1; 571 } else if (index >= buttons.size()) { 572 index = 0; 573 } 574 nextButton = buttons.item(index); 575 576 this._setGroupTabFocus(parent, nextButton); 577 nextButton.focus(); 578 }, 579 580 /** 581 * Sets tab focus for the group. 582 * 583 * @method _setGroupTabFocus 584 * @param {Node} button The node that focus should now be set to. 585 * @private 586 */ 587 _setGroupTabFocus: function(parent, button) { 588 var parentId = parent.generateID(); 589 590 // Unset the previous entry. 591 if (typeof this._groupFocus[parentId] !== 'undefined') { 592 this._groupFocus[parentId].setAttribute('tabindex', '-1'); 593 } 594 595 // Set on the new entry. 596 this._groupFocus[parentId] = button; 597 button.setAttribute('tabindex', 0); 598 parent.setAttribute('aria-activedescendant', button.generateID()); 599 }, 600 601 /** 602 * Reponse to button presses in the TeX library panels. 603 * 604 * @method _selectLibraryItem 605 * @param {EventFacade} e 606 * @return {string} 607 * @private 608 */ 609 _selectLibraryItem: function(e) { 610 var tex = e.currentTarget.getAttribute('data-tex'), 611 oldValue, 612 newValue, 613 input, 614 focusPoint = 0; 615 616 e.preventDefault(); 617 618 // Set the group focus on the button. 619 this._setGroupTabFocus(e.currentTarget.get('parentNode'), e.currentTarget); 620 621 input = e.currentTarget.ancestor('.atto_form').one('textarea'); 622 623 oldValue = input.get('value'); 624 625 newValue = oldValue.substring(0, this._lastCursorPos); 626 if (newValue.charAt(newValue.length - 1) !== ' ') { 627 newValue += ' '; 628 } 629 newValue += tex; 630 focusPoint = newValue.length; 631 632 if (oldValue.charAt(this._lastCursorPos) !== ' ') { 633 newValue += ' '; 634 } 635 newValue += oldValue.substring(this._lastCursorPos, oldValue.length); 636 637 input.set('value', newValue); 638 input.focus(); 639 640 var realInput = input.getDOMNode(); 641 if (typeof realInput.selectionStart === "number") { 642 // Modern browsers have selectionStart and selectionEnd to control the cursor position. 643 realInput.selectionStart = realInput.selectionEnd = focusPoint; 644 } else if (typeof realInput.createTextRange !== "undefined") { 645 // Legacy browsers (IE<=9) use createTextRange(). 646 var range = realInput.createTextRange(); 647 range.moveToPoint(focusPoint); 648 range.select(); 649 } 650 // Focus must be set before updating the preview for the cursor box to be in the correct location. 651 this._updatePreview(false); 652 }, 653 654 /** 655 * Return the HTML for rendering the library of predefined buttons. 656 * 657 * @method _getLibraryContent 658 * @return {string} 659 * @private 660 */ 661 _getLibraryContent: function() { 662 var template = Y.Handlebars.compile(TEMPLATES.LIBRARY), 663 library = this.get('library'), 664 content = ''; 665 666 // Helper to iterate over a newline separated string. 667 Y.Handlebars.registerHelper('split', function(delimiter, str, options) { 668 var parts, 669 current, 670 out; 671 if (typeof delimiter === "undefined" || typeof str === "undefined") { 672 Y.log('Handlebars split helper: String and delimiter are required.', 'debug', 'moodle-atto_equation-button'); 673 return ''; 674 } 675 676 out = ''; 677 parts = str.trim().split(delimiter); 678 while (parts.length > 0) { 679 current = parts.shift().trim(); 680 out += options.fn(current); 681 } 682 683 return out; 684 }); 685 content = template({ 686 elementid: this.get('host').get('elementid'), 687 component: COMPONENTNAME, 688 library: library, 689 CSS: CSS, 690 DELIMITERS: DELIMITERS 691 }); 692 693 var url = M.cfg.wwwroot + '/lib/editor/atto/plugins/equation/ajax.php'; 694 var params = { 695 sesskey: M.cfg.sesskey, 696 contextid: this.get('contextid'), 697 action: 'filtertext', 698 text: content 699 }; 700 701 var preview = Y.io(url, { 702 sync: true, 703 data: params, 704 method: 'POST' 705 }); 706 707 if (preview.status === 200) { 708 content = preview.responseText; 709 } 710 return content; 711 } 712 }, { 713 ATTRS: { 714 /** 715 * Whether the TeX filter is currently active. 716 * 717 * @attribute texfilteractive 718 * @type Boolean 719 */ 720 texfilteractive: { 721 value: false 722 }, 723 724 /** 725 * The contextid to use when generating this preview. 726 * 727 * @attribute contextid 728 * @type String 729 */ 730 contextid: { 731 value: null 732 }, 733 734 /** 735 * The content of the example library. 736 * 737 * @attribute library 738 * @type object 739 */ 740 library: { 741 value: {} 742 }, 743 744 /** 745 * The link to the Moodle Docs page about TeX. 746 * 747 * @attribute texdocsurl 748 * @type string 749 */ 750 texdocsurl: { 751 value: null 752 } 753 754 } 755 });
title
Description
Body
title
Description
Body
title
Description
Body
title
Body
Generated: Thu Aug 11 10:00:09 2016 | Cross-referenced by PHPXref 0.7.1 |