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