UNPKG

mathlive

Version:

Render and edit beautifully typeset math

1,604 lines (1,413 loc) 118 kB
/** * 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 * - &gt;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 * - &gt;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