mathlive
Version:
Render and edit beautifully typeset math
1,159 lines (1,136 loc) • 145 kB
JavaScript
/**
*
* 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