1 // This file is part of Moodle - http://moodle.org/
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.
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/>.
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
23 * Atto text editor equation plugin.
27 * Atto equation editor.
29 * @namespace M.atto_equation
31 * @extends M.editor_atto.EditorPlugin
34 var COMPONENTNAME = 'atto_equation',
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'
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'
55 '<form class="atto_form">' +
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">' +
63 '<button class="{{CSS.SUBMIT}}">{{get_string "saveequation" component}}</button>' +
67 '<div class="{{CSS.LIBRARY}}">' +
70 '<li><a href="#{{elementid}}_{{../CSS.LIBRARY_GROUP_PREFIX}}{{@key}}">{{get_string groupname ../component}}</a></li>' +
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>' +
85 Y.namespace('M.atto_equation').Button = Y.Base.create('button', Y.M.editor_atto.EditorPlugin, [], {
88 * The selection object returned by the browser.
90 * @property _currentSelection
95 _currentSelection: null,
98 * The cursor position in the equation textarea.
100 * @property _lastCursorPos
108 * A reference to the dialogue content.
117 * The source equation we are editing in the text.
119 * @property _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.
131 callback: this._displayDialogue
134 // We need custom highlight logic for this button.
135 this.get('host').on('atto:selectionchanged', function() {
136 if (this._resolveEquation()) {
137 this.highlightButtons();
139 this.unHighlightButtons();
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);
153 * Display the equation editor.
155 * @method _displayDialogue
158 _displayDialogue: function() {
159 this._currentSelection = this.get('host').getSelection();
161 if (this._currentSelection === false) {
165 var dialogue = this.getDialogue({
166 headerContent: M.util.get_string('pluginname', COMPONENTNAME),
167 focusAfterHide: true,
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({
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();
187 content.one(SELECTORS.EQUATION_TEXT).set('text', equation);
189 this._updatePreview(false);
193 * If there is selected text and it is part of an equation,
194 * extract the equation (and set it in the form).
196 * @method _resolveEquation
198 * @return {String|Boolean} The equation or false.
200 _resolveEquation: function() {
202 // Find the equation in the surrounding text.
203 var selectedNode = this.get('host').getSelectionParentNode(),
208 // Note this is a document fragment and YUI doesn't like them.
213 text = Y.one(selectedNode).get('text');
214 // We use space or not space because . does not match new lines.
216 patterns.push(/\$\$([\S\s]*)\$\$/);
218 patterns.push(/\\\(([\S\s]*)\\\)/);
220 patterns.push(/\\\[([\S\s]*)\\\]/);
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) {
229 // Remember the inner match so we can replace it later.
230 this.sourceEquation = equation = equation.shift();
236 this.sourceEquation = '';
241 * Handle insertion of a new equation, or update of an existing one.
243 * @method _setEquation
244 * @param {EventFacade} e
247 _setEquation: function(e) {
254 host = this.get('host');
261 input = e.currentTarget.ancestor('.atto_form').one('textarea');
263 value = input.get('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);
275 // Insert the new equation.
276 value = DELIMITERS.START + ' ' + value + ' ' + DELIMITERS.END;
277 host.insertContentAtFocusPoint(value);
280 // Clean the YUI ids from the HTML.
286 * Smart throttle, only call a function every delay milli seconds,
287 * and always run the last call.
289 * @param {function} fn
290 * @param {Integer} delay - delay in milliseconds
294 _throttle: function(fn, delay) {
297 var context = this, args = arguments;
299 timer = setTimeout(function () {
300 fn.apply(context, args);
306 * Update the preview div to match the current equation.
308 * @param {EventFacade} e
309 * @method _updatePreview
312 _updatePreview: function(e) {
313 var textarea = this._content.one(SELECTORS.EQUATION_TEXT),
314 equation = textarea.get('value'),
317 currentPos = textarea.get('selectionStart'),
319 cursorLatex = '\\square ',
329 // Move the cursor so it does not break expressions.
331 while (equation.charAt(currentPos) === '\\' && currentPos > 0) {
334 isChar = /[a-zA-Z\{\}]/;
335 while (isChar.test(equation.charAt(currentPos)) && currentPos < equation.length) {
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';
347 sesskey: M.cfg.sesskey,
348 contextid: this.get('contextid'),
349 action: 'filtertext',
353 preview = Y.io(url, { sync: true,
355 if (preview.status === 200) {
356 previewNode.setHTML(preview.responseText);
357 Y.fire(M.core.event.FILTER_CONTENT_UPDATED, {nodes: (new Y.NodeList(previewNode))});
362 * Return the dialogue content for the tool, attaching any required
365 * @method _getDialogueContent
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,
377 texdocsurl: this.get('texdocsurl'),
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;
391 * Reponse to button presses in the TeX library panels.
393 * @method _selectLibraryItem
394 * @param {EventFacade} e
398 _selectLibraryItem: function(e) {
399 var tex = e.currentTarget.getAttribute('data-tex');
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);
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);
423 // Focus must be set before updating the preview for the cursor box to be in the correct location.
424 this._updatePreview(false);
428 * Return the HTML for rendering the library of predefined buttons.
430 * @method _getLibraryContent
434 _getLibraryContent: function() {
435 var template = Y.Handlebars.compile(TEMPLATES.LIBRARY),
436 library = this.get('library'),
439 // Helper to iterate over a newline separated string.
440 Y.Handlebars.registerHelper('split', function(delimiter, str, options) {
444 if (typeof delimiter === "undefined" || typeof str === "undefined") {
445 Y.log('Handlebars split helper: String and delimiter are required.', 'debug', 'moodle-atto_equation-button');
450 parts = str.trim().split(delimiter);
451 while (parts.length > 0) {
452 current = parts.shift().trim();
453 out += options.fn(current);
459 elementid: this.get('host').get('elementid'),
460 component: COMPONENTNAME,
463 DELIMITERS: DELIMITERS
466 var url = M.cfg.wwwroot + '/lib/editor/atto/plugins/equation/ajax.php';
468 sesskey: M.cfg.sesskey,
469 contextid: this.get('contextid'),
470 action: 'filtertext',
474 preview = Y.io(url, {
480 if (preview.status === 200) {
481 content = preview.responseText;
488 * Whether the TeX filter is currently active.
490 * @attribute texfilteractive
498 * The contextid to use when generating this preview.
500 * @attribute contextid
508 * The content of the example library.
518 * The link to the Moodle Docs page about TeX.
520 * @attribute texdocsurl