MDL-64506 templates: Move BS2 btns' to BS4 btns'
[moodle.git] / lib / editor / atto / plugins / equation / yui / build / moodle-atto_equation-button / moodle-atto_equation-button.js
1 YUI.add('moodle-atto_equation-button', function (Y, NAME) {
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/>.
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  */
24 /**
25  * Atto text editor equation plugin.
26  */
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 p-1 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="btn btn-secondary {{CSS.SUBMIT}}">{{get_string "saveequation" component}}</button>' +
71                 '</div>' +
72             '</form>',
73         LIBRARY: '' +
74             '<div class="{{CSS.LIBRARY}}">' +
75                 '<ul class="root nav nav-tabs m-b-1" role="tablist">' +
76                     '{{#each library}}' +
77                         '<li  class="nav-item">' +
78                             '<a class="nav-link" href="#{{../elementid}}_{{../CSS.LIBRARY_GROUP_PREFIX}}_{{@key}}" ' +
79                                 ' role="tab" data-toggle="tab">' +
80                                 '{{get_string groupname ../component}}' +
81                             '</a>' +
82                         '</li>' +
83                     '{{/each}}' +
84                 '</ul>' +
85                 '<div class="tab-content m-b-1 {{CSS.LIBRARY_GROUPS}}">' +
86                     '{{#each library}}' +
87                         '<div data-medium-type="{{CSS.LINK}}" class="tab-pane" ' +
88                         'id="{{../elementid}}_{{../CSS.LIBRARY_GROUP_PREFIX}}_{{@key}}">' +
89                             '<div role="toolbar">' +
90                             '{{#split "\n" elements}}' +
91                                 '<button class="btn btn-secondary" tabindex="-1" data-tex="{{this}}"' +
92                                     'aria-label="{{this}}" title="{{this}}">' +
93                                     '{{../../DELIMITERS.START}}{{this}}{{../../DELIMITERS.END}}' +
94                                 '</button>' +
95                             '{{/split}}' +
96                             '</div>' +
97                         '</div>' +
98                     '{{/each}}' +
99                 '</div>' +
100             '</div>'
101     };
103 Y.namespace('M.atto_equation').Button = Y.Base.create('button', Y.M.editor_atto.EditorPlugin, [], {
105     /**
106      * The selection object returned by the browser.
107      *
108      * @property _currentSelection
109      * @type Range
110      * @default null
111      * @private
112      */
113     _currentSelection: null,
115     /**
116      * The cursor position in the equation textarea.
117      *
118      * @property _lastCursorPos
119      * @type Number
120      * @default 0
121      * @private
122      */
123     _lastCursorPos: 0,
125     /**
126      * A reference to the dialogue content.
127      *
128      * @property _content
129      * @type Node
130      * @private
131      */
132     _content: null,
134     /**
135      * The source equation we are editing in the text.
136      *
137      * @property _sourceEquation
138      * @type Object
139      * @private
140      */
141     _sourceEquation: null,
143     /**
144      * A reference to the tab focus set on each group.
145      *
146      * The keys are the IDs of the group, the value is the Node on which the focus is set.
147      *
148      * @property _groupFocus
149      * @type Object
150      * @private
151      */
152     _groupFocus: null,
154     /**
155      * Regular Expression patterns used to pick out the equations in a String.
156      *
157      * @property _equationPatterns
158      * @type Array
159      * @private
160      */
161     _equationPatterns: [
162         // We use space or not space because . does not match new lines.
163         // $$ blah $$.
164         /\$\$([\S\s]+?)\$\$/,
165         // E.g. "\( blah \)".
166         /\\\(([\S\s]+?)\\\)/,
167         // E.g. "\[ blah \]".
168         /\\\[([\S\s]+?)\\\]/,
169         // E.g. "[tex] blah [/tex]".
170         /\[tex\]([\S\s]+?)\[\/tex\]/
171     ],
173     initializer: function() {
174         this._groupFocus = {};
176         // If there is a tex filter active - enable this button.
177         if (this.get('texfilteractive')) {
178             // Add the button to the toolbar.
179             this.addButton({
180                 icon: 'e/math',
181                 callback: this._displayDialogue
182             });
184             // We need custom highlight logic for this button.
185             this.get('host').on('atto:selectionchanged', function() {
186                 if (this._resolveEquation()) {
187                     this.highlightButtons();
188                 } else {
189                     this.unHighlightButtons();
190                 }
191             }, this);
193             // We need to convert these to a non dom node based format.
194             this.editor.all('tex').each(function(texNode) {
195                 var replacement = Y.Node.create('<span>' +
196                         DELIMITERS.START + ' ' + texNode.get('text') + ' ' + DELIMITERS.END +
197                         '</span>');
198                 texNode.replace(replacement);
199             });
200         }
202     },
204     /**
205      * Display the equation editor.
206      *
207      * @method _displayDialogue
208      * @private
209      */
210     _displayDialogue: function() {
211         this._currentSelection = this.get('host').getSelection();
213         if (this._currentSelection === false) {
214             return;
215         }
217         // This needs to be done before the dialogue is opened because the focus will shift to the dialogue.
218         var equation = this._resolveEquation();
220         var dialogue = this.getDialogue({
221             headerContent: M.util.get_string('pluginname', COMPONENTNAME),
222             focusAfterHide: true,
223             width: 600,
224             focusOnShowSelector: SELECTORS.EQUATION_TEXT
225         });
227         var content = this._getDialogueContent();
228         dialogue.set('bodyContent', content);
230         content.one('.nav-item:first-child .nav-link').getDOMNode().click();
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         });
238         if (equation) {
239             content.one(SELECTORS.EQUATION_TEXT).set('text', equation);
240         }
241         this._updatePreview(false);
242     },
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() {
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;
260         // Prevent resolving equations when we don't have focus.
261         if (!this.get('host').isActive()) {
262             return false;
263         }
265         // Note this is a document fragment and YUI doesn't like them.
266         if (!selectedNode) {
267             return false;
268         }
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         }
275         this.sourceEquation = null;
277         selection = selection[0];
279         text = Y.one(selectedNode).get('text');
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"));
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);
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;
314                                 // We'll be returning the inner match for use in the editor itself.
315                                 returnValue = innerMatch[1];
317                                 // Save all data for later.
318                                 this.sourceEquation = {
319                                     // Outer match data.
320                                     startOuterPosition: startOuter,
321                                     endOuterPosition: endOuter,
322                                     outerMatch: match,
324                                     // Inner match data.
325                                     startInnerPosition: startInner,
326                                     endInnerPosition: endInner,
327                                     innerMatch: innerMatch
328                                 };
330                                 // This breaks out of both Y.Array.find functions.
331                                 return true;
332                             }
333                         }
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);
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     },
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;
365         host = this.get('host');
367         e.preventDefault();
368         this.getDialogue({
369             focusAfterHide: null
370         }).hide();
372         input = e.currentTarget.ancestor('.atto_form').one('textarea');
374         value = input.get('value');
375         if (value !== '') {
376             host.setSelection(this._currentSelection);
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);
387                 selectedNode.set('text', newText);
388             } else {
389                 // Insert the new equation.
390                 value = DELIMITERS.START + ' ' + value + ' ' + DELIMITERS.END;
391                 host.insertContentAtFocusPoint(value);
392             }
394             // Clean the YUI ids from the HTML.
395             this.markUpdated();
396         }
397     },
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     },
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;
439         if (e) {
440             e.preventDefault();
441         }
443         // Move the cursor so it does not break expressions.
444         // Start at the very beginning.
445         if (!currentPos) {
446             currentPos = 0;
447         }
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             if (equation.charAt(currentPos - 1) != '{') {
456                 // Now match to the end of the line.
457                 while (isChar.test(equation.charAt(currentPos)) &&
458                        currentPos < equation.length &&
459                        isChar.test(equation.charAt(currentPos - 1))) {
460                     currentPos += 1;
461                 }
462             }
463         }
464         // Save the cursor position - for insertion from the library.
465         this._lastCursorPos = currentPos;
466         equation = prefix + equation.substring(0, currentPos) + cursorLatex + equation.substring(currentPos);
468         equation = DELIMITERS.START + ' ' + equation + ' ' + DELIMITERS.END;
469         // Make an ajax request to the filter.
470         url = M.cfg.wwwroot + '/lib/editor/atto/plugins/equation/ajax.php';
471         params = {
472             sesskey: M.cfg.sesskey,
473             contextid: this.get('contextid'),
474             action: 'filtertext',
475             text: equation
476         };
478         Y.io(url, {
479             context: this,
480             data: params,
481             timeout: 500,
482             on: {
483                 complete: this._loadPreview
484             }
485         });
486     },
488     /**
489      * Load returned preview text into preview
490      *
491      * @param {String} id
492      * @param {EventFacade} e
493      * @method _loadPreview
494      * @private
495      */
496     _loadPreview: function(id, preview) {
497         var previewNode = this._content.one(SELECTORS.EQUATION_PREVIEW);
499         if (preview.status === 200) {
500             previewNode.setHTML(preview.responseText);
502             // Notify the filters about the modified nodes.
503             require(['core/event'], function(event) {
504                 event.notifyFilterContentUpdated(previewNode.getDOMNode());
505             });
506         }
507     },
509     /**
510      * Return the dialogue content for the tool, attaching any required
511      * events.
512      *
513      * @method _getDialogueContent
514      * @return {Node}
515      * @private
516      */
517     _getDialogueContent: function() {
518         var library = this._getLibraryContent(),
519             throttledUpdate = this._throttle(this._updatePreview, 500),
520             template = Y.Handlebars.compile(TEMPLATES.FORM);
522         this._content = Y.Node.create(template({
523             elementid: this.get('host').get('elementid'),
524             component: COMPONENTNAME,
525             library: library,
526             texdocsurl: this.get('texdocsurl'),
527             CSS: CSS
528         }));
530         // Sets the default focus.
531         this._content.all(SELECTORS.LIBRARY_GROUP).each(function(group) {
532             // The first button gets the focus.
533             this._setGroupTabFocus(group, group.one('button'));
534             // Sometimes the filter adds an anchor in the button, no tabindex on that.
535             group.all('button a').setAttribute('tabindex', '-1');
536         }, this);
538         // Keyboard navigation in groups.
539         this._content.delegate('key', this._groupNavigation, 'down:37,39', SELECTORS.LIBRARY_BUTTON, this);
541         this._content.one(SELECTORS.SUBMIT).on('click', this._setEquation, this);
542         this._content.one(SELECTORS.EQUATION_TEXT).on('valuechange', throttledUpdate, this);
543         this._content.one(SELECTORS.EQUATION_TEXT).on('mouseup', throttledUpdate, this);
544         this._content.one(SELECTORS.EQUATION_TEXT).on('keyup', throttledUpdate, this);
545         this._content.delegate('click', this._selectLibraryItem, SELECTORS.LIBRARY_BUTTON, this);
547         return this._content;
548     },
550     /**
551      * Callback handling the keyboard navigation in the groups of the library.
552      *
553      * @param {EventFacade} e The event.
554      * @method _groupNavigation
555      * @private
556      */
557     _groupNavigation: function(e) {
558         e.preventDefault();
560         var current = e.currentTarget,
561             parent = current.get('parentNode'), // This must be the <div> containing all the buttons of the group.
562             buttons = parent.all('button'),
563             direction = e.keyCode !== 37 ? 1 : -1,
564             index = buttons.indexOf(current),
565             nextButton;
567         if (index < 0) {
568             index = 0;
569         }
571         index += direction;
572         if (index < 0) {
573             index = buttons.size() - 1;
574         } else if (index >= buttons.size()) {
575             index = 0;
576         }
577         nextButton = buttons.item(index);
579         this._setGroupTabFocus(parent, nextButton);
580         nextButton.focus();
581     },
583     /**
584      * Sets tab focus for the group.
585      *
586      * @method _setGroupTabFocus
587      * @param {Node} button The node that focus should now be set to.
588      * @private
589      */
590     _setGroupTabFocus: function(parent, button) {
591         var parentId = parent.generateID();
593         // Unset the previous entry.
594         if (typeof this._groupFocus[parentId] !== 'undefined') {
595             this._groupFocus[parentId].setAttribute('tabindex', '-1');
596         }
598         // Set on the new entry.
599         this._groupFocus[parentId] = button;
600         button.setAttribute('tabindex', 0);
601         parent.setAttribute('aria-activedescendant', button.generateID());
602     },
604     /**
605      * Reponse to button presses in the TeX library panels.
606      *
607      * @method _selectLibraryItem
608      * @param {EventFacade} e
609      * @return {string}
610      * @private
611      */
612     _selectLibraryItem: function(e) {
613         var tex = e.currentTarget.getAttribute('data-tex'),
614         oldValue,
615         newValue,
616         input,
617         focusPoint = 0;
619         e.preventDefault();
621         // Set the group focus on the button.
622         this._setGroupTabFocus(e.currentTarget.get('parentNode'), e.currentTarget);
624         input = e.currentTarget.ancestor('.atto_form').one('textarea');
626         oldValue = input.get('value');
628         newValue = oldValue.substring(0, this._lastCursorPos);
629         if (newValue.charAt(newValue.length - 1) !== ' ') {
630             newValue += ' ';
631         }
632         newValue += tex;
633         focusPoint = newValue.length;
635         if (oldValue.charAt(this._lastCursorPos) !== ' ') {
636             newValue += ' ';
637         }
638         newValue += oldValue.substring(this._lastCursorPos, oldValue.length);
640         input.set('value', newValue);
641         input.focus();
643         var realInput = input.getDOMNode();
644         if (typeof realInput.selectionStart === "number") {
645             // Modern browsers have selectionStart and selectionEnd to control the cursor position.
646             realInput.selectionStart = realInput.selectionEnd = focusPoint;
647         } else if (typeof realInput.createTextRange !== "undefined") {
648             // Legacy browsers (IE<=9) use createTextRange().
649             var range = realInput.createTextRange();
650             range.moveToPoint(focusPoint);
651             range.select();
652         }
653         // Focus must be set before updating the preview for the cursor box to be in the correct location.
654         this._updatePreview(false);
655     },
657     /**
658      * Return the HTML for rendering the library of predefined buttons.
659      *
660      * @method _getLibraryContent
661      * @return {string}
662      * @private
663      */
664     _getLibraryContent: function() {
665         var template = Y.Handlebars.compile(TEMPLATES.LIBRARY),
666             library = this.get('library'),
667             content = '';
669         // Helper to iterate over a newline separated string.
670         Y.Handlebars.registerHelper('split', function(delimiter, str, options) {
671             var parts,
672                 current,
673                 out;
674             if (typeof delimiter === "undefined" || typeof str === "undefined") {
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 });
760 }, '@VERSION@', {
761     "requires": [
762         "moodle-editor_atto-plugin",
763         "moodle-core-event",
764         "io",
765         "event-valuechange",
766         "tabview",
767         "array-extras"
768     ]
769 });