mathlive
Version:
Render and edit beautifully typeset math
1,604 lines (1,413 loc) • 118 kB
JavaScript
/**
* This module contains the definition of a data structure representing a list
* of math atoms that can be edited. It is an in-memory representation of a
* mathematical expression whose elements, math atoms, can be removed,
* inserted or re-arranged. In addition, the data structure keeps track
* of a selection, which can be either an insertion point — the selection is
* then said to be _collapsed_ — or a range of atoms.
*
* See {@linkcode EditableMathlist}
*
* @module editor/editableMathlist
* @private
*/
import Definitions from '../core/definitions.js';
import MathAtom from '../core/mathAtom.js';
import Lexer from '../core/lexer.js';
import ParserModule from '../core/parser.js';
import MathPath from './editor-mathpath.js';
import Shortcuts from './editor-shortcuts.js';
/**
*
* **Note**
* - Method names that _begin with_ an underbar `_` are private and meant
* to be used only by the implementation of the class.
* - Method names that _end with_ an underbar `_` are selectors. They can
* be invoked by calling [`MathField.$perform()`]{@link MathField#$perform}.
* They will be dispatched to an instance of `MathEditableList` as necessary.
* Note that the selector name does not include the underbar.
*
* For example:
* ```
* mf.$perform('selectAll');
* ```
*
* @param {Object.<string, any>} config
* @param {HTMLElement} target - A target object passed as the first argument of
* callback functions. Typically, a MathField.
* @property {MathAtom[]} root - The root element of the math expression.
* @property {Object[]} path - The path to the element that is the
* anchor for the selection.
* @property {number} extent - Number of atoms in the selection. `0` if the
* selection is collapsed.
* @property {Object.<string, any>} config
* @property {boolean} suppressChangeNotifications - If true,
* the handlers for notification change won't be called.
* @class
* @global
* @private
* @memberof module:editor/editableMathlist
*/
function EditableMathlist(config, target) {
this.root = MathAtom.makeRoot();
this.path = [{relation: 'body', offset: 0}];
this.extent = 0;
this.config = config ? {...config} : {};
this.target = target;
this.suppressChangeNotifications = false;
}
function clone(mathlist) {
const result = Object.assign(new EditableMathlist(mathlist.config, mathlist.target), mathlist);
result.path = MathPath.clone(mathlist.path);
return result;
}
EditableMathlist.prototype._announce = function(command, mathlist, atoms) {
if (typeof this.config.onAnnounce === 'function') {
this.config.onAnnounce(this.target, command, mathlist, atoms);
}
}
/**
* Iterate over each atom in the expression, starting with the focus.
*
* Return an array of all the paths for which the callback predicate
* returned true.
*
* @param {function} cb - A predicate being passed a path and the atom at this
* path. Return true to include the designated atom in the result.
* @param {number} dir - `+1` to iterate forward, `-1` to iterate backward.
* @return {MathAtom[]} The atoms for which the predicate is true
* @method EditableMathlist#filter
* @private
*/
EditableMathlist.prototype.filter = function(cb, dir) {
dir = dir < 0 ? -1 : +1;
const result = [];
const iter = new EditableMathlist();
iter.path = MathPath.clone(this.path);
iter.extent = this.extent;
iter.root = this.root;
if (dir >= 0) {
iter.collapseForward();
} else {
iter.collapseBackward();
iter.move(1);
}
const initialAnchor = iter.anchor();
do {
if (cb.bind(iter)(iter.path, iter.anchor())) {
result.push(iter.toString());
}
if (dir >= 0) {
iter.next({iterateAll: true});
} else {
iter.previous({iterateAll: true});
}
} while (initialAnchor !== iter.anchor());
return result;
}
/**
* Enumerator
* @param {function} cb - A callback called for each atom in the mathlist.
* @method EditableMathlist#forEach
* @private
*/
EditableMathlist.prototype.forEach = function(cb) {
this.root.forEach(cb);
}
/**
*
* @param {function} cb - A callback called for each selected atom in the
* mathlist.
* @method EditableMathlist#forEachSelected
* @private
*/
EditableMathlist.prototype.forEachSelected = function(cb, options) {
options = options || {};
options.recursive = typeof options.recursive !== 'undefined' ?
options.recursive : false
const siblings = this.siblings()
const firstOffset = this.startOffset() + 1;
const lastOffset = this.endOffset() + 1;
if (options.recursive) {
for (let i = firstOffset; i < lastOffset; i++) {
if (siblings[i] && siblings[i].type !== 'first') {
siblings[i].forEach(cb);
}
}
} else {
for (let i = firstOffset; i < lastOffset; i++) {
if (siblings[i] && siblings[i].type !== 'first') {
cb(siblings[i])
}
}
}
}
/**
* Return a string representation of the selection.
* @todo This is a bad name for this function, since it doesn't return
* a representation of the content, which one might expect...
*
* @return {string}
* @method EditableMathlist#toString
* @private
*/
EditableMathlist.prototype.toString = function() {
return MathPath.pathToString(this.path, this.extent);
}
/**
* When changing the selection, if the former selection is an empty list,
* insert a placeholder if necessary. For example, if in an empty numerator.
* @private
*/
EditableMathlist.prototype.adjustPlaceholder = function() {
// Should we insert a placeholder?
// Check if we're an empty list that is the child of a fraction
const siblings = this.siblings();
if (siblings && siblings.length <= 1) {
let placeholder;
const relation = this.relation();
if (relation === 'numer') {
placeholder = 'numerator';
} else if (relation === 'denom') {
placeholder = 'denominator';
} else if (this.parent().type === 'surd' && relation === 'body') {
// Surd (roots)
placeholder = 'radicand';
} else if (this.parent().type === 'overunder' && relation === 'body') {
placeholder = 'base';
} else if (relation === 'underscript' || relation === 'overscript') {
placeholder = 'annotation';
}
if (placeholder) {
// ◌ ⬚
const placeholderAtom = [new MathAtom.MathAtom(
'math', 'placeholder', '⬚', this.anchorStyle())];
Array.prototype.splice.apply(siblings, [1, 0].concat(placeholderAtom));
}
}
}
EditableMathlist.prototype.selectionWillChange = function() {
if (typeof this.config.onSelectionWillChange === 'function' && !this.suppressChangeNotifications) {
this.config.onSelectionWillChange(this.target);
}
}
EditableMathlist.prototype.selectionDidChange = function() {
if (typeof this.config.onSelectionDidChange === 'function' && !this.suppressChangeNotifications) {
this.config.onSelectionDidChange(this.target);
}
}
EditableMathlist.prototype.contentWillChange = function() {
if (typeof this.config.onContentWillChange === 'function' && !this.suppressChangeNotifications) {
this.config.onContentWillChange(this.target);
}
}
EditableMathlist.prototype.contentDidChange = function() {
if (typeof this.config.onContentDidChange === 'function' && !this.suppressChangeNotifications) {
this.config.onContentDidChange(this.target);
}
}
/**
*
* @param {string|Array} selection
* @param {number} extent the length of the selection
* @return {boolean} true if the path has actually changed
* @method EditableMathlist#setPath
* @private
*/
EditableMathlist.prototype.setPath = function(selection, extent) {
// Convert to a path array if necessary
if (typeof selection === 'string') {
selection = MathPath.pathFromString(selection);
} else if (Array.isArray(selection)) {
// need to temporarily change the path of this to use 'sibling()'
const newPath = MathPath.clone(selection);
const oldPath = this.path;
this.path = newPath;
if (extent === 0 && this.anchor().type === 'placeholder') {
// select the placeholder
newPath[newPath.length - 1].offset = 0;
extent = 1;
}
selection = {
path: newPath,
extent: extent || 0
};
this.path = oldPath;
}
const pathChanged = MathPath.pathDistance(this.path, selection.path) !== 0;
const extentChanged = selection.extent !== this.extent;
if (pathChanged || extentChanged) {
if (pathChanged) {
this.adjustPlaceholder();
}
this.selectionWillChange();
this.path = MathPath.clone(selection.path);
if (this.siblings().length < this.anchorOffset()) {
// The new path is out of bounds.
// Reset the path to something valid
console.warn('Invalid selection: ' + this.toString() +
' in "' + this.root.toLatex() + '"');
this.path = [{relation: 'body', offset: 0}];
this.extent = 0;
} else {
this.setExtent(selection.extent);
}
this.selectionDidChange();
}
return pathChanged || extentChanged;
}
EditableMathlist.prototype.wordBoundary = function(path, dir) {
dir = dir < 0 ? -1 : +1;
const iter = new EditableMathlist();
iter.path = MathPath.clone(path);
iter.root = this.root;
let i = 0;
while (iter.sibling(i) && iter.sibling(i).mode === 'text' &&
Definitions.LETTER_AND_DIGITS.test(iter.sibling(i).body)) {
i += dir;
}
if (!iter.sibling(i)) i -= dir;
iter.path[iter.path.length - 1].offset += i;
return iter.path;
}
/*
* Calculates the offset of the "next word".
* This is inspired by the behavior of text editors on macOS, namely:
blue yellow
^-
^-------
* That is:
* (1) If starts with an alphanumerical character, find the first alphanumerical
* character which is followed by a non-alphanumerical character
*
* The behavior regarding non-alphanumeric characters is less consistent.
* Here's the behavior we use:
*
* +=-()_:” blue
* ^---------
* +=-()_:” blue
* ^---------
* +=-()_:”blue
* ^--------
*
* (2) If starts in whitespace, skip whitespace, then find first non-whitespace*
* followed by whitespace
* (*) Pages actually uses the character class of the first non-whitespace
* encountered.
*
* (3) If starts in a non-whitespace, non alphanumerical character, find the first
* whitespace
*
*/
EditableMathlist.prototype.wordBoundaryOffset = function(offset, dir) {
dir = dir < 0 ? -1 : +1;
const siblings = this.siblings();
if (!siblings[offset]) return offset;
if (siblings[offset].mode !== 'text') return offset;
let result;
if (Definitions.LETTER_AND_DIGITS.test(siblings[offset].body)) {
// (1) We start with an alphanumerical character
let i = offset;
let match;
do {
match = siblings[i].mode === 'text' &&
Definitions.LETTER_AND_DIGITS.test(siblings[i].body);
i += dir;
} while (siblings[i] && match);
result = siblings[i] ? (i - 2 * dir) : i - dir;
} else if (/\s/.test(siblings[offset].body)) {
// (2) We start with whitespace
// Skip whitespace
let i = offset;
while (siblings[i] && siblings[i].mode === 'text' &&
/\s/.test(siblings[i].body)) {
i += dir;
}
if (!siblings[i]) {
// We've reached the end
result = i - dir;
} else {
let match = true;
do {
match = siblings[i].mode === 'text' &&
!/\s/.test(siblings[i].body);
i += dir;
} while (siblings[i] && match);
result = siblings[i] ? (i - 2 * dir) : i - dir;
}
} else {
// (3)
let i = offset;
// Skip non-whitespace
while (siblings[i] && siblings[i].mode === 'text' &&
!/\s/.test(siblings[i].body)) {
i += dir;
}
result = siblings[i] ? i : i - dir;
let match = true;
while (siblings[i] && match) {
match = siblings[i].mode === 'text' && /\s/.test(siblings[i].body);
if (match) result = i;
i += dir;
}
result = siblings[i] ? (i - 2 * dir) : i - dir;
}
return result - (dir > 0 ? 0 : 1);
}
/**
* Extend the selection between `from` and `to` nodes
*
* @param {string[]} from
* @param {string[]} to
* @param {object} options
* - options.extendToWordBoundary
* @method EditableMathlist#setRange
* @return {boolean} true if the range was actually changed
* @private
*/
EditableMathlist.prototype.setRange = function(from, to, options) {
options = options || {};
// Measure the 'distance' between `from` and `to`
const distance = MathPath.pathDistance(from, to);
if (distance === 0) {
// `from` and `to` are equal.
if (options.extendToWordBoundary) {
from = this.wordBoundary(from, -1);
to = this.wordBoundary(to, +1);
return this.setRange(from, to);
}
// Set the path to a collapsed insertion point
return this.setPath(MathPath.clone(from), 0);
}
if (distance === 1) {
const extent = (to[to.length - 1].offset - from[from.length - 1].offset);
if (options.extendToWordBoundary) {
from = this.wordBoundary(from, extent < 0 ? + 1 : -1);
to = this.wordBoundary(to, extent < 0 ? -1 : +1);
return this.setRange(from, to);
}
// They're siblings, set an extent
return this.setPath(MathPath.clone(from), extent);
}
// They're neither identical, not siblings.
// Find the common ancestor between the nodes
let commonAncestor = MathPath.pathCommonAncestor(from, to);
const ancestorDepth = commonAncestor.length;
if (from.length === ancestorDepth || to.length === ancestorDepth ||
from[ancestorDepth].relation !== to[ancestorDepth].relation) {
return this.setPath(commonAncestor, -1);
}
commonAncestor.push(from[ancestorDepth]);
commonAncestor = MathPath.clone(commonAncestor);
let extent = to[ancestorDepth].offset - from[ancestorDepth].offset + 1;
if (extent <= 0) {
if (to.length > ancestorDepth + 1) {
// axb/c+y -> select from y to x
commonAncestor[ancestorDepth].relation = to[ancestorDepth].relation;
commonAncestor[ancestorDepth].offset = to[ancestorDepth].offset;
commonAncestor[commonAncestor.length - 1].offset -= 1;
extent = -extent + 2;
} else {
// x+(ayb/c) -> select from y to x
commonAncestor[ancestorDepth].relation = to[ancestorDepth].relation;
commonAncestor[ancestorDepth].offset = to[ancestorDepth].offset;
extent = -extent + 1;
}
} else if (to.length <= from.length) {
// axb/c+y -> select from x to y
commonAncestor[commonAncestor.length - 1].offset -= 1;
} else if (to.length > from.length) {
commonAncestor[ancestorDepth].offset -= 1;
}
return this.setPath(commonAncestor, extent);
}
/**
* Convert an array row/col into an array index.
* @param {MathAtom[][]} array
* @param {object} rowCol
* @return {number}
* @private
*/
function arrayIndex(array, rowCol) {
let result = 0;
for (let i = 0; i < rowCol.row; i++) {
for (let j = 0; j < array[i].length; j++) {
result += 1;
}
}
result += rowCol.col;
return result;
}
/**
* Convert an array index (scalar) to an array row/col.
* @param {MathAtom[][]} array
* @param {number|string} index
* @return {object}
* - row: number
* - col: number
* @private
*/
function arrayColRow(array, index) {
if (typeof index === 'string') {
const m = index.match(/cell([0-9]*)$/);
if (m) index = parseInt(m[1]);
}
const result = {row: 0, col: 0};
while (index > 0) {
result.col += 1;
if (!array[result.row] || result.col >= array[result.row].length) {
result.col = 0;
result.row += 1;
}
index -= 1;
}
return result;
}
/**
* Return the array cell corresponding to colrow or null (for example in
* a sparse array)
*
* @param {MathAtom[][]} array
* @param {number|string|object} colrow
* @private
*/
function arrayCell(array, colrow) {
if (typeof colrow !== 'object') colrow = arrayColRow(array, colrow);
let result;
if (Array.isArray(array[colrow.row])) {
result = array[colrow.row][colrow.col] || null;
}
// If the 'first' math atom is missing, insert it
if (result && (result.length === 0 || result[0].type !== 'first')) {
result.unshift(makeFirstAtom());
}
return result;
}
/**
* Total numbers of cells (include sparse cells) in the array.
* @param {MathAtom[][]} array
* @private
*/
function arrayCellCount(array) {
let result = 0;
let numRows = 0;
let numCols = 1;
for (const row of array) {
numRows += 1;
if (row.length > numCols) numCols = row.length;
}
result = numRows * numCols;
return result;
}
/**
* Join all the cells at the indicated row into a single mathlist
* @param {MathAtom[]} row
* @param {string} separator
* @param {object} style
* @return {MathAtom[]}
* @private
*/
function arrayJoinColumns(row, separator, style) {
if (!row) return [];
if (!separator) separator = ',';
let result = [];
let sep;
for (let cell of row) {
if (cell && cell.length > 0 && cell[0].type === 'first') {
// Remove the 'first' atom, if present
cell = cell.slice(1);
}
if (cell && cell.length > 0) {
if (sep) {
result.push(sep);
} else {
sep = new MathAtom.MathAtom('math', 'mpunct', separator, style);
}
result = result.concat(cell);
}
}
return result;
}
/**
* Join all the rows into a single atom list
* @param {MathAtom} array
* @param {strings} separators
* @param {object} style
* @return {MathAtom[]}
* @private
*/
function arrayJoinRows(array, separators, style) {
if (!separators) separators = [';', ','];
let result = [];
let sep;
for (const row of array) {
if (sep) {
result.push(sep);
} else {
sep = new MathAtom.MathAtom('math', 'mpunct', separators[0], style);
}
result = result.concat(arrayJoinColumns(row, separators[1]));
}
return result;
}
/**
* Return the number of non-empty cells in that column
* @param {MathAtom} array
* @param {number} col
* @return {number}
* @private
*/
function arrayColumnCellCount(array, col) {
let result = 0;
const colRow = {col: col, row: 0};
while (colRow.row < array.length) {
const cell = arrayCell(array, colRow);
if (cell && cell.length > 0) {
let cellLen = cell.length;
if (cell[0].type === 'first') cellLen -= 1;
if (cellLen > 0) {
result += 1;
}
}
colRow.row += 1;
}
return result;
}
/**
* Remove the indicated column from the array
* @param {MathAtom} array
* @param {number} col
* @private
*/
function arrayRemoveColumn(array, col) {
let row = 0;
while (row < array.length) {
if (array[row][col]) {
array[row].splice(col, 1);
}
row += 1;
}
}
/**
* Remove the indicated row from the array
* @param {MathAtom} atom
* @param {number} row
* @private
*/
function arrayRemoveRow(array, row) {
array.splice(row, 1);
}
/**
* Return the first non-empty cell, row by row
* @param {MathAtom[][]} array
* @return {string}
* @private
*/
function arrayFirstCellByRow(array) {
const colRow = {col: 0, row: 0};
while (colRow.row < array.length && !arrayCell(array, colRow)) {
colRow.row += 1;
}
return arrayCell(array, colRow) ? 'cell' + arrayIndex(array, colRow) : '';
}
/**
* Adjust colRow to point to the next/previous available row
* If no more rows, go to the next/previous column
* If no more columns, return null
* @param {MathAtom[][]} array
* @param {object} colRow
* @param {number} dir
* @private
*/
function arrayAdjustRow(array, colRow, dir) {
const result = {...colRow};
result.row += dir;
if (result.row < 0) {
result.col += dir;
result.row = array.length - 1;
if (result.col < 0) return null;
while (result.row >= 0 && !arrayCell(array, result)) {
result.row -= 1;
}
if (result.row < 0) return null;
} else if (result.row >= array.length) {
result.col += dir;
result.row = 0;
while (result.row < array.length && !arrayCell(array, result)) {
result.row += 1;
}
if (result.row > array.length - 1) return null;
}
return result;
}
/**
* @param {number} ancestor distance from self to ancestor.
* - `ancestor` = 0: self
* - `ancestor` = 1: parent
* - `ancestor` = 2: grand-parent
* - etc...
* @return {MathAtom}
* @method EditableMathlist#ancestor
* @private
*/
EditableMathlist.prototype.ancestor = function(ancestor) {
// If the requested ancestor goes beyond what's available,
// return null
if (ancestor > this.path.length) return null;
// Start with the root
let result = this.root;
// Iterate over the path segments, selecting the appropriate
for (let i = 0; i < (this.path.length - ancestor); i++) {
const segment = this.path[i];
if (result.array) {
result = arrayCell(result.array, segment.relation)[segment.offset];
} else if (!result[segment.relation]) {
// There is no such relation... (the path got out of sync with the tree)
return null;
} else {
// Make sure the 'first' atom has been inserted, otherwise
// the segment.offset might be invalid
if (result[segment.relation].length === 0 || result[segment.relation][0].type !== 'first') {
result[segment.relation].unshift(makeFirstAtom());
}
const offset = Math.min(segment.offset, result[segment.relation].length - 1);
result = result[segment.relation][offset];
}
}
return result;
}
/**
* The atom where the selection starts. When the selection is extended
* the anchor remains fixed. The anchor could be either before or
* after the focus.
*
* @method EditableMathlist#anchor
* @private
*/
EditableMathlist.prototype.anchor = function() {
if (this.parent().array) {
return arrayCell(this.parent().array, this.relation())[this.anchorOffset()];
}
const siblings = this.siblings();
return siblings[Math.min(siblings.length - 1, this.anchorOffset())];
}
EditableMathlist.prototype.parent = function() {
return this.ancestor(1);
}
EditableMathlist.prototype.relation = function() {
return this.path.length > 0 ? this.path[this.path.length - 1].relation : '';
}
EditableMathlist.prototype.anchorOffset = function() {
return this.path.length > 0 ? this.path[this.path.length - 1].offset : 0;
}
EditableMathlist.prototype.focusOffset = function() {
return this.path.length > 0 ?
this.path[this.path.length - 1].offset + this.extent : 0;
}
/**
* Offset of the first atom included in the selection
* i.e. `=1` => selection starts with and includes first atom
* With expression _x=_ and atoms :
* - 0: _<first>_
* - 1: _x_
* - 2: _=_
*
* - if caret is before _x_: `start` = 0, `end` = 0
* - if caret is after _x_: `start` = 1, `end` = 1
* - if _x_ is selected: `start` = 1, `end` = 2
* - if _x=_ is selected: `start` = 1, `end` = 3
* @method EditableMathlist#startOffset
* @private
*/
EditableMathlist.prototype.startOffset = function() {
return Math.min(this.focusOffset(), this.anchorOffset());
}
/**
* Offset of the first atom not included in the selection
* i.e. max value of `siblings.length`
* `endOffset - startOffset = extent`
* @method EditableMathlist#endOffset
* @private
*/
EditableMathlist.prototype.endOffset = function() {
return Math.max(this.focusOffset(), this.anchorOffset());
}
/**
* If necessary, insert a `first` atom in the sibling list.
* If there's already a `first` atom, do nothing.
* The `first` atom is used as a 'placeholder' to hold the blinking caret when
* the caret is positioned at the very beginning of the mathlist.
* @method EditableMathlist#insertFirstAtom
* @private
*/
EditableMathlist.prototype.insertFirstAtom = function() {
this.siblings();
}
/**
* @return {MathAtom[]} array of children of the parent
* @method EditableMathlist#siblings
* @private
*/
EditableMathlist.prototype.siblings = function() {
if (this.path.length === 0) return [];
let siblings;
if (this.parent().array) {
siblings = arrayCell(this.parent().array, this.relation());
} else {
siblings = this.parent()[this.relation()] || [];
if (typeof siblings === 'string') siblings = [];
}
// If the 'first' math atom is missing, insert it
if (siblings.length === 0 || siblings[0].type !== 'first') {
siblings.unshift(makeFirstAtom());
}
return siblings;
}
/**
* Sibling, relative to `anchor`
* `sibling(0)` = start of selection
* `sibling(-1)` = sibling immediately left of start offset
* @return {MathAtom}
* @method EditableMathlist#sibling
* @private
*/
EditableMathlist.prototype.sibling = function(offset) {
return this.siblings()[this.startOffset() + offset]
}
/**
* @return {boolean} True if the selection is an insertion point.
* @method EditableMathlist#isCollapsed
* @private
*/
EditableMathlist.prototype.isCollapsed = function() {
return this.extent === 0;
}
/**
* @param {number} extent
* @method EditableMathlist#setExtent
* @private
*/
EditableMathlist.prototype.setExtent = function(extent) {
this.extent = extent;
}
EditableMathlist.prototype.collapseForward = function() {
if (this.extent === 0) return false;
this.setSelection(this.endOffset());
return true;
}
EditableMathlist.prototype.collapseBackward = function() {
if (this.extent === 0) return false;
this.setSelection(this.startOffset());
return true;
}
/**
* Return true if the atom could be a part of a number
* i.e. "-12354.568"
* @param {object} atom
* @private
*/
function isNumber(atom) {
if (!atom) return false;
return (atom.type === 'mord' && /[0-9.]/.test(atom.body)) ||
(atom.type === 'mpunct' && atom.body === ',');
}
/**
* Select all the atoms in the current group, that is all the siblings.
* When the selection is in a numerator, the group is the numerator. When
* the selection is a superscript or subscript, the group is the supsub.
* When the selection is in a text zone, the "group" is a word.
* @method EditableMathlist#selectGroup_
* @private
*/
EditableMathlist.prototype.selectGroup_ = function() {
const siblings = this.siblings();
if (this.anchorMode() === 'text') {
let start = this.startOffset();
let end = this.endOffset();
//
while (siblings[start] && siblings[start].mode === 'text' &&
Definitions.LETTER_AND_DIGITS.test(siblings[start].body)) {
start -= 1;
}
while (siblings[end] && siblings[end].mode === 'text' &&
Definitions.LETTER_AND_DIGITS.test(siblings[end].body)) {
end += 1;
}
end -= 1;
if (start >= end) {
// No word found. Select a single character
this.setSelection(this.endOffset() - 1, 1);
return;
}
this.setSelection(start, end - start);
} else {
// In a math zone, select all the sibling nodes
if (this.sibling(0).type === 'mord' && /[0-9,.]/.test(this.sibling(0).body)) {
// In a number, select all the digits
let start = this.startOffset();
let end = this.endOffset();
//
while (isNumber(siblings[start])) start -= 1;
while (isNumber(siblings[end])) end += 1;
end -= 1;
this.setSelection(start, end - start);
} else {
this.setSelection(0, 'end');
}
}
}
/**
* Select all the atoms in the math field.
* @method EditableMathlist#selectAll_
* @private
*/
EditableMathlist.prototype.selectAll_ = function() {
this.path = [{relation: 'body', offset: 0}];
this.setSelection(0, 'end');
}
/**
* Delete everything in the field
* @method EditableMathlist#deleteAll_
* @private
*/
EditableMathlist.prototype.deleteAll_ = function() {
this.selectAll_();
this.delete_();
}
/**
*
* @param {MathAtom} atom
* @param {MathAtom} target
* @return {boolean} True if `atom` is the target, or if one of the
* children of `atom` contains the target
* @function atomContains
* @private
*/
function atomContains(atom, target) {
if (!atom) return false;
if (Array.isArray(atom)) {
for (const child of atom) {
if (atomContains(child, target)) return true;
}
} else {
if (atom === target) return true;
if (['body', 'numer', 'denom',
'index', 'subscript', 'superscript',
'underscript', 'overscript']
.some(function(value) {
return value === target || atomContains(atom[value], target)
} )) return true;
if (atom.array) {
for (let i = arrayCellCount(atom.array); i >= 0; i--) {
if (atomContains(arrayCell(atom.array, i), target)) {
return true;
}
}
}
}
return false;
}
/**
* @param {MathAtom} atom
* @return {boolean} True if `atom` is within the selection range
* @todo: poorly named, since this is specific to the selection, not the math
* field
* @method EditableMathlist#contains
* @private
*/
EditableMathlist.prototype.contains = function(atom) {
if (this.isCollapsed()) return false;
const siblings = this.siblings()
const firstOffset = this.startOffset();
const lastOffset = this.endOffset();
for (let i = firstOffset; i < lastOffset; i++) {
if (atomContains(siblings[i], atom)) return true;
}
return false;
}
/**
* @return {MathAtom[]} The currently selected atoms, or `null` if the
* selection is collapsed
* @method EditableMathlist#getSelectedAtoms
* @private
*/
EditableMathlist.prototype.getSelectedAtoms = function() {
if (this.isCollapsed()) return null;
const result = [];
const siblings = this.siblings()
const firstOffset = this.startOffset() + 1;
const lastOffset = this.endOffset() + 1;
for (let i = firstOffset; i < lastOffset; i++) {
if (siblings[i] && siblings[i].type !== 'first') result.push(siblings[i]);
}
return result;
}
/**
* Return a `{start:, end:}` for the offsets of the command around the insertion
* point, or null.
* - `start` is the first atom which is of type `command`
* - `end` is after the last atom of type `command`
* @return {object}
* @method EditableMathlist#commandOffsets
* @private
*/
EditableMathlist.prototype.commandOffsets = function() {
const siblings = this.siblings();
if (siblings.length <= 1) return null;
let start = Math.min(this.endOffset(), siblings.length - 1);
// let start = Math.max(0, this.endOffset());
if (siblings[start].type !== 'command') return null;
while (start > 0 && siblings[start].type === 'command') start -= 1;
let end = this.startOffset() + 1;
while (end <= siblings.length - 1 && siblings[end].type === 'command') end += 1;
if (end > start) {
return {start: start + 1, end: end};
}
return null;
}
/**
* @return {string}
* @method EditableMathlist#extractCommandStringAroundInsertionPoint
* @private
*/
EditableMathlist.prototype.extractCommandStringAroundInsertionPoint =
function(beforeInsertionPointOnly) {
let result = '';
const command = this.commandOffsets();
if (command) {
const end = beforeInsertionPointOnly ? this.anchorOffset() + 1 : command.end;
const siblings = this.siblings();
for (let i = command.start; i < end; i++) {
// All these atoms are 'command' atom with a body that's
// a single character
result += siblings[i].body || '';
}
}
return result;
}
/**
* @param {boolean} value If true, decorate the command string around the
* insertion point with an error indicator (red dotted underline). If false,
* remove it.
* @method EditableMathlist#decorateCommandStringAroundInsertionPoint
* @private
*/
EditableMathlist.prototype.decorateCommandStringAroundInsertionPoint = function(value) {
const command = this.commandOffsets();
if (command) {
const siblings = this.siblings();
for (let i = command.start; i < command.end; i++) {
siblings[i].error = value;
}
}
}
/**
* @return {string}
* @method EditableMathlist#commitCommandStringBeforeInsertionPoint
* @private
*/
EditableMathlist.prototype.commitCommandStringBeforeInsertionPoint = function() {
const command = this.commandOffsets();
if (command) {
const siblings = this.siblings();
const anchorOffset = this.anchorOffset() + 1;
for (let i = command.start; i < anchorOffset; i++) {
if (siblings[i]) {
siblings[i].suggestion = false;
}
}
}
}
EditableMathlist.prototype.spliceCommandStringAroundInsertionPoint = function(mathlist) {
const command = this.commandOffsets();
if (command) {
// Dispatch notifications
this.contentWillChange();
if (!mathlist) {
this.siblings().splice(command.start, command.end - command.start);
this.setSelection(command.start - 1, 0);
} else {
Array.prototype.splice.apply(this.siblings(),
[command.start, command.end - command.start].concat(mathlist));
let newPlaceholders = [];
for (const atom of mathlist) {
newPlaceholders = newPlaceholders.concat(atom.filter(
atom => atom.type === 'placeholder'));
}
this.setExtent(0);
// Set the anchor offset to a reasonable value that can be used by
// leap(). In particular, the current offset value may be invalid
// if the length of the mathlist is shorter than the name of the command
this.path[this.path.length - 1].offset = command.start - 1;
if (newPlaceholders.length === 0 || !this.leap(+1, false)) {
this.setSelection(command.start + mathlist.length - 1);
}
}
// Dispatch notifications
this.contentDidChange();
}
}
function removeCommandStringFromAtom(atom) {
if (!atom) return;
if (Array.isArray(atom)) {
for (let i = atom.length - 1; i >= 0; i--) {
if (atom[i].type === 'command') {
atom.splice(i, 1);
// i += 1;
} else {
removeCommandStringFromAtom(atom[i]);
}
}
return;
}
removeCommandStringFromAtom(atom.body);
removeCommandStringFromAtom(atom.superscript);
removeCommandStringFromAtom(atom.subscript);
removeCommandStringFromAtom(atom.underscript);
removeCommandStringFromAtom(atom.overscript);
removeCommandStringFromAtom(atom.numer);
removeCommandStringFromAtom(atom.denom);
removeCommandStringFromAtom(atom.index);
if (atom.array) {
for (let j = arrayCellCount(atom.array); j >= 0; j--) {
removeCommandStringFromAtom(arrayCell(atom.array, j));
}
}
}
EditableMathlist.prototype.removeCommandString = function() {
this.contentWillChange();
const contentWasChanging = this.suppressChangeNotifications;
this.suppressChangeNotifications = true;
removeCommandStringFromAtom(this.root.body);
this.suppressChangeNotifications = contentWasChanging;
this.contentDidChange();
}
/**
* @return {string}
* @method EditableMathlist#extractArgBeforeInsertionPoint
* @private
*/
EditableMathlist.prototype.extractArgBeforeInsertionPoint = function() {
const siblings = this.siblings();
if (siblings.length <= 1) return [];
const result = [];
let i = this.startOffset();
if (siblings[i].mode === 'text') {
while (i >= 1 && siblings[i].mode === 'text') {
result.unshift(siblings[i]);
i--
}
} else {
while (i >= 1 &&
/^(mord|surd|msubsup|leftright|mop)$/.test(siblings[i].type)) {
result.unshift(siblings[i]);
i--
}
}
return result;
}
// 3 + 4(sin(x) > 3 + 4[sin(x)]/[ __ ]
// Add a frac inside a partial leftright: remove leftright
// When smartfence, add paren at end of expr
// a+3x=1 insert after + => paren before =
/**
* @param {number} offset
* - >0: index of the child in the group where the selection will start from
* - <0: index counting from the end of the group
* @param {number|string} [extent=0] Number of items in the selection:
* - 0: collapsed selection, single insertion point
* - >0: selection extending _after_ the offset
* - <0: selection extending _before_ the offset
* - `'end'`: selection extending to the end of the group
* - `'start'`: selection extending to the beginning of the group
* @param {string} relation e.g. `'body'`, `'superscript'`, etc...
* @return {boolean} False if the relation is invalid (no such children)
* @method EditableMathlist#setSelection
* @private
*/
EditableMathlist.prototype.setSelection = function(offset, extent, relation) {
offset = offset || 0;
extent = extent || 0;
// If no relation ("children", "superscript", etc...) is specified
// keep the current relation
const oldRelation = this.path[this.path.length - 1].relation;
if (!relation) relation = oldRelation;
// If the relation is invalid, exit and return false
const parent = this.parent();
if (!parent && relation !== 'body') return false;
const arrayRelation = relation.startsWith('cell');
if ((!arrayRelation && !parent[relation]) ||
(arrayRelation && !parent.array)) return false;
const relationChanged = relation !== oldRelation;
// Temporarily set the path to the potentially new relation to get the
// right siblings
this.path[this.path.length - 1].relation = relation;
// Invoking siblings() will have the side-effect of adding the 'first'
// atom if necessary
// ... and we want the siblings with the potentially new relation...
const siblings = this.siblings();
const siblingsCount = siblings.length;
// Restore the relation
this.path[this.path.length - 1].relation = oldRelation;
const oldExtent = this.extent;
if (extent === 'end') {
extent = siblingsCount - offset - 1;
} else if (extent === 'start') {
extent = -offset;
}
this.setExtent(extent);
const extentChanged = this.extent !== oldExtent;
this.setExtent(oldExtent);
// Calculate the new offset, and make sure it is in range
// (setSelection can be called with an offset that greater than
// the number of children, for example when doing an up from a
// numerator to a smaller denominator, e.g. "1/(x+1)".
if (offset < 0) {
offset = siblingsCount + offset;
}
offset = Math.max(0, Math.min(offset, siblingsCount - 1));
const oldOffset = this.path[this.path.length - 1].offset;
const offsetChanged = oldOffset !== offset;
if (relationChanged || offsetChanged || extentChanged) {
if (relationChanged) {
this.adjustPlaceholder();
}
this.selectionWillChange();
this.path[this.path.length - 1].relation = relation;
this.path[this.path.length - 1].offset = offset;
this.setExtent(extent);
this.selectionDidChange();
}
return true;
}
/**
* Move the anchor to the next permissible atom
* @method EditableMathlist#next
* @private
*/
EditableMathlist.prototype.next = function(options) {
options = options || {};
const NEXT_RELATION = {
'body': 'numer',
'numer': 'denom',
'denom': 'index',
'index': 'overscript',
'overscript': 'underscript',
'underscript': 'subscript',
'subscript': 'superscript'
}
if (this.anchorOffset() === this.siblings().length - 1) {
this.adjustPlaceholder();
// We've reached the end of this list.
// Is there another list to consider?
let relation = NEXT_RELATION[this.relation()];
const parent = this.parent();
while (relation && !parent[relation]) {
relation = NEXT_RELATION[relation];
}
// We found a new relation/set of siblings...
if (relation) {
this.setSelection(0, 0, relation);
return;
}
// No more siblings, check if we have a sibling cell in an array
if (this.parent().array) {
const maxCellCount = arrayCellCount(this.parent().array);
let cellIndex = parseInt(this.relation().match(/cell([0-9]*)$/)[1]) + 1;
while (cellIndex < maxCellCount) {
const cell = arrayCell(this.parent().array, cellIndex);
// Some cells could be null (sparse array), so skip them
if (cell && this.setSelection(0, 0, 'cell' + cellIndex)) {
this.selectionDidChange();
return;
}
cellIndex += 1;
}
}
// No more siblings, go up to the parent.
if (this.path.length === 1) {
// Invoke handler and perform default if they return true.
if (this.suppressChangeNotifications ||
!this.config.onMoveOutOf ||
this.config.onMoveOutOf(this, 'forward')) {
// We're at the root, so loop back
this.path[0].offset = 0;
}
} else {
// We've reached the end of the siblings. If we're a group
// with skipBoundary, when exiting, move one past the next atom
const skip = !options.iterateAll && this.parent().skipBoundary;
this.path.pop();
if (skip) {
this.next(options);
}
}
this.selectionDidChange();
return;
}
// Still some siblings to go through. Move on to the next one.
this.setSelection(this.anchorOffset() + 1);
const anchor = this.anchor();
// Dive into its components, if the new anchor is a compound atom,
// and allows capture of the selection by its sub-elements
if (anchor && !anchor.captureSelection) {
let relation;
if (anchor.array) {
// Find the first non-empty cell in this array
let cellIndex = 0;
relation = '';
const maxCellCount = arrayCellCount(anchor.array);
while (!relation && cellIndex < maxCellCount) {
// Some cells could be null (sparse array), so skip them
if (arrayCell(anchor.array, cellIndex)) {
relation = 'cell' + cellIndex.toString();
}
cellIndex += 1;
}
console.assert(relation);
this.path.push({relation:relation, offset: 0});
this.setSelection(0, 0 , relation);
return;
}
relation = 'body';
while (relation) {
if (Array.isArray(anchor[relation])) {
this.path.push({relation:relation, offset: 0});
this.insertFirstAtom();
if (!options.iterateAll && anchor.skipBoundary) this.next(options);
return;
}
relation = NEXT_RELATION[relation];
}
}
}
EditableMathlist.prototype.previous = function(options) {
options = options || {};
const PREVIOUS_RELATION = {
'numer': 'body',
'denom': 'numer',
'index': 'denom',
'overscript': 'index',
'underscript': 'overscript',
'subscript': 'underscript',
'superscript': 'subscript'
}
if (!options.iterateAll && this.anchorOffset() === 1 && this.parent() && this.parent().skipBoundary) {
this.setSelection(0);
}
if (this.anchorOffset() < 1) {
// We've reached the first of these siblings.
// Is there another set of siblings to consider?
let relation = PREVIOUS_RELATION[this.relation()];
while (relation && !this.setSelection(-1, 0 , relation)) {
relation = PREVIOUS_RELATION[relation];
}
// Ignore the body of the subsup scaffolding and of
// 'mop' atoms (for example, \sum): their body is not editable.
const parentType = this.parent() ? this.parent().type : 'none';
if (relation === 'body' && (parentType === 'msubsup' || parentType === 'mop')) {
relation = null;
}
// We found a new relation/set of siblings...
if (relation) return;
this.adjustPlaceholder();
this.selectionWillChange();
// No more siblings, check if we have a sibling cell in an array
if (this.relation().startsWith('cell')) {
let cellIndex = parseInt(this.relation().match(/cell([0-9]*)$/)[1]) - 1;
while (cellIndex >= 0) {
const cell = arrayCell(this.parent().array, cellIndex);
if (cell && this.setSelection(-1, 0, 'cell' + cellIndex)) {
this.selectionDidChange();
return;
}
cellIndex -= 1;
}
}
// No more siblings, go up to the parent.
if (this.path.length === 1) {
// Invoke handler and perform default if they return true.
if (this.suppressChangeNotifications ||
!this.config.onMoveOutOf ||
this.config.onMoveOutOf.bind(this)(-1)) {
// We're at the root, so loop back
this.path[0].offset = this.root.body.length - 1;
}
} else {
this.path.pop();
this.setSelection(this.anchorOffset() - 1);
}
this.selectionDidChange();
return;
}
// If the new anchor is a compound atom, dive into its components
const anchor = this.anchor();
// Only dive in if the atom allows capture of the selection by
// its sub-elements
if (!anchor.captureSelection) {
let relation;
if (anchor.array) {
relation = '';
const maxCellCount = arrayCellCount(anchor.array);
let cellIndex = maxCellCount - 1;
while (!relation && cellIndex < maxCellCount) {
// Some cells could be null (sparse array), so skip them
if (arrayCell(anchor.array, cellIndex)) {
relation = 'cell' + cellIndex.toString();
}
cellIndex -= 1;
}
cellIndex += 1;
console.assert(relation);
this.path.push({relation:relation,
offset: arrayCell(anchor.array, cellIndex).length - 1});
this.setSelection(-1, 0 , relation);
return;
}
relation = 'superscript';
while (relation) {
if (Array.isArray(anchor[relation])) {
this.path.push({relation:relation,
offset: anchor[relation].length - 1});
this.setSelection(-1, 0, relation);
return;
}
relation = PREVIOUS_RELATION[relation];
}
}
// There wasn't a component to navigate to, so...
// Still some siblings to go through: move on to the previous one.
this.setSelection(this.anchorOffset() - 1);
if (!options.iterateAll && this.sibling(0) && this.sibling(0).skipBoundary) {
this.previous(options);
}
}
EditableMathlist.prototype.move = function(dist, options) {
options = options || {extend: false};
const extend = options.extend || false;
this.removeSuggestion();
if (extend) {
this.extend(dist, options);
} else {
const oldPath = clone(this);
// const previousParent = this.parent();
// const previousRelation = this.relation();
// const previousSiblings = this.siblings();
if (dist > 0) {
if (this.collapseForward()) dist--;
while (dist > 0) {
this.next();
dist--;
}
} else if (dist < 0) {
if (this.collapseBackward()) dist++;
while (dist !== 0) {
this.previous();
dist++;
}
}
// ** @todo: can't do that without updating the path.
// If the siblings list we left was empty, remove the relation
// if (previousSiblings.length <= 1) {
// if (['superscript', 'subscript', 'index'].includes(previousRelation)) {
// previousParen