UNPKG

mathlive

Version:

Render and edit beautifully typeset math

1,159 lines (1,136 loc) 145 kB
/** * * See {@linkcode MathField} * @module editor/mathfield * @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 Span from '../core/span.js'; import EditableMathlist from './editor-editableMathlist.js'; import MathPath from './editor-mathpath.js'; import Keyboard from './editor-keyboard.js'; import Undo from './editor-undo.js'; import Shortcuts from './editor-shortcuts.js'; import Popover from './editor-popover.js'; import VirtualKeyboard from './editor-virtualKeyboard.js'; import GraphemeSplitter from '../core/grapheme-splitter.js'; import { toASCIIMath } from './outputASCIIMath.js'; import { l10n } from './l10n.js'; import '../addons/outputLatex.js'; import '../addons/outputMathML.js'; import '../addons/maston.js'; import '../addons/outputSpokenText.js'; /* Note: The OutputLatex, OutputMathML, MASTON and OutputSpokenText modules are required, even though they are not referenced directly. They modify the MathAtom class, adding toLatex(), toAST(), toMathML() and toSpeakableText() respectively. */ /* The default eslint parser, espree, does not parse the "declare type" correctly. Could use a different parser (babel-eslint), but to avoid bringing in another dependency, just turn off linting for this line */ /* eslint-disable */ /** * @typedef {function} MathFieldCallback * @param {MathField} mf * @return {void} * @global */ /* eslint-enable */ /** @typedef MathFieldConfig @type {Object} @property {string} locale? @property {object<string, string>} strings? @property {number} horizontalSpacingScale? @property {string} namespace? @property {function} substituteTextArea? @property {"math" | "text"} defaultMode? @property {MathFieldCallback} onFocus? @property {MathFieldCallback} onBlur? @property {function} onKeystroke? @property {function} onAnnounce? @property {boolean} overrideDefaultInlineShortcuts? @property {object<string, string>} inlineShortcuts? @property {number} inlineShortcutTimeout? @property {boolean} smartFence? @property {boolean} smartSuperscript? @property {number} scriptDepth? @property {boolean} removeExtraneousParentheses? @property {boolean} ignoreSpacebarInMathMode? @property {string} virtualKeyboardToggleGlyph? @property {"manual" | "onfocus" | "off" } virtualKeyboardMode? @property {"all" | "numeric" | "roman" | "greek" | "functions" | "command" | string} virtualKeyboards? @property {"qwerty" | "azerty" | "qwertz" | "dvorak" | "colemak"} virtualKeyboardRomanLayout? @property {object<string, string>} customVirtualKeyboardLayers? @property {object<string, object>} customVirtualKeyboards? @property {"material" | "apple" | ""} virtualKeyboardTheme? @property {boolean} keypressVibration? @property {string} keypressSound? @property {string} plonkSound? @property {"mathlive" | "sre"} textToSpeechRules? @property {"ssml" | "mac"} textToSpeechMarkup? @property {object} textToSpeechRulesOptions? @property {"local" | "amazon"} speechEngine? @property {string} speechEngineVoice? @property {string} speechEngineRate? @property {function} onMoveOutOf? @property {function} onTabOutOf? @property {MathFieldCallback} onContentWillChange? @property {MathFieldCallback} onContentDidChange? @property {MathFieldCallback} onSelectionWillChange? @property {MathFieldCallback} onSelectionDidChange? @property {function} onUndoStateWillChange? @property {function} onUndoStateDidChange? @property {function} onModeChange? @property {function} onVirtualKeyboardToggle? @property {function} onReadAloudStatus? @property {function} handleSpeak? @property {function} handleReadAloud? @global */ const HAPTIC_FEEDBACK_DURATION = 3; // in ms const AUDIO_FEEDBACK_VOLUME = 0.5; // from 0.0 to 1.0 function on(el, selectors, listener, options) { selectors = selectors.split(' '); for (const sel of selectors) { const m = sel.match(/(.*):(.*)/); if (m) { const options2 = options || {}; if (m[2] === 'active') { options2.passive = false; } else { options2[m[2]] = true; } el.addEventListener(m[1], listener, options2); } else { el.addEventListener(sel, listener, options); } } } function off(el, selectors, listener, options) { selectors = selectors.split(' '); for (const sel of selectors) { const m = sel.match(/(.*):(.*)/); if (m) { const options2 = options || {}; if (m[2] === 'active') { options2.passive = false; } else { options2[m[2]] = true; } el.removeEventListener(m[1], listener, options2); } else { el.removeEventListener(sel, listener, options); } } } function getSharedElement(id, cls) { let result = document.getElementById(id); if (result) { result.setAttribute('data-refcount', parseInt(result.getAttribute('data-refcount')) + 1); } else { result = document.createElement('div'); result.setAttribute('aria-hidden', 'true'); result.setAttribute('data-refcount', '1'); result.className = cls; result.id = id; document.body.appendChild(result); } return result; } function releaseSharedElement(el) { if (!el) return null; const refcount = parseInt(el.getAttribute('data-refcount')); if (!refcount || refcount === 1) { el.remove(); } else { el.setAttribute('data-refcount', refcount - 1); } return el; } /** * Validate a style specification object * @param {object} style * @private */ function validateStyle(style) { const result = {}; if (typeof style.mode === 'string') { result.mode = style.mode.toLowerCase(); console.assert(result.mode === 'math' || result.mode === 'text' || result.mode === 'command'); } if (typeof style.color === 'string') { result.color = style.color; } if (typeof style.backgroundColor === 'string') { result.backgroundColor = style.backgroundColor; } if (typeof style.fontFamily === 'string') { result.fontFamily = style.fontFamily; } if (typeof style.series === 'string') { result.fontSeries = style.series; } if (typeof style.fontSeries === 'string') { result.fontSeries = style.fontSeries.toLowerCase(); } if (result.fontSeries) { result.fontSeries = { "bold": 'b', "medium": 'm', "normal": 'mn', }[result.fontSeries] || result.fontSeries; } if (typeof style.shape === 'string') { result.fontShape = style.shape; } if (typeof style.fontShape === 'string') { result.fontShape = style.fontShape.toLowerCase(); } if (result.fontShape) { result.fontShape = { "italic": 'it', "up": 'n', "upright": 'n', "normal": 'n', }[result.fontShape] || result.fontShape; } if (typeof style.size === 'string') { result.fontSize = style.size; } else if (typeof style.size === 'number') { result.fontSize = 'size' + Math.min(0, Math.max(10, style.size)); } if (typeof style.fontSize === 'string') { result.fontSize = style.fontSize.toLowerCase(); } if (result.fontSize) { result.fontSize = { 'tiny': 'size1', 'scriptsize': 'size2', 'footnotesize': 'size3', 'small': 'size4', 'normal': 'size5', 'normalsize': 'size5', 'large': 'size6', 'Large': 'size7', 'LARGE': 'size8', 'huge': 'size9', 'Huge': 'size10' }[result.fontSize] || result.fontSize; } return result; } /* * **Note** * - Method names that begin with `$` are public. * - 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}. Note * that the selector name does not include the underbar. * * For example: * ``` * mf.$perform('selectAll'); * ``` */ /** * * @property {HTMLElement} element - The DOM element this mathfield is attached to. * @property {Object.<string, any>} config - A set of key/value pairs that can * be used to customize the behavior of the mathfield * @property {string} id - A unique ID identifying this mathfield * @property {boolean} keystrokeCaptionVisible - True if the keystroke caption * panel is visible * @property {boolean} virtualKeyboardVisible - True if the virtual keyboard is * visible * @property {string} keystrokeBuffer The last few keystrokes, to look out * for inline shortcuts * @property {object[]} keystrokeBufferStates The saved state for each of the * past keystrokes * @class MathField * @global */ class MathField { /** * To create a mathfield, you would typically use {@linkcode module:MathLive#makeMathField MathLive.makeMathField()} * instead of invoking directly this constructor. * * * @param {HTMLElement} element - The DOM element that this mathfield is attached to. * Note that `element.mathfield` is this object. * @param {MathFieldConfig} config - See {@tutorial CONFIG} for details * @method MathField#constructor * @private */ constructor(element, config) { // Setup default config options this.$setConfig(config || {}); this.element = element; element.mathfield = this; // Save existing content this.originalContent = element.innerHTML; let elementText = this.element.textContent; if (elementText) { elementText = elementText.trim(); } // Additional elements used for UI. // They are retrieved in order a bit later, so they need to be kept in sync // 1.0/ The field, where the math equation will be displayed // 1.1/ The virtual keyboard toggle // 2/ The popover panel which displays info in command mode // 3/ The keystroke caption panel (option+shift+K) // 4/ The virtual keyboard // 5.0/ The area to stick MathML for screen reading larger exprs (not used right now) // The for the area is that focus would bounce their and then back triggering the // screen reader to read it // 5.1/ The aria-live region for announcements let markup = ''; if (!this.config.substituteTextArea) { if (/android|ipad|ipod|iphone/i.test(navigator.userAgent)) { // On Android or iOS, don't use a textarea, which has the side effect of // bringing up the OS virtual keyboard markup += `<span class='ML__textarea'> <span class='ML__textarea__textarea' tabindex="0" role="textbox" style='display:inline-block;height:1px;width:1px' > </span> </span>`; } else { markup += '<span class="ML__textarea">' + '<textarea class="ML__textarea__textarea" autocapitalize="off" autocomplete="off" ' + 'autocorrect="off" spellcheck="false" aria-hidden="true" tabindex="0">' + '</textarea>' + '</span>'; } } else { if (typeof this.config.substituteTextArea === 'string') { markup += this.config.substituteTextArea; } else { // We don't really need this one, but we keep it here so that the // indexes below remain the same whether a substituteTextArea is // provided or not. markup += '<span></span>'; } } markup += '<span class="ML__fieldcontainer">' + '<span class="ML__fieldcontainer__field"></span>'; // If no value is specified for the virtualKeyboardMode, use // `onfocus` on touch-capable devices and `off` otherwise. if (!this.config.virtualKeyboardMode) { this.config.virtualKeyboardMode = (window.matchMedia && window.matchMedia("(any-pointer: coarse)").matches) ? 'onfocus' : 'off'; } // Only display the virtual keyboard toggle if the virtual keyboard mode is // 'manual' if (this.config.virtualKeyboardMode === 'manual') { markup += `<button class="ML__virtual-keyboard-toggle" data-tooltip="${l10n('tooltip.toggle virtual keyboard')}">`; // data-tooltip='Toggle Virtual Keyboard' if (this.config.virtualKeyboardToggleGlyph) { markup += this.config.virtualKeyboardToggleGlyph; } else { markup += `<span style="width: 21px; margin-top: 4px;"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 576 512"><path d="M528 64H48C21.49 64 0 85.49 0 112v288c0 26.51 21.49 48 48 48h480c26.51 0 48-21.49 48-48V112c0-26.51-21.49-48-48-48zm16 336c0 8.823-7.177 16-16 16H48c-8.823 0-16-7.177-16-16V112c0-8.823 7.177-16 16-16h480c8.823 0 16 7.177 16 16v288zM168 268v-24c0-6.627-5.373-12-12-12h-24c-6.627 0-12 5.373-12 12v24c0 6.627 5.373 12 12 12h24c6.627 0 12-5.373 12-12zm96 0v-24c0-6.627-5.373-12-12-12h-24c-6.627 0-12 5.373-12 12v24c0 6.627 5.373 12 12 12h24c6.627 0 12-5.373 12-12zm96 0v-24c0-6.627-5.373-12-12-12h-24c-6.627 0-12 5.373-12 12v24c0 6.627 5.373 12 12 12h24c6.627 0 12-5.373 12-12zm96 0v-24c0-6.627-5.373-12-12-12h-24c-6.627 0-12 5.373-12 12v24c0 6.627 5.373 12 12 12h24c6.627 0 12-5.373 12-12zm-336 80v-24c0-6.627-5.373-12-12-12H84c-6.627 0-12 5.373-12 12v24c0 6.627 5.373 12 12 12h24c6.627 0 12-5.373 12-12zm384 0v-24c0-6.627-5.373-12-12-12h-24c-6.627 0-12 5.373-12 12v24c0 6.627 5.373 12 12 12h24c6.627 0 12-5.373 12-12zM120 188v-24c0-6.627-5.373-12-12-12H84c-6.627 0-12 5.373-12 12v24c0 6.627 5.373 12 12 12h24c6.627 0 12-5.373 12-12zm96 0v-24c0-6.627-5.373-12-12-12h-24c-6.627 0-12 5.373-12 12v24c0 6.627 5.373 12 12 12h24c6.627 0 12-5.373 12-12zm96 0v-24c0-6.627-5.373-12-12-12h-24c-6.627 0-12 5.373-12 12v24c0 6.627 5.373 12 12 12h24c6.627 0 12-5.373 12-12zm96 0v-24c0-6.627-5.373-12-12-12h-24c-6.627 0-12 5.373-12 12v24c0 6.627 5.373 12 12 12h24c6.627 0 12-5.373 12-12zm96 0v-24c0-6.627-5.373-12-12-12h-24c-6.627 0-12 5.373-12 12v24c0 6.627 5.373 12 12 12h24c6.627 0 12-5.373 12-12zm-96 152v-8c0-6.627-5.373-12-12-12H180c-6.627 0-12 5.373-12 12v8c0 6.627 5.373 12 12 12h216c6.627 0 12-5.373 12-12z"/></svg></span>`; } markup += '</button>'; } else { markup += '<span ></span>'; } markup += '</span>'; markup += ` <div class="sr-only"> <span aria-live="assertive" aria-atomic="true"></span> <span></span> </div> `; this.element.innerHTML = markup; let iChild = 0; // index of child -- used to make changes below easier if (typeof this.config.substituteTextArea === 'function') { this.textarea = this.config.substituteTextArea(); } else { this.textarea = this.element.children[iChild++].firstElementChild; } this.field = this.element.children[iChild].children[0]; // Listen to 'wheel' events to scroll (horizontally) the field when it overflows this.field.addEventListener('wheel', ev => { ev.preventDefault(); ev.stopPropagation(); let wheelDelta = typeof ev.deltaX === 'undefined' ? ev.detail : -ev.deltaX; if (!isFinite(wheelDelta)) { wheelDelta = ev.wheelDelta / 10; } this.field.scroll({ top: 0, left: this.field.scrollLeft - wheelDelta * 5 }); }, { passive: false }); this.virtualKeyboardToggleDOMNode = this.element.children[iChild++].children[1]; this._attachButtonHandlers(this.virtualKeyboardToggleDOMNode, { default: 'toggleVirtualKeyboard', alt: 'toggleVirtualKeyboardAlt', shift: 'toggleVirtualKeyboardShift' }); this.ariaLiveText = this.element.children[iChild].children[0]; this.accessibleNode = this.element.children[iChild++].children[1]; // Some panels are shared amongst instances of mathfield // (there's a single instance in the document) this.popover = getSharedElement('mathlive-popover-panel', 'ML__popover'); this.keystrokeCaption = getSharedElement('mathlive-keystroke-caption-panel', 'ML__keystroke-caption'); // The keystroke caption panel and the command bar are // initially hidden this.keystrokeCaptionVisible = false; this.virtualKeyboardVisible = false; this.keystrokeBuffer = ''; this.keystrokeBufferStates = []; this.keystrokeBufferResetTimer = null; // This index indicates which of the suggestions available to // display in the popover panel this.suggestionIndex = 0; // The input mode (text, math, command) // While mathlist.anchorMode() represent the mode of the current selection, // this.mode is the mode chosen by the user. It indicates the mode the // next character typed will be interpreted in. // It is often identical to mathlist.anchorMode() since changing the selection // changes the mode, but sometimes it is not, for example when a user // enters a mode changing command. this.mode = config.defaultMode || 'math'; this.smartModeSuppressed = false; // Current style (color, weight, italic, etc...) // Reflects the style to be applied on next insertion, if any this.style = {}; // Focus/blur state this.blurred = true; on(this.element, 'focus', this); on(this.element, 'blur', this); // Capture clipboard events on(this.textarea, 'cut', this); on(this.textarea, 'copy', this); on(this.textarea, 'paste', this); // Delegate keyboard events Keyboard.delegateKeyboardEvents(this.textarea, { container: this.element, allowDeadKey: () => this.mode === 'text', typedText: this._onTypedText.bind(this), paste: this._onPaste.bind(this), keystroke: this._onKeystroke.bind(this), focus: this._onFocus.bind(this), blur: this._onBlur.bind(this), }); // Delegate mouse and touch events if (window.PointerEvent) { // Use modern pointer events if available on(this.field, 'pointerdown', this); } else { on(this.field, 'touchstart:active mousedown', this); } // Request notification for when the window is resized ( // or the device switched from portrait to landscape) to adjust // the UI (popover, etc...) on(window, 'resize', this); // Override some handlers in the config const localConfig = { ...config }; localConfig.onSelectionDidChange = MathField.prototype._onSelectionDidChange.bind(this); localConfig.onContentDidChange = MathField.prototype._onContentDidChange.bind(this); localConfig.onAnnounce = this.config.onAnnounce; localConfig.macros = this.config.macros; localConfig.removeExtraneousParentheses = this.config.removeExtraneousParentheses; this.mathlist = new EditableMathlist.EditableMathlist(localConfig, this); // Prepare to manage undo/redo this.undoManager = new Undo.UndoManager(this.mathlist); // If there was some content in the element, use it for the initial // value of the mathfield if (elementText.length > 0) { this.$latex(elementText); } // Now start recording potentially undoable actions this.undoManager.startRecording(); this.undoManager.snapshot(this.config); } /* * handleEvent is a function invoked when an event is registered with an * object instead ( see `addEventListener()` in `on()`) * The name is defined by addEventListener() and cannot be changed. * This pattern is used to be able to release bound event handlers, * (event handlers that need access to `this`) as the bind() function * would create a new function that would have to be kept track off * to be able to properly remove the event handler later. */ handleEvent(evt) { switch (evt.type) { case 'focus': this._onFocus(evt); break; case 'blur': this._onBlur(evt); break; case 'touchstart': this._onPointerDown(evt); break; case 'mousedown': this._onPointerDown(evt); break; case 'pointerdown': this._onPointerDown(evt); break; case 'resize': { if (this._resizeTimer) { window.cancelAnimationFrame(this._resizeTimer); } this._resizeTimer = window.requestAnimationFrame(() => this._onResize()); break; } case 'cut': this._onCut(evt); break; case 'copy': this._onCopy(evt); break; case 'paste': this._onPaste(evt); break; default: console.warn('Unexpected event type', evt.type); } } /** * Revert this math field to its original content. After this method has been * called, no other methods can be called on the MathField object. To turn the * element back into a MathField, call `MathLive.makeMathField()` on the * element again to get a new math field object. * * @method MathField#$revertToOriginalContent */ $revertToOriginalContent() { this.element.innerHTML = this.originalContent; this.element.mathfield = null; delete this.accessibleNode; delete this.ariaLiveText; delete this.field; off(this.textarea, 'cut', this); off(this.textarea, 'copy', this); off(this.textarea, 'paste', this); this.textarea.remove(); delete this.textarea; this.virtualKeyboardToggleDOMNode.remove(); delete this.virtualKeyboardToggleDOMNode; delete releaseSharedElement(this.popover); delete releaseSharedElement(this.keystrokeCaption); delete releaseSharedElement(this.virtualKeyboard); delete releaseSharedElement(document.getElementById('mathlive-alternate-keys-panel')); off(this.element, 'pointerdown', this); off(this.element, 'touchstart:active mousedown', this); off(this.element, 'focus', this); off(this.element, 'blur', this); off(window, 'resize', this); } _resetKeystrokeBuffer() { this.keystrokeBuffer = ''; this.keystrokeBufferStates = []; clearTimeout(this.keystrokeBufferResetTimer); } /** * Return the (x,y) client coordinates of the caret * * @method MathField#_getCaretPosition * @private */ _getCaretPosition() { const caret = _findElementWithCaret(this.field); if (caret) { const bounds = caret.getBoundingClientRect(); return { x: bounds.right + window.scrollX, y: bounds.bottom + window.scrollY }; } return null; } _getSelectionBounds() { const selectedNodes = this.field.querySelectorAll('.ML__selected'); if (selectedNodes && selectedNodes.length > 0) { const selectionRect = { top: Infinity, bottom: -Infinity, left: Infinity, right: -Infinity }; // Calculate the union of the bounds of all the selected spans selectedNodes.forEach(node => { const bounds = node.getBoundingClientRect(); if (bounds.left < selectionRect.left) { selectionRect.left = bounds.left; } if (bounds.right > selectionRect.right) { selectionRect.right = bounds.right; } if (bounds.bottom > selectionRect.bottom) { selectionRect.bottom = bounds.bottom; } if (bounds.top < selectionRect.top) { selectionRect.top = bounds.top; } }); const fieldRect = this.field.getBoundingClientRect(); const w = selectionRect.right - selectionRect.left; const h = selectionRect.bottom - selectionRect.top; selectionRect.left = Math.ceil(selectionRect.left - fieldRect.left + this.field.scrollLeft); selectionRect.right = selectionRect.left + w; selectionRect.top = Math.ceil(selectionRect.top - fieldRect.top); selectionRect.bottom = selectionRect.top + h; return selectionRect; } return null; } /** * @param {number} x * @param {number} y * @param {object} options * @param {boolean} options.bias if 0, the midpoint of the bounding box * is considered to return the sibling. If <0, the left sibling is * favored, if >0, the right sibling * @private */ _pathFromPoint(x, y, options) { options = options || {}; options.bias = options.bias || 0; let result; // Try to find the deepest element that is near the point that was // clicked on (the point could be outside of the element) const nearest = nearestElementFromPoint(this.field, x, y); const el = nearest.element; const id = el ? el.getAttribute('data-atom-id') : null; if (id) { // Let's find the atom that has a matching ID with the element that // was clicked on (or near) const paths = this.mathlist.filter(function (_path, atom) { // If the atom allows children to be selected, match only if // the ID of the atom matches the one we're looking for. if (!atom.captureSelection) { return atom.id === id; } // If the atom does not allow children to be selected // (captureSelection === true), the element matches if any of // its children has an ID that matches. return atom.filter(childAtom => childAtom.id === id).length > 0; }); if (paths && paths.length > 0) { // (There should be exactly one atom that matches this ID...) // Set the result to the path to this atom result = MathPath.pathFromString(paths[0]).path; if (options.bias === 0) { // If the point clicked is to the left of the vertical midline, // adjust the path to *before* the atom (i.e. after the // preceding atom) const bounds = el.getBoundingClientRect(); if (x < bounds.left + bounds.width / 2 && !el.classList.contains('ML__placeholder')) { result[result.length - 1].offset = Math.max(0, result[result.length - 1].offset - 1); } } else if (options.bias < 0) { result[result.length - 1].offset = Math.min(this.mathlist.siblings().length - 1, Math.max(0, result[result.length - 1].offset + options.bias)); } } } return result; } _onPointerDown(evt) { const that = this; let anchor; let trackingPointer = false; let trackingWords = false; let dirty = false; // If a mouse button other than the main one was pressed, return if (evt.buttons !== 1) { return; } function endPointerTracking(evt) { if (window.PointerEvent) { off(that.field, 'pointermove', onPointerMove); off(that.field, 'pointerend pointerleave pointercancel', endPointerTracking); // off(window, 'pointermove', onPointerMove); // off(window, 'pointerup blur', endPointerTracking); that.field.releasePointerCapture(evt.pointerId); } else { off(that.field, 'touchmove', onPointerMove); off(that.field, 'touchend touchleave', endPointerTracking); off(window, 'mousemove', onPointerMove); off(window, 'mouseup blur', endPointerTracking); } trackingPointer = false; clearInterval(scrollInterval); that.element.querySelectorAll('.ML__scroller').forEach(x => x.parentNode.removeChild(x)); evt.preventDefault(); evt.stopPropagation(); } let scrollLeft = false; let scrollRight = false; const scrollInterval = setInterval(() => { if (scrollLeft) { that.field.scroll({ top: 0, left: that.field.scrollLeft - 16 }); } else if (scrollRight) { that.field.scroll({ top: 0, left: that.field.scrollLeft + 16 }); } }, 32); function onPointerMove(evt) { const x = evt.touches ? evt.touches[0].clientX : evt.clientX; const y = evt.touches ? evt.touches[0].clientY : evt.clientY; // Ignore events that are within small spatial and temporal bounds // of the pointer down const hysteresis = evt.pointerType === 'touch' ? 20 : 5; if (Date.now() < anchorTime + 500 && Math.abs(anchorX - x) < hysteresis && Math.abs(anchorY - y) < hysteresis) { evt.preventDefault(); evt.stopPropagation(); return; } const fieldBounds = that.field.getBoundingClientRect(); scrollRight = x > fieldBounds.right; scrollLeft = x < fieldBounds.left; let actualAnchor = anchor; if (window.PointerEvent) { if (!evt.isPrimary) { actualAnchor = that._pathFromPoint(evt.clientX, evt.clientY, { bias: 0 }); } } else { if (evt.touches && evt.touches.length === 2) { actualAnchor = that._pathFromPoint(evt.touches[1].clientX, evt.touches[1].clientY, { bias: 0 }); } } const focus = that._pathFromPoint(x, y, { bias: x <= anchorX ? (x === anchorX ? 0 : -1) : +1 }); if (focus && that.mathlist.setRange(actualAnchor, focus, { extendToWordBoundary: trackingWords })) { // Re-render if the range has actually changed that._requestUpdate(); } // Prevent synthetic mouseMove event when this is a touch event evt.preventDefault(); evt.stopPropagation(); } const anchorX = evt.touches ? evt.touches[0].clientX : evt.clientX; const anchorY = evt.touches ? evt.touches[0].clientY : evt.clientY; const anchorTime = Date.now(); // Calculate the tap count if (lastTap && Math.abs(lastTap.x - anchorX) < 5 && Math.abs(lastTap.y - anchorY) < 5 && Date.now() < lastTap.time + 500) { tapCount += 1; lastTap.time = anchorTime; } else { lastTap = { x: anchorX, y: anchorY, time: anchorTime }; tapCount = 1; } const bounds = this.field.getBoundingClientRect(); if (anchorX >= bounds.left && anchorX <= bounds.right && anchorY >= bounds.top && anchorY <= bounds.bottom) { // Create divs to block out pointer tracking to the left and right of // the math field (to avoid triggering the hover of the virtual // keyboard toggle, for example) let div = document.createElement('div'); div.className = 'ML__scroller'; this.element.appendChild(div); div.style.left = (bounds.left - 200) + 'px'; div = document.createElement('div'); div.className = 'ML__scroller'; this.element.appendChild(div); div.style.left = (bounds.right) + 'px'; // Focus the math field if (!this.$hasFocus()) { dirty = true; if (this.textarea.focus) { this.textarea.focus(); } } // Clicking or tapping the field resets the keystroke buffer and // smart mode this._resetKeystrokeBuffer(); this.smartModeSuppressed = false; anchor = this._pathFromPoint(anchorX, anchorY, { bias: 0 }); if (anchor) { if (evt.shiftKey) { // Extend the selection if the shift-key is down this.mathlist.setRange(this.mathlist.path, anchor); anchor = MathPath.clone(this.mathlist.path); anchor[anchor.length - 1].offset -= 1; } else { this.mathlist.setPath(anchor, 0); } // The selection has changed, so we'll need to re-render dirty = true; // Reset any user-specified style this.style = {}; // evt.detail contains the number of consecutive clicks // for double-click, triple-click, etc... // (note that evt.detail is not set when using pointerEvent) if (evt.detail === 3 || tapCount > 2) { endPointerTracking(evt); if (evt.detail === 3 || tapCount === 3) { // This is a triple-click this.mathlist.selectAll_(); } } else if (!trackingPointer) { trackingPointer = true; if (window.PointerEvent) { on(that.field, 'pointermove', onPointerMove); on(that.field, 'pointerend pointercancel pointerup', endPointerTracking); that.field.setPointerCapture(evt.pointerId); } else { on(window, 'blur', endPointerTracking); if (evt.touches) { // To receive the subsequent touchmove/touch, need to // listen to this evt.target. // This was a touch event on(evt.target, 'touchmove', onPointerMove); on(evt.target, 'touchend', endPointerTracking); } else { on(window, 'mousemove', onPointerMove); on(window, 'mouseup', endPointerTracking); } } if (evt.detail === 2 || tapCount === 2) { // This is a double-click trackingWords = true; this.mathlist.selectGroup_(); } } } } else { lastTap = null; } if (dirty) { this._requestUpdate(); } // Prevent the browser from handling, in particular when this is a // touch event prevent the synthetic mouseDown event from being generated evt.preventDefault(); } _onSelectionDidChange() { // Every atom before the new caret position is now committed this.mathlist.commitCommandStringBeforeInsertionPoint(); // If the selection is not collapsed, put it in the textarea // This will allow cut/copy to work. let result = ''; this.mathlist.forEachSelected(atom => { result += atom.toLatex(); }); if (result) { this.textarea.value = result; // The textarea may be a span (on mobile, for example), so check that // it has a select() before calling it. if (this.$hasFocus() && this.textarea.select) { this.textarea.select(); } } else { this.textarea.value = ''; this.textarea.setAttribute('aria-label', ''); } // Update the mode { const previousMode = this.mode; this.mode = this.mathlist.anchorMode() || this.config.defaultMode; if (this.mode !== previousMode && typeof this.config.onModeChange === 'function') { this.config.onModeChange(this, this.mode); } if (previousMode === 'command' && this.mode !== 'command') { Popover.hidePopover(this); this.mathlist.removeCommandString(); } } // Defer the updating of the popover position: we'll need the tree to be // re-rendered first to get an updated caret position Popover.updatePopoverPosition(this, { deferred: true }); // Invoke client handlers, if provided. if (typeof this.config.onSelectionDidChange === 'function') { this.config.onSelectionDidChange(this); } } _onContentDidChange() { if (this.undoManager.canRedo()) { this.element.classList.add('can-redo'); } else { this.element.classList.remove('can-redo'); } if (this.undoManager.canUndo()) { this.element.classList.add('can-undo'); } else { this.element.classList.remove('can-undo'); } if (typeof this.config.onContentDidChange === 'function') { this.config.onContentDidChange(this); } } /* Returns the speech text of the next atom after the selection or * an 'end of' phrasing based on what structure we are at the end of */ _nextAtomSpeechText(oldMathlist) { function relation(parent, leaf) { const EXPR_NAME = { // 'array': 'should not happen', 'numer': 'numerator', 'denom': 'denominator', 'index': 'index', 'body': 'parent', 'subscript': 'subscript', 'superscript': 'superscript' }; const PARENT_NAME = { 'enclose': 'cross out', 'leftright': 'fence', 'surd': 'square root', 'root': 'math field' }; return (leaf.relation === 'body' ? PARENT_NAME[parent.type] : EXPR_NAME[leaf.relation]); } const oldPath = oldMathlist ? oldMathlist.path : []; const path = this.mathlist.path; const leaf = path[path.length - 1]; let result = ''; while (oldPath.length > path.length) { result += 'out of ' + relation(oldMathlist.parent(), oldPath[oldPath.length - 1]) + '; '; oldPath.pop(); } if (!this.mathlist.isCollapsed()) { return speakableText(this, '', this.mathlist.getSelectedAtoms()); } // announce start of denominator, etc const relationName = relation(this.mathlist.parent(), leaf); if (leaf.offset === 0) { result += (relationName ? 'start of ' + relationName : 'unknown') + ': '; } const atom = this.mathlist.sibling(Math.max(1, this.mathlist.extent)); if (atom) { result += speakableText(this, '', atom); } else if (leaf.offset !== 0) { // don't say both start and end result += relationName ? 'end of ' + relationName : 'unknown'; } return result; } _announce(command, mathlist, atoms) { if (typeof this.config.onAnnounce === 'function') { this.config.onAnnounce(this, command, mathlist, atoms); } } _onFocus() { if (this.blurred) { this.blurred = false; // The textarea may be a span (on mobile, for example), so check that // it has a focus() before calling it. if (this.textarea.focus) { this.textarea.focus(); } if (this.config.virtualKeyboardMode === 'onfocus') { this.showVirtualKeyboard_(); } Popover.updatePopoverPosition(this); if (this.config.onFocus) { this.config.onFocus(this); } this._requestUpdate(); } } _onBlur() { if (!this.blurred) { this.blurred = true; this.ariaLiveText.textContent = ''; if (this.config.virtualKeyboardMode === 'onfocus') { this.hideVirtualKeyboard_(); } this.complete_({ discard: true }); this._requestUpdate(); if (this.config.onBlur) { this.config.onBlur(this); } } } _onResize() { this.element.classList.remove('ML__isNarrowWidth', 'ML__isWideWidth', 'ML__isExtendedWidth'); if (window.innerWidth >= 1024) { this.element.classList.add('ML__isExtendedWidth'); } else if (window.innerWidth >= 768) { this.element.classList.add('ML__isWideWidth'); } else { this.element.classList.add('ML__isNarrowWidth'); } Popover.updatePopoverPosition(this); } toggleKeystrokeCaption_() { this.keystrokeCaptionVisible = !this.keystrokeCaptionVisible; this.keystrokeCaption.innerHTML = ''; if (!this.keystrokeCaptionVisible) { this.keystrokeCaption.style.visibility = 'hidden'; } } _showKeystroke(keystroke) { const vb = this.keystrokeCaption; if (vb && this.keystrokeCaptionVisible) { const bounds = this.element.getBoundingClientRect(); vb.style.left = bounds.left + 'px'; vb.style.top = (bounds.top - 64) + 'px'; vb.innerHTML = '<span>' + (Shortcuts.stringify(keystroke) || keystroke) + '</span>' + vb.innerHTML; vb.style.visibility = 'visible'; setTimeout(function () { if (vb.childNodes.length > 0) { vb.removeChild(vb.childNodes[vb.childNodes.length - 1]); } if (vb.childNodes.length === 0) { vb.style.visibility = 'hidden'; } }, 3000); } } /** * @param {string|string[]} command - A selector, or an array whose first element * is a selector, and whose subsequent elements are arguments to the selector. * Note that selectors do not include a final "_". They can be passed either * in camelCase or kebab-case. So: * ```javascript * mf.$perform('selectAll'); * mf.$perform('select-all'); * ``` * both calls are valid and invoke the same selector. * * @method MathField#$perform */ $perform(command) { if (!command) { return false; } let handled = false; let selector; let args = []; let dirty = false; if (Array.isArray(command)) { selector = command[0]; args = command.slice(1); } else { selector = command; } // Convert kebab case (like-this) to camel case (likeThis). selector = selector.replace(/-\w/g, (m) => m[1].toUpperCase()); selector += '_'; if (typeof this.mathlist[selector] === 'function') { if (/^(delete|transpose|add)/.test(selector)) { this._resetKeystrokeBuffer(); } if (/^(delete|transpose|add)/.test(selector) && this.mode !== 'command') { // Update the undo state to account for the current selection this.undoManager.pop(); this.undoManager.snapshot(this.config); } this.mathlist[selector](...args); if (/^(delete|transpose|add)/.test(selector) && this.mode !== 'command') { this.undoManager.snapshot(this.config); } if (/^(delete)/.test(selector) && this.mode === 'command') { const command = this.mathlist.extractCommandStringAroundInsertionPoint(); const suggestions = Definitions.suggest(command); if (suggestions.length === 0) { Popover.hidePopover(this); } else { Popover.showPopoverWithLatex(this, suggestions[0].match, suggestions.length > 1); } } dirty = true; handled = true; } else if (typeof this[selector] === 'function') { dirty = this[selector](...args); handled = true; } // If the command changed the selection so that it is no longer // collapsed, or if it was an editing command, reset the inline // shortcut buffer and the user style if (!this.mathlist.isCollapsed() || /^(transpose|paste|complete|((moveToNextChar|moveToPreviousChar|extend).*))_$/.test(selector)) { this._resetKeystrokeBuffer(); this.style = {}; } // Render the mathlist if (dirty) { this._requestUpdate(); } return handled; } /** * Perform a command, but: * * focus the mathfield * * provide haptic and audio feedback * This is used by the virtual keyboard when command keys (delete, arrows, etc..) * are pressed. * @param {string} command * @private */ performWithFeedback_(command) { this.$focus(); if (this.config.keypressVibration && navigator.vibrate) { navigator.vibrate(HAPTIC_FEEDBACK_DURATION); } // Convert kebab case to camel case. command = command.replace(/-\w/g, (m) => m[1].toUpperCase()); if (command === 'moveToNextPlaceholder' || command === 'moveToPreviousPlaceholder' || command === 'complete') { if (this.returnKeypressSound) { this.returnKeypressSound.load(); this.returnKeypressSound.play().catch(err => console.warn(err)); } else if (this.keypressSound) { this.keypressSound.load(); this.keypressSound.play().catch(err => console.warn(err)); } } else if (command === 'deletePreviousChar' || command === 'deleteNextChar' || command === 'deletePreviousWord' || command === 'deleteNextWord' || command === 'deleteToGroupStart' || command === 'deleteToGroupEnd' || command === 'deleteToMathFieldStart' || command === 'deleteToMathFieldEnd') { if (this.deleteKeypressSound) { this.deleteKeypressSound.load(); this.deleteKeypressSound.play().catch(err => console.warn(err)); } else if (this.keypressSound) { this.keypressSound.load(); this.keypressSound.play().catch(err => console.warn(err)); } } else if (this.keypressSound) { this.keypressSound.load(); this.keypressSound.play().catch(err => console.warn(err)); } return this.$perform(command); } /** * Convert the atoms before the anchor to 'text' mode * @param {number} count - how many atoms back to look at * @param {function} until - callback to indicate when to stop * @private */ convertLastAtomsToText_(count, until) { if (typeof count === 'function') { until = count; count = Infinity; } if (count === undefined) { count = Infinity; } let i = 0; let done = false; this.mathlist.contentWillChange(); while (!done) { const atom = this.mathlist.sibling(i); done = count === 0 || !atom || atom.mode !== 'math' || !(/mord|textord|mpunct/.test(atom.type) || (atom.type === 'mop' && /[a-zA-Z]+/.test(atom.body))) || atom.superscript || atom.subscript || (until && !until(atom)); if (!done) { atom.applyStyle({ mode: 'text' }); atom.latex = atom.body; } i -= 1; count -= 1; } this.mathlist.contentDidChange(); } /** * Convert the atoms before the anchor to 'math' mode 'mord' * @param {number} count - how many atoms back to look at * @param {function} until - callback to indicate when to stop * @private */ convertLastAtomsToMath_(count, until) { if (typeof count === 'function') { until = count; count = Infinity; } if (count === undefined) { count = Infinity; } this.mathlist.contentWillChange(); let i = 0; let done = false; while (!done) { const atom = this.mathlist.sibling(i); done = count === 0 || !atom || atom.mode !== 'text' || atom.body === ' ' || (until && !until(atom)); if (!done) { atom.applyStyle({ mode: 'math', type: 'mord' }); } i -= 1; count -= 1; } this.removeIsolatedSpace_(); this.mathlist.contentDidChange(); } /** * Going backwards from the anchor, if a text zone consisting of a single