* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
/**
* Atto text editor equation plugin.
*/
/**
* Atto equation editor.
*
* @namespace M.atto_equation
* @class Button
* @extends M.editor_atto.EditorPlugin
*/
var COMPONENTNAME = 'atto_equation',
LOGNAME = 'atto_equation',
CSS = {
EQUATION_TEXT: 'atto_equation_equation',
EQUATION_PREVIEW: 'atto_equation_preview',
SUBMIT: 'atto_equation_submit',
LIBRARY: 'atto_equation_library',
LIBRARY_GROUPS: 'atto_equation_groups',
LIBRARY_GROUP_PREFIX: 'atto_equation_group'
},
SELECTORS = {
LIBRARY: '.' + CSS.LIBRARY,
LIBRARY_GROUP: '.' + CSS.LIBRARY_GROUPS + ' > div > div',
EQUATION_TEXT: '.' + CSS.EQUATION_TEXT,
EQUATION_PREVIEW: '.' + CSS.EQUATION_PREVIEW,
SUBMIT: '.' + CSS.SUBMIT,
LIBRARY_BUTTON: '.' + CSS.LIBRARY + ' button'
},
DELIMITERS = {
START: '\\(',
END: '\\)'
},
TEMPLATES = {
FORM: '' +
'',
LIBRARY: '' +
'' +
'
' +
'
' +
'{{#each library}}' +
'
' +
'
' +
'{{#split "\n" elements}}' +
'' +
'{{/split}}' +
'
' +
'
' +
'{{/each}}' +
'
' +
'
'
};
Y.namespace('M.atto_equation').Button = Y.Base.create('button', Y.M.editor_atto.EditorPlugin, [], {
/**
* The selection object returned by the browser.
*
* @property _currentSelection
* @type Range
* @default null
* @private
*/
_currentSelection: null,
/**
* The cursor position in the equation textarea.
*
* @property _lastCursorPos
* @type Number
* @default 0
* @private
*/
_lastCursorPos: 0,
/**
* A reference to the dialogue content.
*
* @property _content
* @type Node
* @private
*/
_content: null,
/**
* The source equation we are editing in the text.
*
* @property _sourceEquation
* @type Object
* @private
*/
_sourceEquation: null,
/**
* A reference to the tab focus set on each group.
*
* The keys are the IDs of the group, the value is the Node on which the focus is set.
*
* @property _groupFocus
* @type Object
* @private
*/
_groupFocus: null,
/**
* Regular Expression patterns used to pick out the equations in a String.
*
* @property _equationPatterns
* @type Array
* @private
*/
_equationPatterns: [
// We use space or not space because . does not match new lines.
// $$ blah $$.
/\$\$([\S\s]+?)\$\$/,
// E.g. "\( blah \)".
/\\\(([\S\s]+?)\\\)/,
// E.g. "\[ blah \]".
/\\\[([\S\s]+?)\\\]/,
// E.g. "[tex] blah [/tex]".
/\[tex\]([\S\s]+?)\[\/tex\]/
],
initializer: function() {
this._groupFocus = {};
// If there is a tex filter active - enable this button.
if (this.get('texfilteractive')) {
// Add the button to the toolbar.
this.addButton({
icon: 'e/math',
callback: this._displayDialogue
});
// We need custom highlight logic for this button.
this.get('host').on('atto:selectionchanged', function() {
if (this._resolveEquation()) {
this.highlightButtons();
} else {
this.unHighlightButtons();
}
}, this);
// We need to convert these to a non dom node based format.
this.editor.all('tex').each(function(texNode) {
var replacement = Y.Node.create('' +
DELIMITERS.START + ' ' + texNode.get('text') + ' ' + DELIMITERS.END +
'');
texNode.replace(replacement);
});
}
},
/**
* Display the equation editor.
*
* @method _displayDialogue
* @private
*/
_displayDialogue: function() {
this._currentSelection = this.get('host').getSelection();
if (this._currentSelection === false) {
return;
}
// This needs to be done before the dialogue is opened because the focus will shift to the dialogue.
var equation = this._resolveEquation();
var dialogue = this.getDialogue({
headerContent: M.util.get_string('pluginname', COMPONENTNAME),
focusAfterHide: true,
width: 600,
focusOnShowSelector: SELECTORS.EQUATION_TEXT
});
var content = this._getDialogueContent();
dialogue.set('bodyContent', content);
content.one('.nav-item:first-child .nav-link').getDOMNode().click();
dialogue.show();
// Notify the filters about the modified nodes.
require(['core/event'], function(event) {
event.notifyFilterContentUpdated(dialogue.get('boundingBox').getDOMNode());
});
if (equation) {
content.one(SELECTORS.EQUATION_TEXT).set('text', equation);
}
this._updatePreview(false);
},
/**
* If there is selected text and it is part of an equation,
* extract the equation (and set it in the form).
*
* @method _resolveEquation
* @private
* @return {String|Boolean} The equation or false.
*/
_resolveEquation: function() {
// Find the equation in the surrounding text.
var selectedNode = this.get('host').getSelectionParentNode(),
selection = this.get('host').getSelection(),
text,
returnValue = false;
// Prevent resolving equations when we don't have focus.
if (!this.get('host').isActive()) {
return false;
}
// Note this is a document fragment and YUI doesn't like them.
if (!selectedNode) {
return false;
}
// We don't yet have a cursor selection somehow so we can't possible be resolving an equation that has selection.
if (!selection || selection.length === 0) {
return false;
}
this.sourceEquation = null;
selection = selection[0];
text = Y.one(selectedNode).get('text');
// For each of these patterns we have a RegExp which captures the inner component of the equation but also
// includes the delimiters.
// We first run the RegExp adding the global flag ("g"). This ignores the capture, instead matching the entire
// equation including delimiters and returning one entry per match of the whole equation.
// We have to deal with multiple occurences of the same equation in a String so must be able to loop on the
// match results.
Y.Array.find(this._equationPatterns, function(pattern) {
// For each pattern in turn, find all whole matches (including the delimiters).
var patternMatches = text.match(new RegExp(pattern.source, "g"));
if (patternMatches && patternMatches.length) {
// This pattern matches at least once. See if this pattern matches our current position.
// Note: We return here to break the Y.Array.find loop - any truthy return will stop any subsequent
// searches which is the required behaviour of this function.
return Y.Array.find(patternMatches, function(match) {
// Check each occurrence of this match.
var startIndex = 0;
while (text.indexOf(match, startIndex) !== -1) {
// Determine whether the cursor is in the current occurrence of this string.
// Note: We do not support a selection exceeding the bounds of an equation.
var startOuter = text.indexOf(match, startIndex),
endOuter = startOuter + match.length,
startMatch = (selection.startOffset >= startOuter && selection.startOffset < endOuter),
endMatch = (selection.endOffset <= endOuter && selection.endOffset > startOuter);
if (startMatch && endMatch) {
// This match is in our current position - fetch the innerMatch data.
var innerMatch = match.match(pattern);
if (innerMatch && innerMatch.length) {
// We need the start and end of the inner match for later.
var startInner = text.indexOf(innerMatch[1], startOuter),
endInner = startInner + innerMatch[1].length;
// We'll be returning the inner match for use in the editor itself.
returnValue = innerMatch[1];
// Save all data for later.
this.sourceEquation = {
// Outer match data.
startOuterPosition: startOuter,
endOuterPosition: endOuter,
outerMatch: match,
// Inner match data.
startInnerPosition: startInner,
endInnerPosition: endInner,
innerMatch: innerMatch
};
// This breaks out of both Y.Array.find functions.
return true;
}
}
// Update the startIndex to match the end of the current match so that we can continue hunting
// for further matches.
startIndex = endOuter;
}
}, this);
}
}, this);
// We trim the equation when we load it and then add spaces when we save it.
if (returnValue !== false) {
returnValue = returnValue.trim();
}
return returnValue;
},
/**
* Handle insertion of a new equation, or update of an existing one.
*
* @method _setEquation
* @param {EventFacade} e
* @private
*/
_setEquation: function(e) {
var input,
selectedNode,
text,
value,
host,
newText;
host = this.get('host');
e.preventDefault();
this.getDialogue({
focusAfterHide: null
}).hide();
input = e.currentTarget.ancestor('.atto_form').one('textarea');
value = input.get('value');
if (value !== '') {
host.setSelection(this._currentSelection);
if (this.sourceEquation) {
// Replace the equation.
selectedNode = Y.one(host.getSelectionParentNode());
text = selectedNode.get('text');
value = ' ' + value + ' ';
newText = text.slice(0, this.sourceEquation.startInnerPosition) +
value +
text.slice(this.sourceEquation.endInnerPosition);
selectedNode.set('text', newText);
} else {
// Insert the new equation.
value = DELIMITERS.START + ' ' + value + ' ' + DELIMITERS.END;
host.insertContentAtFocusPoint(value);
}
// Clean the YUI ids from the HTML.
this.markUpdated();
}
},
/**
* Smart throttle, only call a function every delay milli seconds,
* and always run the last call. Y.throttle does not work here,
* because it calls the function immediately, the first time, and then
* ignores repeated calls within X seconds. This does not guarantee
* that the last call will be executed (which is required here).
*
* @param {function} fn
* @param {Number} delay Delay in milliseconds
* @method _throttle
* @private
*/
_throttle: function(fn, delay) {
var timer = null;
return function() {
var context = this, args = arguments;
clearTimeout(timer);
timer = setTimeout(function() {
fn.apply(context, args);
}, delay);
};
},
/**
* Update the preview div to match the current equation.
*
* @param {EventFacade} e
* @method _updatePreview
* @private
*/
_updatePreview: function(e) {
var textarea = this._content.one(SELECTORS.EQUATION_TEXT),
equation = textarea.get('value'),
url,
currentPos = textarea.get('selectionStart'),
prefix = '',
cursorLatex = '\\Downarrow ',
isChar,
params;
if (e) {
e.preventDefault();
}
// Move the cursor so it does not break expressions.
// Start at the very beginning.
if (!currentPos) {
currentPos = 0;
}
// First move back to the beginning of the line.
while (equation.charAt(currentPos) === '\\' && currentPos >= 0) {
currentPos -= 1;
}
isChar = /[a-zA-Z\{]/;
if (currentPos !== 0) {
if (equation.charAt(currentPos - 1) != '{') {
// Now match to the end of the line.
while (isChar.test(equation.charAt(currentPos)) &&
currentPos < equation.length &&
isChar.test(equation.charAt(currentPos - 1))) {
currentPos += 1;
}
}
}
// Save the cursor position - for insertion from the library.
this._lastCursorPos = currentPos;
equation = prefix + equation.substring(0, currentPos) + cursorLatex + equation.substring(currentPos);
equation = DELIMITERS.START + ' ' + equation + ' ' + DELIMITERS.END;
// Make an ajax request to the filter.
url = M.cfg.wwwroot + '/lib/editor/atto/plugins/equation/ajax.php';
params = {
sesskey: M.cfg.sesskey,
contextid: this.get('contextid'),
action: 'filtertext',
text: equation
};
Y.io(url, {
context: this,
data: params,
timeout: 500,
on: {
complete: this._loadPreview
}
});
},
/**
* Load returned preview text into preview
*
* @param {String} id
* @param {EventFacade} e
* @method _loadPreview
* @private
*/
_loadPreview: function(id, preview) {
var previewNode = this._content.one(SELECTORS.EQUATION_PREVIEW);
if (preview.status === 200) {
previewNode.setHTML(preview.responseText);
// Notify the filters about the modified nodes.
require(['core/event'], function(event) {
event.notifyFilterContentUpdated(previewNode.getDOMNode());
});
}
},
/**
* Return the dialogue content for the tool, attaching any required
* events.
*
* @method _getDialogueContent
* @return {Node}
* @private
*/
_getDialogueContent: function() {
var library = this._getLibraryContent(),
throttledUpdate = this._throttle(this._updatePreview, 500),
template = Y.Handlebars.compile(TEMPLATES.FORM);
this._content = Y.Node.create(template({
elementid: this.get('host').get('elementid'),
component: COMPONENTNAME,
library: library,
texdocsurl: this.get('texdocsurl'),
CSS: CSS
}));
// Sets the default focus.
this._content.all(SELECTORS.LIBRARY_GROUP).each(function(group) {
// The first button gets the focus.
this._setGroupTabFocus(group, group.one('button'));
// Sometimes the filter adds an anchor in the button, no tabindex on that.
group.all('button a').setAttribute('tabindex', '-1');
}, this);
// Keyboard navigation in groups.
this._content.delegate('key', this._groupNavigation, 'down:37,39', SELECTORS.LIBRARY_BUTTON, this);
this._content.one(SELECTORS.SUBMIT).on('click', this._setEquation, this);
this._content.one(SELECTORS.EQUATION_TEXT).on('valuechange', throttledUpdate, this);
this._content.one(SELECTORS.EQUATION_TEXT).on('mouseup', throttledUpdate, this);
this._content.one(SELECTORS.EQUATION_TEXT).on('keyup', throttledUpdate, this);
this._content.delegate('click', this._selectLibraryItem, SELECTORS.LIBRARY_BUTTON, this);
return this._content;
},
/**
* Callback handling the keyboard navigation in the groups of the library.
*
* @param {EventFacade} e The event.
* @method _groupNavigation
* @private
*/
_groupNavigation: function(e) {
e.preventDefault();
var current = e.currentTarget,
parent = current.get('parentNode'), // This must be the containing all the buttons of the group.
buttons = parent.all('button'),
direction = e.keyCode !== 37 ? 1 : -1,
index = buttons.indexOf(current),
nextButton;
if (index < 0) {
Y.log('Unable to find the current button in the list of buttons', 'debug', LOGNAME);
index = 0;
}
index += direction;
if (index < 0) {
index = buttons.size() - 1;
} else if (index >= buttons.size()) {
index = 0;
}
nextButton = buttons.item(index);
this._setGroupTabFocus(parent, nextButton);
nextButton.focus();
},
/**
* Sets tab focus for the group.
*
* @method _setGroupTabFocus
* @param {Node} button The node that focus should now be set to.
* @private
*/
_setGroupTabFocus: function(parent, button) {
var parentId = parent.generateID();
// Unset the previous entry.
if (typeof this._groupFocus[parentId] !== 'undefined') {
this._groupFocus[parentId].setAttribute('tabindex', '-1');
}
// Set on the new entry.
this._groupFocus[parentId] = button;
button.setAttribute('tabindex', 0);
parent.setAttribute('aria-activedescendant', button.generateID());
},
/**
* Reponse to button presses in the TeX library panels.
*
* @method _selectLibraryItem
* @param {EventFacade} e
* @return {string}
* @private
*/
_selectLibraryItem: function(e) {
var tex = e.currentTarget.getAttribute('data-tex'),
oldValue,
newValue,
input,
focusPoint = 0;
e.preventDefault();
// Set the group focus on the button.
this._setGroupTabFocus(e.currentTarget.get('parentNode'), e.currentTarget);
input = e.currentTarget.ancestor('.atto_form').one('textarea');
oldValue = input.get('value');
newValue = oldValue.substring(0, this._lastCursorPos);
if (newValue.charAt(newValue.length - 1) !== ' ') {
newValue += ' ';
}
newValue += tex;
focusPoint = newValue.length;
if (oldValue.charAt(this._lastCursorPos) !== ' ') {
newValue += ' ';
}
newValue += oldValue.substring(this._lastCursorPos, oldValue.length);
input.set('value', newValue);
input.focus();
var realInput = input.getDOMNode();
if (typeof realInput.selectionStart === "number") {
// Modern browsers have selectionStart and selectionEnd to control the cursor position.
realInput.selectionStart = realInput.selectionEnd = focusPoint;
} else if (typeof realInput.createTextRange !== "undefined") {
// Legacy browsers (IE<=9) use createTextRange().
var range = realInput.createTextRange();
range.moveToPoint(focusPoint);
range.select();
}
// Focus must be set before updating the preview for the cursor box to be in the correct location.
this._updatePreview(false);
},
/**
* Return the HTML for rendering the library of predefined buttons.
*
* @method _getLibraryContent
* @return {string}
* @private
*/
_getLibraryContent: function() {
var template = Y.Handlebars.compile(TEMPLATES.LIBRARY),
library = this.get('library'),
content = '';
// Helper to iterate over a newline separated string.
Y.Handlebars.registerHelper('split', function(delimiter, str, options) {
var parts,
current,
out;
if (typeof delimiter === "undefined" || typeof str === "undefined") {
Y.log('Handlebars split helper: String and delimiter are required.', 'debug', 'moodle-atto_equation-button');
return '';
}
out = '';
parts = str.trim().split(delimiter);
while (parts.length > 0) {
current = parts.shift().trim();
out += options.fn(current);
}
return out;
});
content = template({
elementid: this.get('host').get('elementid'),
component: COMPONENTNAME,
library: library,
CSS: CSS,
DELIMITERS: DELIMITERS
});
var url = M.cfg.wwwroot + '/lib/editor/atto/plugins/equation/ajax.php';
var params = {
sesskey: M.cfg.sesskey,
contextid: this.get('contextid'),
action: 'filtertext',
text: content
};
var preview = Y.io(url, {
sync: true,
data: params,
method: 'POST'
});
if (preview.status === 200) {
content = preview.responseText;
}
return content;
}
}, {
ATTRS: {
/**
* Whether the TeX filter is currently active.
*
* @attribute texfilteractive
* @type Boolean
*/
texfilteractive: {
value: false
},
/**
* The contextid to use when generating this preview.
*
* @attribute contextid
* @type String
*/
contextid: {
value: null
},
/**
* The content of the example library.
*
* @attribute library
* @type object
*/
library: {
value: {}
},
/**
* The link to the Moodle Docs page about TeX.
*
* @attribute texdocsurl
* @type string
*/
texdocsurl: {
value: null
}
}
});