MDL-43856 MathJax: Improvements to the MathJax filter
[moodle.git] / lib / editor / atto / plugins / equation / yui / src / button / js / button.js
blob92a3f209016cf3cd9ba06a4ac77264b747098993
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.
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  */
34 var COMPONENTNAME = '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_GROUP_PREFIX: 'atto_equation_library'
41     },
42     SELECTORS = {
43         LIBRARY_GROUP_PREFIX: '.' + CSS.LIBRARY_GROUP_PREFIX,
44         EQUATION_TEXT: '.' + CSS.EQUATION_TEXT,
45         EQUATION_PREVIEW: '.' + CSS.EQUATION_PREVIEW,
46         SUBMIT: '.' + CSS.SUBMIT,
47         LIBRARY_BUTTON: '.' + CSS.LIBRARY + ' button'
48     },
49     DELIMITERS = {
50         START: '\\(',
51         END: '\\)'
52     },
53     TEMPLATES = {
54         FORM: '' +
55             '<form class="atto_form">' +
56                 '{{{library}}}' +
57                 '<label for="{{elementid}}_{{CSS.EQUATION_TEXT}}">{{{get_string "editequation" component texdocsurl}}}</label>' +
58                 '<textarea class="fullwidth {{CSS.EQUATION_TEXT}}" id="{{elementid}}_{{CSS.EQUATION_TEXT}}" rows="8"></textarea><br/>' +
59                 '<label for="{{elementid}}_{{CSS.EQUATION_PREVIEW}}">{{get_string "preview" component}}</label>' +
60                 '<div class="fullwidth {{CSS.EQUATION_PREVIEW}}" id="{{elementid}}_{{CSS.EQUATION_PREVIEW}}"></div>' +
61                 '<div class="mdl-align">' +
62                     '<br/>' +
63                     '<button class="{{CSS.SUBMIT}}">{{get_string "saveequation" component}}</button>' +
64                 '</div>' +
65             '</form>',
66         LIBRARY: '' +
67             '<div class="{{CSS.LIBRARY}}">' +
68                 '<ul>' +
69                     '{{#each library}}' +
70                         '<li><a href="#{{elementid}}_{{../CSS.LIBRARY_GROUP_PREFIX}}{{@key}}">{{get_string groupname ../component}}</a></li>' +
71                     '{{/each}}' +
72                 '</ul>' +
73                 '<div>' +
74                     '{{#each library}}' +
75                         '<div id="{{elementid}}_{{../CSS.LIBRARY_GROUP_PREFIX}}{{@key}}">' +
76                         '{{#split "\n" elements}}' +
77                             '<button data-tex="{{this}}" title="{{this}}">{{../../DELIMITERS.START}}{{this}}{{../../DELIMITERS.END}}</button>' +
78                         '{{/split}}' +
79                         '</div>' +
80                     '{{/each}}' +
81                 '</div>' +
82             '</div>'
83     };
85 Y.namespace('M.atto_equation').Button = Y.Base.create('button', Y.M.editor_atto.EditorPlugin, [], {
87     /**
88      * The selection object returned by the browser.
89      *
90      * @property _currentSelection
91      * @type Range
92      * @default null
93      * @private
94      */
95     _currentSelection: null,
97     /**
98      * The cursor position in the equation textarea.
99      *
100      * @property _lastCursorPos
101      * @type Number
102      * @default 0
103      * @private
104      */
105     _lastCursorPos: 0,
107     /**
108      * A reference to the dialogue content.
109      *
110      * @property _content
111      * @type Node
112      * @private
113      */
114     _content: null,
116     /**
117      * The source equation we are editing in the text.
118      *
119      * @property _sourceEquation
120      * @type String
121      * @private
122      */
123     _sourceEquation: '',
125     initializer: function() {
126         // If there is a tex filter active - enable this button.
127         if (this.get('texfilteractive')) {
128             // Add the button to the toolbar.
129             this.addButton({
130                 icon: 'e/math',
131                 callback: this._displayDialogue
132             });
134             // We need custom highlight logic for this button.
135             this.get('host').on('atto:selectionchanged', function() {
136                 if (this._resolveEquation()) {
137                     this.highlightButtons();
138                 } else {
139                     this.unHighlightButtons();
140                 }
141             }, this);
143             // We need to convert these to a non dom node based format.
144             this.editor.all('tex').each(function (texNode) {
145                 var replacement = Y.Node.create('<span>' + DELIMITERS.START + ' ' + texNode.get('text') + ' ' + DELIMITERS.END + '</span>');
146                 texNode.replace(replacement);
147             });
148         }
150     },
152     /**
153      * Display the equation editor.
154      *
155      * @method _displayDialogue
156      * @private
157      */
158     _displayDialogue: function() {
159         this._currentSelection = this.get('host').getSelection();
161         if (this._currentSelection === false) {
162             return;
163         }
165         var dialogue = this.getDialogue({
166             headerContent: M.util.get_string('pluginname', COMPONENTNAME),
167             focusAfterHide: true,
168             width: 600
169         });
171         var content = this._getDialogueContent();
172         dialogue.set('bodyContent', content);
174         var library = content.one(SELECTORS.LIBRARY_GROUP_PREFIX);
176         var tabview = new Y.TabView({
177             srcNode: library
178         });
180         tabview.render();
181         dialogue.show();
182         // Trigger any JS filters to reprocess the new nodes.
183         Y.fire(M.core.event.FILTER_CONTENT_UPDATED, {nodes: (new Y.NodeList(dialogue.get('boundingBox')))});
185         var equation = this._resolveEquation();
186         if (equation) {
187             content.one(SELECTORS.EQUATION_TEXT).set('text', equation);
188         }
189         this._updatePreview(false);
190     },
192     /**
193      * If there is selected text and it is part of an equation,
194      * extract the equation (and set it in the form).
195      *
196      * @method _resolveEquation
197      * @private
198      * @return {String|Boolean} The equation or false.
199      */
200     _resolveEquation: function() {
202         // Find the equation in the surrounding text.
203         var selectedNode = this.get('host').getSelectionParentNode(),
204             text,
205             equation,
206             patterns = [], i;
208         // Note this is a document fragment and YUI doesn't like them.
209         if (!selectedNode) {
210             return false;
211         }
213         text = Y.one(selectedNode).get('text');
214         // We use space or not space because . does not match new lines.
215         // $$ blah $$
216         patterns.push(/\$\$([\S\s]*)\$\$/);
217         // \( blah \)
218         patterns.push(/\\\(([\S\s]*)\\\)/);
219         // \[ blah \]
220         patterns.push(/\\\[([\S\s]*)\\\]/);
221         // [tex] blah [/tex]
222         patterns.push(/\[tex\]([\S\s]*)\[\/tex\]/);
224         for (i = 0; i < patterns.length; i++) {
225             pattern = patterns[i];
226             equation = pattern.exec(text);
227             if (equation && equation.length) {
228                 equation.shift();
229                 // Remember the inner match so we can replace it later.
230                 this.sourceEquation = equation = equation.shift();
232                 return equation;
233             }
234         }
236         this.sourceEquation = '';
237         return false;
238     },
240     /**
241      * Handle insertion of a new equation, or update of an existing one.
242      *
243      * @method _setEquation
244      * @param {EventFacade} e
245      * @private
246      */
247     _setEquation: function(e) {
248         var input,
249             selectedNode,
250             text,
251             value,
252             host;
254         host = this.get('host');
256         e.preventDefault();
257         this.getDialogue({
258             focusAfterHide: null
259         }).hide();
261         input = e.currentTarget.ancestor('.atto_form').one('textarea');
263         value = input.get('value');
264         if (value !== '') {
265             host.setSelection(this._currentSelection);
267             if (this.sourceEquation.length) {
268                 // Replace the equation.
269                 selectedNode = Y.one(host.getSelectionParentNode());
270                 text = selectedNode.get('text');
272                 text = text.replace(this.sourceEquation, value);
273                 selectedNode.set('text', text);
274             } else {
275                 // Insert the new equation.
276                 value = DELIMITERS.START + ' ' + value + ' ' + DELIMITERS.END;
277                 host.insertContentAtFocusPoint(value);
278             }
280             // Clean the YUI ids from the HTML.
281             this.markUpdated();
282         }
283     },
285     /**
286      * Smart throttle, only call a function every delay milli seconds,
287      * and always run the last call.
288      *
289      * @param {function} fn
290      * @param {Integer} delay - delay in milliseconds
291      * @method _throttle
292      * @private
293      */
294     _throttle: function(fn, delay) {
295         var timer = null;
296         return function () {
297             var context = this, args = arguments;
298             clearTimeout(timer);
299             timer = setTimeout(function () {
300               fn.apply(context, args);
301             }, delay);
302         };
303     },
305     /**
306      * Update the preview div to match the current equation.
307      *
308      * @param {EventFacade} e
309      * @method _updatePreview
310      * @private
311      */
312     _updatePreview: function(e) {
313         var textarea = this._content.one(SELECTORS.EQUATION_TEXT),
314             equation = textarea.get('value'),
315             url,
316             preview,
317             currentPos = textarea.get('selectionStart'),
318             prefix = '',
319             cursorLatex = '\\square ',
320             isChar;
322         if (e) {
323             e.preventDefault();
324         }
326         if (!currentPos) {
327             currentPos = 0;
328         }
329         // Move the cursor so it does not break expressions.
330         //
331         while (equation.charAt(currentPos) === '\\' && currentPos > 0) {
332             currentPos -= 1;
333         }
334         isChar = /[a-zA-Z\{\}]/;
335         while (isChar.test(equation.charAt(currentPos)) && currentPos < equation.length) {
336             currentPos += 1;
337         }
338         // Save the cursor position - for insertion from the library.
339         this._lastCursorPos = currentPos;
340         equation = prefix + equation.substring(0, currentPos) + cursorLatex + equation.substring(currentPos);
342         var previewNode = this._content.one(SELECTORS.EQUATION_PREVIEW);
343         equation = DELIMITERS.START + ' ' + equation + ' ' + DELIMITERS.END;
344         // Make an ajax request to the filter.
345         url = M.cfg.wwwroot + '/lib/editor/atto/plugins/equation/ajax.php';
346         params = {
347             sesskey: M.cfg.sesskey,
348             contextid: this.get('contextid'),
349             action: 'filtertext',
350             text: equation
351         };
353         preview = Y.io(url, { sync: true,
354                               data: params });
355         if (preview.status === 200) {
356             previewNode.setHTML(preview.responseText);
357             Y.fire(M.core.event.FILTER_CONTENT_UPDATED, {nodes: (new Y.NodeList(previewNode))});
358         }
359     },
361     /**
362      * Return the dialogue content for the tool, attaching any required
363      * events.
364      *
365      * @method _getDialogueContent
366      * @return {Node}
367      * @private
368      */
369     _getDialogueContent: function() {
370         var library = this._getLibraryContent(),
371             template = Y.Handlebars.compile(TEMPLATES.FORM);
373         this._content = Y.Node.create(template({
374             elementid: this.get('host').get('elementid'),
375             component: COMPONENTNAME,
376             library: library,
377             texdocsurl: this.get('texdocsurl'),
378             CSS: CSS
379         }));
381         this._content.one(SELECTORS.SUBMIT).on('click', this._setEquation, this);
382         this._content.one(SELECTORS.EQUATION_TEXT).on('valuechange', this._throttle(this._updatePreview, 500), this);
383         this._content.one(SELECTORS.EQUATION_TEXT).on('mouseup', this._throttle(this._updatePreview, 500), this);
384         this._content.one(SELECTORS.EQUATION_TEXT).on('keyup', this._throttle(this._updatePreview, 500), this);
385         this._content.delegate('click', this._selectLibraryItem, SELECTORS.LIBRARY_BUTTON, this);
387         return this._content;
388     },
390     /**
391      * Reponse to button presses in the TeX library panels.
392      *
393      * @method _selectLibraryItem
394      * @param {EventFacade} e
395      * @return {string}
396      * @private
397      */
398     _selectLibraryItem: function(e) {
399         var tex = e.currentTarget.getAttribute('data-tex');
401         e.preventDefault();
403         input = e.currentTarget.ancestor('.atto_form').one('textarea');
405         value = input.get('value');
407         value = value.substring(0, this._lastCursorPos) + tex + value.substring(this._lastCursorPos, value.length);
409         input.set('value', value);
410         input.focus();
412         var focusPoint = this._lastCursorPos + tex.length,
413             realInput = input.getDOMNode();
414         if (typeof realInput.selectionStart === "number") {
415             // Modern browsers have selectionStart and selectionEnd to control the cursor position.
416             realInput.selectionStart = realInput.selectionEnd = focusPoint;
417         } else if (typeof realInput.createTextRange !== "undefined") {
418             // Legacy browsers (IE<=9) use createTextRange().
419             var range = realInput.createTextRange();
420             range.moveToPoint(focusPoint);
421             range.select();
422         }
423         // Focus must be set before updating the preview for the cursor box to be in the correct location.
424         this._updatePreview(false);
425     },
427     /**
428      * Return the HTML for rendering the library of predefined buttons.
429      *
430      * @method _getLibraryContent
431      * @return {string}
432      * @private
433      */
434     _getLibraryContent: function() {
435         var template = Y.Handlebars.compile(TEMPLATES.LIBRARY),
436             library = this.get('library'),
437             content = '';
439         // Helper to iterate over a newline separated string.
440         Y.Handlebars.registerHelper('split', function(delimiter, str, options) {
441             var parts,
442                 current,
443                 out;
444             if (typeof delimiter === "undefined" || typeof str === "undefined") {
445                 Y.log('Handlebars split helper: String and delimiter are required.', 'debug', 'moodle-atto_equation-button');
446                 return '';
447             }
449             out = '';
450             parts = str.trim().split(delimiter);
451             while (parts.length > 0) {
452                 current = parts.shift().trim();
453                 out += options.fn(current);
454             }
456             return out;
457         });
458         content = template({
459             elementid: this.get('host').get('elementid'),
460             component: COMPONENTNAME,
461             library: library,
462             CSS: CSS,
463             DELIMITERS: DELIMITERS
464         });
466         var url = M.cfg.wwwroot + '/lib/editor/atto/plugins/equation/ajax.php';
467         var params = {
468             sesskey: M.cfg.sesskey,
469             contextid: this.get('contextid'),
470             action: 'filtertext',
471             text: content
472         };
474         preview = Y.io(url, {
475             sync: true,
476             data: params,
477             method: 'POST'
478         });
480         if (preview.status === 200) {
481             content = preview.responseText;
482         }
483         return content;
484     }
485 }, {
486     ATTRS: {
487         /**
488          * Whether the TeX filter is currently active.
489          *
490          * @attribute texfilteractive
491          * @type Boolean
492          */
493         texfilteractive: {
494             value: false
495         },
497         /**
498          * The contextid to use when generating this preview.
499          *
500          * @attribute contextid
501          * @type String
502          */
503         contextid: {
504             value: null
505         },
507         /**
508          * The content of the example library.
509          *
510          * @attribute library
511          * @type object
512          */
513         library: {
514             value: {}
515         },
517         /**
518          * The link to the Moodle Docs page about TeX.
519          *
520          * @attribute texdocsurl
521          * @type string
522          */
523         texdocsurl: {
524             value: null
525         }
527     }