6db2cb882c5e9ec3247ff5e0b8ca8f3d7ddf17cb
[moodle.git] / lib / editor / atto / plugins / equation / yui / src / button / js / button.js
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/>.
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  */
22 /**
23  * Atto text editor equation plugin.
24  */
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 p-1 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="btn btn-default {{CSS.SUBMIT}}">{{get_string "saveequation" component}}</button>' +
69                 '</div>' +
70             '</form>',
71         LIBRARY: '' +
72             '<div class="{{CSS.LIBRARY}}">' +
73                 '<ul class="root nav nav-tabs m-b-1" role="tablist">' +
74                     '{{#each library}}' +
75                         '<li  class="nav-item">' +
76                             '<a class="nav-link" href="#{{../elementid}}_{{../CSS.LIBRARY_GROUP_PREFIX}}_{{@key}}" ' +
77                                 ' role="tab" data-toggle="tab">' +
78                                 '{{get_string groupname ../component}}' +
79                             '</a>' +
80                         '</li>' +
81                     '{{/each}}' +
82                 '</ul>' +
83                 '<div class="tab-content m-b-1 {{CSS.LIBRARY_GROUPS}}">' +
84                     '{{#each library}}' +
85                         '<div data-medium-type="{{CSS.LINK}}" class="tab-pane" ' +
86                         'id="{{../elementid}}_{{../CSS.LIBRARY_GROUP_PREFIX}}_{{@key}}">' +
87                             '<div role="toolbar">' +
88                             '{{#split "\n" elements}}' +
89                                 '<button class="btn btn-default" tabindex="-1" data-tex="{{this}}"' +
90                                     'aria-label="{{this}}" title="{{this}}">' +
91                                     '{{../../DELIMITERS.START}}{{this}}{{../../DELIMITERS.END}}' +
92                                 '</button>' +
93                             '{{/split}}' +
94                             '</div>' +
95                         '</div>' +
96                     '{{/each}}' +
97                 '</div>' +
98             '</div>'
99     };
101 Y.namespace('M.atto_equation').Button = Y.Base.create('button', Y.M.editor_atto.EditorPlugin, [], {
103     /**
104      * The selection object returned by the browser.
105      *
106      * @property _currentSelection
107      * @type Range
108      * @default null
109      * @private
110      */
111     _currentSelection: null,
113     /**
114      * The cursor position in the equation textarea.
115      *
116      * @property _lastCursorPos
117      * @type Number
118      * @default 0
119      * @private
120      */
121     _lastCursorPos: 0,
123     /**
124      * A reference to the dialogue content.
125      *
126      * @property _content
127      * @type Node
128      * @private
129      */
130     _content: null,
132     /**
133      * The source equation we are editing in the text.
134      *
135      * @property _sourceEquation
136      * @type Object
137      * @private
138      */
139     _sourceEquation: null,
141     /**
142      * A reference to the tab focus set on each group.
143      *
144      * The keys are the IDs of the group, the value is the Node on which the focus is set.
145      *
146      * @property _groupFocus
147      * @type Object
148      * @private
149      */
150     _groupFocus: null,
152     /**
153      * Regular Expression patterns used to pick out the equations in a String.
154      *
155      * @property _equationPatterns
156      * @type Array
157      * @private
158      */
159     _equationPatterns: [
160         // We use space or not space because . does not match new lines.
161         // $$ blah $$.
162         /\$\$([\S\s]+?)\$\$/,
163         // E.g. "\( blah \)".
164         /\\\(([\S\s]+?)\\\)/,
165         // E.g. "\[ blah \]".
166         /\\\[([\S\s]+?)\\\]/,
167         // E.g. "[tex] blah [/tex]".
168         /\[tex\]([\S\s]+?)\[\/tex\]/
169     ],
171     initializer: function() {
172         this._groupFocus = {};
174         // If there is a tex filter active - enable this button.
175         if (this.get('texfilteractive')) {
176             // Add the button to the toolbar.
177             this.addButton({
178                 icon: 'e/math',
179                 callback: this._displayDialogue
180             });
182             // We need custom highlight logic for this button.
183             this.get('host').on('atto:selectionchanged', function() {
184                 if (this._resolveEquation()) {
185                     this.highlightButtons();
186                 } else {
187                     this.unHighlightButtons();
188                 }
189             }, this);
191             // We need to convert these to a non dom node based format.
192             this.editor.all('tex').each(function(texNode) {
193                 var replacement = Y.Node.create('<span>' +
194                         DELIMITERS.START + ' ' + texNode.get('text') + ' ' + DELIMITERS.END +
195                         '</span>');
196                 texNode.replace(replacement);
197             });
198         }
200     },
202     /**
203      * Display the equation editor.
204      *
205      * @method _displayDialogue
206      * @private
207      */
208     _displayDialogue: function() {
209         this._currentSelection = this.get('host').getSelection();
211         if (this._currentSelection === false) {
212             return;
213         }
215         // This needs to be done before the dialogue is opened because the focus will shift to the dialogue.
216         var equation = this._resolveEquation();
218         var dialogue = this.getDialogue({
219             headerContent: M.util.get_string('pluginname', COMPONENTNAME),
220             focusAfterHide: true,
221             width: 600,
222             focusOnShowSelector: SELECTORS.EQUATION_TEXT
223         });
225         var content = this._getDialogueContent();
226         dialogue.set('bodyContent', content);
228         content.one('.nav-item:first-child .nav-link').getDOMNode().click();
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         });
236         if (equation) {
237             content.one(SELECTORS.EQUATION_TEXT).set('text', equation);
238         }
239         this._updatePreview(false);
240     },
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() {
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;
258         // Prevent resolving equations when we don't have focus.
259         if (!this.get('host').isActive()) {
260             return false;
261         }
263         // Note this is a document fragment and YUI doesn't like them.
264         if (!selectedNode) {
265             return false;
266         }
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         }
273         this.sourceEquation = null;
275         selection = selection[0];
277         text = Y.one(selectedNode).get('text');
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"));
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);
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;
312                                 // We'll be returning the inner match for use in the editor itself.
313                                 returnValue = innerMatch[1];
315                                 // Save all data for later.
316                                 this.sourceEquation = {
317                                     // Outer match data.
318                                     startOuterPosition: startOuter,
319                                     endOuterPosition: endOuter,
320                                     outerMatch: match,
322                                     // Inner match data.
323                                     startInnerPosition: startInner,
324                                     endInnerPosition: endInner,
325                                     innerMatch: innerMatch
326                                 };
328                                 // This breaks out of both Y.Array.find functions.
329                                 return true;
330                             }
331                         }
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);
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     },
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;
363         host = this.get('host');
365         e.preventDefault();
366         this.getDialogue({
367             focusAfterHide: null
368         }).hide();
370         input = e.currentTarget.ancestor('.atto_form').one('textarea');
372         value = input.get('value');
373         if (value !== '') {
374             host.setSelection(this._currentSelection);
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);
385                 selectedNode.set('text', newText);
386             } else {
387                 // Insert the new equation.
388                 value = DELIMITERS.START + ' ' + value + ' ' + DELIMITERS.END;
389                 host.insertContentAtFocusPoint(value);
390             }
392             // Clean the YUI ids from the HTML.
393             this.markUpdated();
394         }
395     },
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     },
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;
437         if (e) {
438             e.preventDefault();
439         }
441         // Move the cursor so it does not break expressions.
442         // Start at the very beginning.
443         if (!currentPos) {
444             currentPos = 0;
445         }
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             if (equation.charAt(currentPos - 1) != '{') {
454                 // Now match to the end of the line.
455                 while (isChar.test(equation.charAt(currentPos)) &&
456                        currentPos < equation.length &&
457                        isChar.test(equation.charAt(currentPos - 1))) {
458                     currentPos += 1;
459                 }
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);
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         };
476         Y.io(url, {
477             context: this,
478             data: params,
479             timeout: 500,
480             on: {
481                 complete: this._loadPreview
482             }
483         });
484     },
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);
497         if (preview.status === 200) {
498             previewNode.setHTML(preview.responseText);
500             // Notify the filters about the modified nodes.
501             require(['core/event'], function(event) {
502                 event.notifyFilterContentUpdated(previewNode.getDOMNode());
503             });
504         }
505     },
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);
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         }));
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);
536         // Keyboard navigation in groups.
537         this._content.delegate('key', this._groupNavigation, 'down:37,39', SELECTORS.LIBRARY_BUTTON, this);
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);
545         return this._content;
546     },
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();
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;
565         if (index < 0) {
566             Y.log('Unable to find the current button in the list of buttons', 'debug', LOGNAME);
567             index = 0;
568         }
570         index += direction;
571         if (index < 0) {
572             index = buttons.size() - 1;
573         } else if (index >= buttons.size()) {
574             index = 0;
575         }
576         nextButton = buttons.item(index);
578         this._setGroupTabFocus(parent, nextButton);
579         nextButton.focus();
580     },
582     /**
583      * Sets tab focus for the group.
584      *
585      * @method _setGroupTabFocus
586      * @param {Node} button The node that focus should now be set to.
587      * @private
588      */
589     _setGroupTabFocus: function(parent, button) {
590         var parentId = parent.generateID();
592         // Unset the previous entry.
593         if (typeof this._groupFocus[parentId] !== 'undefined') {
594             this._groupFocus[parentId].setAttribute('tabindex', '-1');
595         }
597         // Set on the new entry.
598         this._groupFocus[parentId] = button;
599         button.setAttribute('tabindex', 0);
600         parent.setAttribute('aria-activedescendant', button.generateID());
601     },
603     /**
604      * Reponse to button presses in the TeX library panels.
605      *
606      * @method _selectLibraryItem
607      * @param {EventFacade} e
608      * @return {string}
609      * @private
610      */
611     _selectLibraryItem: function(e) {
612         var tex = e.currentTarget.getAttribute('data-tex'),
613         oldValue,
614         newValue,
615         input,
616         focusPoint = 0;
618         e.preventDefault();
620         // Set the group focus on the button.
621         this._setGroupTabFocus(e.currentTarget.get('parentNode'), e.currentTarget);
623         input = e.currentTarget.ancestor('.atto_form').one('textarea');
625         oldValue = input.get('value');
627         newValue = oldValue.substring(0, this._lastCursorPos);
628         if (newValue.charAt(newValue.length - 1) !== ' ') {
629             newValue += ' ';
630         }
631         newValue += tex;
632         focusPoint = newValue.length;
634         if (oldValue.charAt(this._lastCursorPos) !== ' ') {
635             newValue += ' ';
636         }
637         newValue += oldValue.substring(this._lastCursorPos, oldValue.length);
639         input.set('value', newValue);
640         input.focus();
642         var realInput = input.getDOMNode();
643         if (typeof realInput.selectionStart === "number") {
644             // Modern browsers have selectionStart and selectionEnd to control the cursor position.
645             realInput.selectionStart = realInput.selectionEnd = focusPoint;
646         } else if (typeof realInput.createTextRange !== "undefined") {
647             // Legacy browsers (IE<=9) use createTextRange().
648             var range = realInput.createTextRange();
649             range.moveToPoint(focusPoint);
650             range.select();
651         }
652         // Focus must be set before updating the preview for the cursor box to be in the correct location.
653         this._updatePreview(false);
654     },
656     /**
657      * Return the HTML for rendering the library of predefined buttons.
658      *
659      * @method _getLibraryContent
660      * @return {string}
661      * @private
662      */
663     _getLibraryContent: function() {
664         var template = Y.Handlebars.compile(TEMPLATES.LIBRARY),
665             library = this.get('library'),
666             content = '';
668         // Helper to iterate over a newline separated string.
669         Y.Handlebars.registerHelper('split', function(delimiter, str, options) {
670             var parts,
671                 current,
672                 out;
673             if (typeof delimiter === "undefined" || typeof str === "undefined") {
674                 Y.log('Handlebars split helper: String and delimiter are required.', 'debug', 'moodle-atto_equation-button');
675                 return '';
676             }
678             out = '';
679             parts = str.trim().split(delimiter);
680             while (parts.length > 0) {
681                 current = parts.shift().trim();
682                 out += options.fn(current);
683             }
685             return out;
686         });
687         content = template({
688             elementid: this.get('host').get('elementid'),
689             component: COMPONENTNAME,
690             library: library,
691             CSS: CSS,
692             DELIMITERS: DELIMITERS
693         });
695         var url = M.cfg.wwwroot + '/lib/editor/atto/plugins/equation/ajax.php';
696         var params = {
697             sesskey: M.cfg.sesskey,
698             contextid: this.get('contextid'),
699             action: 'filtertext',
700             text: content
701         };
703         var preview = Y.io(url, {
704             sync: true,
705             data: params,
706             method: 'POST'
707         });
709         if (preview.status === 200) {
710             content = preview.responseText;
711         }
712         return content;
713     }
714 }, {
715     ATTRS: {
716         /**
717          * Whether the TeX filter is currently active.
718          *
719          * @attribute texfilteractive
720          * @type Boolean
721          */
722         texfilteractive: {
723             value: false
724         },
726         /**
727          * The contextid to use when generating this preview.
728          *
729          * @attribute contextid
730          * @type String
731          */
732         contextid: {
733             value: null
734         },
736         /**
737          * The content of the example library.
738          *
739          * @attribute library
740          * @type object
741          */
742         library: {
743             value: {}
744         },
746         /**
747          * The link to the Moodle Docs page about TeX.
748          *
749          * @attribute texdocsurl
750          * @type string
751          */
752         texdocsurl: {
753             value: null
754         }
756     }
757 });