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