@pie-lib/math-input-mathlive 1.1.5-beta.10 → 1.1.5-beta.12
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/__tests__/input-selection.test.js +103 -0
- package/lib/__tests__/keypad-label.test.js +13 -1
- package/lib/__tests__/latex-bridge.test.js +29 -1
- package/lib/__tests__/longdiv-wiring.test.js +215 -0
- package/lib/__tests__/longdiv.test.js +281 -0
- package/lib/__tests__/selection.test.js +195 -0
- package/lib/keypad/index.js +9 -1
- package/lib/keypad/index.js.map +1 -1
- package/lib/longdiv.js +173 -0
- package/lib/longdiv.js.map +1 -0
- package/lib/mathlive-instance.js +4 -0
- package/lib/mathlive-instance.js.map +1 -1
- package/lib/mf/input.js +16 -0
- package/lib/mf/input.js.map +1 -1
- package/lib/mf/static.js +13 -1
- package/lib/mf/static.js.map +1 -1
- package/lib/selection.js +148 -0
- package/lib/selection.js.map +1 -0
- package/package.json +1 -1
- package/src/__tests__/input-selection.test.jsx +64 -0
- package/src/__tests__/longdiv-wiring.test.jsx +137 -0
- package/src/__tests__/longdiv.test.js +259 -0
- package/src/__tests__/selection.test.js +247 -0
- package/src/keypad/index.jsx +14 -2
- package/src/longdiv.js +172 -0
- package/src/mathlive-instance.js +4 -0
- package/src/mf/input.jsx +18 -0
- package/src/mf/static.jsx +15 -1
- package/src/selection.js +141 -0
package/lib/selection.js
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
var _interopRequireDefault = require("@babel/runtime/helpers/interopRequireDefault");
|
|
4
|
+
Object.defineProperty(exports, "__esModule", {
|
|
5
|
+
value: true
|
|
6
|
+
});
|
|
7
|
+
exports.trackWholeAtomSelection = exports.expandRange = void 0;
|
|
8
|
+
var _slicedToArray2 = _interopRequireDefault(require("@babel/runtime/helpers/slicedToArray"));
|
|
9
|
+
var _debug = _interopRequireDefault(require("debug"));
|
|
10
|
+
var log = (0, _debug["default"])('pie-lib:math-input-mathlive:selection');
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Widen a selection so it covers whole atoms of a single parent.
|
|
14
|
+
*
|
|
15
|
+
* MathLive extends a selection over *flat offsets*, so Shift+Arrow walks into
|
|
16
|
+
* a fraction instead of over it: from before `2x` in `5x^2+2x+\frac{\pi}{2}-4`,
|
|
17
|
+
* five presses select `2x+` plus the `\pi` of the numerator, and copying gives
|
|
18
|
+
* `2x+\pi`. MathQuill treated a fraction (root, superscript...) as one unit,
|
|
19
|
+
* which is what authors expect.
|
|
20
|
+
*
|
|
21
|
+
* The tree is recovered from MathLive's public `getElementInfo(offset)`, where
|
|
22
|
+
* offset `i` describes the atom ending at caret position `i`:
|
|
23
|
+
*
|
|
24
|
+
* - a composite atom (fraction, root, script) owns the offset right *after*
|
|
25
|
+
* its children; the children are one level deeper;
|
|
26
|
+
* - each branch (numerator, denominator, radicand) opens with an empty-latex
|
|
27
|
+
* "start" atom.
|
|
28
|
+
*
|
|
29
|
+
* The rule: find the shallowest depth the selection touches, counting the caret
|
|
30
|
+
* it starts from. If the range crosses into a sibling branch at that depth, the
|
|
31
|
+
* real common parent is one level up. Then push both ends out of anything
|
|
32
|
+
* deeper. A script atom (`^2`) is kept with its base, since MathLive stores
|
|
33
|
+
* `x^2` as `x` followed by a separate `^2` atom.
|
|
34
|
+
*
|
|
35
|
+
* Pure, so it can be tested without a browser.
|
|
36
|
+
*
|
|
37
|
+
* @param {number} start
|
|
38
|
+
* @param {number} end
|
|
39
|
+
* @param {function(number): {depth?: number, latex?: string}} info
|
|
40
|
+
* @param {number} lastOffset
|
|
41
|
+
* @returns {[number, number]} the widened range, start <= end
|
|
42
|
+
*/
|
|
43
|
+
var expandRange = exports.expandRange = function expandRange(start, end, info, lastOffset) {
|
|
44
|
+
var at = function at(i) {
|
|
45
|
+
return info(i) || {};
|
|
46
|
+
};
|
|
47
|
+
var depth = function depth(i) {
|
|
48
|
+
return at(i).depth || 0;
|
|
49
|
+
};
|
|
50
|
+
var isBranchStart = function isBranchStart(i) {
|
|
51
|
+
return depth(i) > 0 && at(i).latex === '';
|
|
52
|
+
};
|
|
53
|
+
if (end < start) {
|
|
54
|
+
var _ref = [end, start];
|
|
55
|
+
start = _ref[0];
|
|
56
|
+
end = _ref[1];
|
|
57
|
+
}
|
|
58
|
+
if (end - start < 1) {
|
|
59
|
+
return [start, end];
|
|
60
|
+
}
|
|
61
|
+
var target = Infinity;
|
|
62
|
+
for (var i = start; i <= end; i++) {
|
|
63
|
+
target = Math.min(target, depth(i));
|
|
64
|
+
}
|
|
65
|
+
for (var _i = start + 1; _i <= end; _i++) {
|
|
66
|
+
if (depth(_i) === target && isBranchStart(_i)) {
|
|
67
|
+
target -= 1;
|
|
68
|
+
break;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
target = Math.max(target, 0);
|
|
72
|
+
while (start > 0 && depth(start) > target) {
|
|
73
|
+
start -= 1;
|
|
74
|
+
}
|
|
75
|
+
while (end < lastOffset && depth(end) > target) {
|
|
76
|
+
end += 1;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Keep a superscript / subscript with its base.
|
|
80
|
+
var first = start + 1;
|
|
81
|
+
while (first <= end && depth(first) > target) {
|
|
82
|
+
first += 1;
|
|
83
|
+
}
|
|
84
|
+
if (start > 0 && first <= end && /^[\^_]/.test(at(first).latex || '') && depth(start) === target) {
|
|
85
|
+
start -= 1;
|
|
86
|
+
}
|
|
87
|
+
return [start, end];
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Keep a mathfield's selection snapped to whole atoms (see {@link expandRange}).
|
|
92
|
+
*
|
|
93
|
+
* This fixes both what the user sees highlighted and what copy/cut put on the
|
|
94
|
+
* clipboard, since both come from the selection. The selection direction is
|
|
95
|
+
* preserved, so further Shift+Arrow presses keep extending the same end.
|
|
96
|
+
*
|
|
97
|
+
* @param {HTMLElement} mathField a MathLive `<math-field>`
|
|
98
|
+
* @returns {function|undefined} teardown, to call on unmount
|
|
99
|
+
*/
|
|
100
|
+
var trackWholeAtomSelection = exports.trackWholeAtomSelection = function trackWholeAtomSelection(mathField) {
|
|
101
|
+
if (!mathField || typeof mathField.addEventListener !== 'function') {
|
|
102
|
+
return undefined;
|
|
103
|
+
}
|
|
104
|
+
var applying = false;
|
|
105
|
+
var update = function update() {
|
|
106
|
+
if (applying) {
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
try {
|
|
110
|
+
if (mathField.selectionIsCollapsed || typeof mathField.getElementInfo !== 'function') {
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
var selection = mathField.selection;
|
|
114
|
+
var range = selection && selection.ranges && selection.ranges[0];
|
|
115
|
+
if (!range) {
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
var _range = (0, _slicedToArray2["default"])(range, 2),
|
|
119
|
+
start = _range[0],
|
|
120
|
+
end = _range[1];
|
|
121
|
+
var lo = Math.min(start, end);
|
|
122
|
+
var hi = Math.max(start, end);
|
|
123
|
+
var _expandRange = expandRange(lo, hi, function (i) {
|
|
124
|
+
return mathField.getElementInfo(i);
|
|
125
|
+
}, mathField.lastOffset),
|
|
126
|
+
_expandRange2 = (0, _slicedToArray2["default"])(_expandRange, 2),
|
|
127
|
+
nextStart = _expandRange2[0],
|
|
128
|
+
nextEnd = _expandRange2[1];
|
|
129
|
+
if (nextStart === lo && nextEnd === hi) {
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
applying = true;
|
|
133
|
+
mathField.selection = {
|
|
134
|
+
ranges: [[nextStart, nextEnd]],
|
|
135
|
+
direction: selection.direction
|
|
136
|
+
};
|
|
137
|
+
} catch (e) {
|
|
138
|
+
log('could not normalise the selection: %s', e && e.message);
|
|
139
|
+
} finally {
|
|
140
|
+
applying = false;
|
|
141
|
+
}
|
|
142
|
+
};
|
|
143
|
+
mathField.addEventListener('selection-change', update);
|
|
144
|
+
return function () {
|
|
145
|
+
return mathField.removeEventListener('selection-change', update);
|
|
146
|
+
};
|
|
147
|
+
};
|
|
148
|
+
//# sourceMappingURL=selection.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"selection.js","names":["_debug","_interopRequireDefault","require","log","debug","expandRange","exports","start","end","info","lastOffset","at","i","depth","isBranchStart","latex","_ref","target","Infinity","Math","min","max","first","test","trackWholeAtomSelection","mathField","addEventListener","undefined","applying","update","selectionIsCollapsed","getElementInfo","selection","range","ranges","_range","_slicedToArray2","lo","hi","_expandRange","_expandRange2","nextStart","nextEnd","direction","e","message","removeEventListener"],"sources":["../src/selection.js"],"sourcesContent":["import debug from 'debug';\n\nconst log = debug('pie-lib:math-input-mathlive:selection');\n\n/**\n * Widen a selection so it covers whole atoms of a single parent.\n *\n * MathLive extends a selection over *flat offsets*, so Shift+Arrow walks into\n * a fraction instead of over it: from before `2x` in `5x^2+2x+\\frac{\\pi}{2}-4`,\n * five presses select `2x+` plus the `\\pi` of the numerator, and copying gives\n * `2x+\\pi`. MathQuill treated a fraction (root, superscript...) as one unit,\n * which is what authors expect.\n *\n * The tree is recovered from MathLive's public `getElementInfo(offset)`, where\n * offset `i` describes the atom ending at caret position `i`:\n *\n * - a composite atom (fraction, root, script) owns the offset right *after*\n * its children; the children are one level deeper;\n * - each branch (numerator, denominator, radicand) opens with an empty-latex\n * \"start\" atom.\n *\n * The rule: find the shallowest depth the selection touches, counting the caret\n * it starts from. If the range crosses into a sibling branch at that depth, the\n * real common parent is one level up. Then push both ends out of anything\n * deeper. A script atom (`^2`) is kept with its base, since MathLive stores\n * `x^2` as `x` followed by a separate `^2` atom.\n *\n * Pure, so it can be tested without a browser.\n *\n * @param {number} start\n * @param {number} end\n * @param {function(number): {depth?: number, latex?: string}} info\n * @param {number} lastOffset\n * @returns {[number, number]} the widened range, start <= end\n */\nexport const expandRange = (start, end, info, lastOffset) => {\n const at = (i) => info(i) || {};\n const depth = (i) => at(i).depth || 0;\n const isBranchStart = (i) => depth(i) > 0 && at(i).latex === '';\n\n if (end < start) {\n [start, end] = [end, start];\n }\n\n if (end - start < 1) {\n return [start, end];\n }\n\n let target = Infinity;\n\n for (let i = start; i <= end; i++) {\n target = Math.min(target, depth(i));\n }\n\n for (let i = start + 1; i <= end; i++) {\n if (depth(i) === target && isBranchStart(i)) {\n target -= 1;\n break;\n }\n }\n\n target = Math.max(target, 0);\n\n while (start > 0 && depth(start) > target) {\n start -= 1;\n }\n\n while (end < lastOffset && depth(end) > target) {\n end += 1;\n }\n\n // Keep a superscript / subscript with its base.\n let first = start + 1;\n\n while (first <= end && depth(first) > target) {\n first += 1;\n }\n\n if (start > 0 && first <= end && /^[\\^_]/.test(at(first).latex || '') && depth(start) === target) {\n start -= 1;\n }\n\n return [start, end];\n};\n\n/**\n * Keep a mathfield's selection snapped to whole atoms (see {@link expandRange}).\n *\n * This fixes both what the user sees highlighted and what copy/cut put on the\n * clipboard, since both come from the selection. The selection direction is\n * preserved, so further Shift+Arrow presses keep extending the same end.\n *\n * @param {HTMLElement} mathField a MathLive `<math-field>`\n * @returns {function|undefined} teardown, to call on unmount\n */\nexport const trackWholeAtomSelection = (mathField) => {\n if (!mathField || typeof mathField.addEventListener !== 'function') {\n return undefined;\n }\n\n let applying = false;\n\n const update = () => {\n if (applying) {\n return;\n }\n\n try {\n if (mathField.selectionIsCollapsed || typeof mathField.getElementInfo !== 'function') {\n return;\n }\n\n const selection = mathField.selection;\n const range = selection && selection.ranges && selection.ranges[0];\n\n if (!range) {\n return;\n }\n\n const [start, end] = range;\n const lo = Math.min(start, end);\n const hi = Math.max(start, end);\n const [nextStart, nextEnd] = expandRange(lo, hi, (i) => mathField.getElementInfo(i), mathField.lastOffset);\n\n if (nextStart === lo && nextEnd === hi) {\n return;\n }\n\n applying = true;\n mathField.selection = { ranges: [[nextStart, nextEnd]], direction: selection.direction };\n } catch (e) {\n log('could not normalise the selection: %s', e && e.message);\n } finally {\n applying = false;\n }\n };\n\n mathField.addEventListener('selection-change', update);\n\n return () => mathField.removeEventListener('selection-change', update);\n};\n"],"mappings":";;;;;;;;AAAA,IAAAA,MAAA,GAAAC,sBAAA,CAAAC,OAAA;AAEA,IAAMC,GAAG,GAAG,IAAAC,iBAAK,EAAC,uCAAuC,CAAC;;AAE1D;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACO,IAAMC,WAAW,GAAAC,OAAA,CAAAD,WAAA,GAAG,SAAdA,WAAWA,CAAIE,KAAK,EAAEC,GAAG,EAAEC,IAAI,EAAEC,UAAU,EAAK;EAC3D,IAAMC,EAAE,GAAG,SAALA,EAAEA,CAAIC,CAAC;IAAA,OAAKH,IAAI,CAACG,CAAC,CAAC,IAAI,CAAC,CAAC;EAAA;EAC/B,IAAMC,KAAK,GAAG,SAARA,KAAKA,CAAID,CAAC;IAAA,OAAKD,EAAE,CAACC,CAAC,CAAC,CAACC,KAAK,IAAI,CAAC;EAAA;EACrC,IAAMC,aAAa,GAAG,SAAhBA,aAAaA,CAAIF,CAAC;IAAA,OAAKC,KAAK,CAACD,CAAC,CAAC,GAAG,CAAC,IAAID,EAAE,CAACC,CAAC,CAAC,CAACG,KAAK,KAAK,EAAE;EAAA;EAE/D,IAAIP,GAAG,GAAGD,KAAK,EAAE;IAAA,IAAAS,IAAA,GACA,CAACR,GAAG,EAAED,KAAK,CAAC;IAA1BA,KAAK,GAAAS,IAAA;IAAER,GAAG,GAAAQ,IAAA;EACb;EAEA,IAAIR,GAAG,GAAGD,KAAK,GAAG,CAAC,EAAE;IACnB,OAAO,CAACA,KAAK,EAAEC,GAAG,CAAC;EACrB;EAEA,IAAIS,MAAM,GAAGC,QAAQ;EAErB,KAAK,IAAIN,CAAC,GAAGL,KAAK,EAAEK,CAAC,IAAIJ,GAAG,EAAEI,CAAC,EAAE,EAAE;IACjCK,MAAM,GAAGE,IAAI,CAACC,GAAG,CAACH,MAAM,EAAEJ,KAAK,CAACD,CAAC,CAAC,CAAC;EACrC;EAEA,KAAK,IAAIA,EAAC,GAAGL,KAAK,GAAG,CAAC,EAAEK,EAAC,IAAIJ,GAAG,EAAEI,EAAC,EAAE,EAAE;IACrC,IAAIC,KAAK,CAACD,EAAC,CAAC,KAAKK,MAAM,IAAIH,aAAa,CAACF,EAAC,CAAC,EAAE;MAC3CK,MAAM,IAAI,CAAC;MACX;IACF;EACF;EAEAA,MAAM,GAAGE,IAAI,CAACE,GAAG,CAACJ,MAAM,EAAE,CAAC,CAAC;EAE5B,OAAOV,KAAK,GAAG,CAAC,IAAIM,KAAK,CAACN,KAAK,CAAC,GAAGU,MAAM,EAAE;IACzCV,KAAK,IAAI,CAAC;EACZ;EAEA,OAAOC,GAAG,GAAGE,UAAU,IAAIG,KAAK,CAACL,GAAG,CAAC,GAAGS,MAAM,EAAE;IAC9CT,GAAG,IAAI,CAAC;EACV;;EAEA;EACA,IAAIc,KAAK,GAAGf,KAAK,GAAG,CAAC;EAErB,OAAOe,KAAK,IAAId,GAAG,IAAIK,KAAK,CAACS,KAAK,CAAC,GAAGL,MAAM,EAAE;IAC5CK,KAAK,IAAI,CAAC;EACZ;EAEA,IAAIf,KAAK,GAAG,CAAC,IAAIe,KAAK,IAAId,GAAG,IAAI,QAAQ,CAACe,IAAI,CAACZ,EAAE,CAACW,KAAK,CAAC,CAACP,KAAK,IAAI,EAAE,CAAC,IAAIF,KAAK,CAACN,KAAK,CAAC,KAAKU,MAAM,EAAE;IAChGV,KAAK,IAAI,CAAC;EACZ;EAEA,OAAO,CAACA,KAAK,EAAEC,GAAG,CAAC;AACrB,CAAC;;AAED;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACO,IAAMgB,uBAAuB,GAAAlB,OAAA,CAAAkB,uBAAA,GAAG,SAA1BA,uBAAuBA,CAAIC,SAAS,EAAK;EACpD,IAAI,CAACA,SAAS,IAAI,OAAOA,SAAS,CAACC,gBAAgB,KAAK,UAAU,EAAE;IAClE,OAAOC,SAAS;EAClB;EAEA,IAAIC,QAAQ,GAAG,KAAK;EAEpB,IAAMC,MAAM,GAAG,SAATA,MAAMA,CAAA,EAAS;IACnB,IAAID,QAAQ,EAAE;MACZ;IACF;IAEA,IAAI;MACF,IAAIH,SAAS,CAACK,oBAAoB,IAAI,OAAOL,SAAS,CAACM,cAAc,KAAK,UAAU,EAAE;QACpF;MACF;MAEA,IAAMC,SAAS,GAAGP,SAAS,CAACO,SAAS;MACrC,IAAMC,KAAK,GAAGD,SAAS,IAAIA,SAAS,CAACE,MAAM,IAAIF,SAAS,CAACE,MAAM,CAAC,CAAC,CAAC;MAElE,IAAI,CAACD,KAAK,EAAE;QACV;MACF;MAEA,IAAAE,MAAA,OAAAC,eAAA,aAAqBH,KAAK;QAAnB1B,KAAK,GAAA4B,MAAA;QAAE3B,GAAG,GAAA2B,MAAA;MACjB,IAAME,EAAE,GAAGlB,IAAI,CAACC,GAAG,CAACb,KAAK,EAAEC,GAAG,CAAC;MAC/B,IAAM8B,EAAE,GAAGnB,IAAI,CAACE,GAAG,CAACd,KAAK,EAAEC,GAAG,CAAC;MAC/B,IAAA+B,YAAA,GAA6BlC,WAAW,CAACgC,EAAE,EAAEC,EAAE,EAAE,UAAC1B,CAAC;UAAA,OAAKa,SAAS,CAACM,cAAc,CAACnB,CAAC,CAAC;QAAA,GAAEa,SAAS,CAACf,UAAU,CAAC;QAAA8B,aAAA,OAAAJ,eAAA,aAAAG,YAAA;QAAnGE,SAAS,GAAAD,aAAA;QAAEE,OAAO,GAAAF,aAAA;MAEzB,IAAIC,SAAS,KAAKJ,EAAE,IAAIK,OAAO,KAAKJ,EAAE,EAAE;QACtC;MACF;MAEAV,QAAQ,GAAG,IAAI;MACfH,SAAS,CAACO,SAAS,GAAG;QAAEE,MAAM,EAAE,CAAC,CAACO,SAAS,EAAEC,OAAO,CAAC,CAAC;QAAEC,SAAS,EAAEX,SAAS,CAACW;MAAU,CAAC;IAC1F,CAAC,CAAC,OAAOC,CAAC,EAAE;MACVzC,GAAG,CAAC,uCAAuC,EAAEyC,CAAC,IAAIA,CAAC,CAACC,OAAO,CAAC;IAC9D,CAAC,SAAS;MACRjB,QAAQ,GAAG,KAAK;IAClB;EACF,CAAC;EAEDH,SAAS,CAACC,gBAAgB,CAAC,kBAAkB,EAAEG,MAAM,CAAC;EAEtD,OAAO;IAAA,OAAMJ,SAAS,CAACqB,mBAAmB,CAAC,kBAAkB,EAAEjB,MAAM,CAAC;EAAA;AACxE,CAAC","ignoreList":[]}
|
package/package.json
CHANGED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The editable Input keeps its selection snapped to whole atoms (see
|
|
3
|
+
* selection.js). This checks the wiring: attached on mount, removed on unmount.
|
|
4
|
+
*/
|
|
5
|
+
jest.mock('../mathlive-instance', () => {
|
|
6
|
+
class FakeMathfieldElement {
|
|
7
|
+
constructor() {
|
|
8
|
+
const el = global.document.createElement('math-field');
|
|
9
|
+
|
|
10
|
+
el.value = '';
|
|
11
|
+
el.getValue = () => el.value;
|
|
12
|
+
|
|
13
|
+
return el;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
return {
|
|
18
|
+
loadMathLive: () => Promise.resolve({ MathfieldElement: FakeMathfieldElement }),
|
|
19
|
+
getMacros: () => ({}),
|
|
20
|
+
MATH_MODE_SPACE: '\\ ',
|
|
21
|
+
applyShadowStyles: () => {},
|
|
22
|
+
trackPlaceholderCaret: () => () => {},
|
|
23
|
+
latexToMarkup: (l) => `<span>${l}</span>`,
|
|
24
|
+
};
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
const mockUntrack = jest.fn();
|
|
28
|
+
|
|
29
|
+
jest.mock('../selection', () => ({
|
|
30
|
+
trackWholeAtomSelection: jest.fn(() => mockUntrack),
|
|
31
|
+
}));
|
|
32
|
+
|
|
33
|
+
// eslint-disable-next-line import/first
|
|
34
|
+
import Input from '../mf/input';
|
|
35
|
+
// eslint-disable-next-line import/first
|
|
36
|
+
import { trackWholeAtomSelection } from '../selection';
|
|
37
|
+
|
|
38
|
+
describe('Input: whole-atom selection', () => {
|
|
39
|
+
beforeEach(() => {
|
|
40
|
+
trackWholeAtomSelection.mockClear();
|
|
41
|
+
mockUntrack.mockClear();
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('attaches the tracker to the mathfield on mount', async () => {
|
|
45
|
+
const input = new Input({});
|
|
46
|
+
|
|
47
|
+
input.holderRef = { current: document.createElement('span') };
|
|
48
|
+
await input.componentDidMount();
|
|
49
|
+
|
|
50
|
+
expect(trackWholeAtomSelection).toHaveBeenCalledTimes(1);
|
|
51
|
+
expect(trackWholeAtomSelection).toHaveBeenCalledWith(input.mathField);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('removes it on unmount', async () => {
|
|
55
|
+
const input = new Input({});
|
|
56
|
+
|
|
57
|
+
input.holderRef = { current: document.createElement('span') };
|
|
58
|
+
await input.componentDidMount();
|
|
59
|
+
input.componentWillUnmount();
|
|
60
|
+
|
|
61
|
+
expect(mockUntrack).toHaveBeenCalledTimes(1);
|
|
62
|
+
expect(input.untrackSelection).toBeUndefined();
|
|
63
|
+
});
|
|
64
|
+
});
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Every place that draws MathLive output must apply the long-division
|
|
3
|
+
* correction (see longdiv.js): live fields after every render, static markup
|
|
4
|
+
* after it is written into the DOM.
|
|
5
|
+
*/
|
|
6
|
+
jest.mock('../mathlive-instance', () => {
|
|
7
|
+
class FakeMathfieldElement {
|
|
8
|
+
constructor() {
|
|
9
|
+
const el = global.document.createElement('math-field');
|
|
10
|
+
|
|
11
|
+
el.value = '';
|
|
12
|
+
el.getValue = () => el.value;
|
|
13
|
+
|
|
14
|
+
return el;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
return {
|
|
19
|
+
loadMathLive: () => Promise.resolve({ MathfieldElement: FakeMathfieldElement }),
|
|
20
|
+
getMacros: () => ({}),
|
|
21
|
+
MATH_MODE_SPACE: '\\ ',
|
|
22
|
+
applyShadowStyles: () => {},
|
|
23
|
+
trackPlaceholderCaret: () => () => {},
|
|
24
|
+
latexToMarkup: (l) => `<span>${l}</span>`,
|
|
25
|
+
};
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
const mockUntrack = jest.fn();
|
|
29
|
+
|
|
30
|
+
jest.mock('../longdiv', () => ({
|
|
31
|
+
trackLongDivision: jest.fn(() => mockUntrack),
|
|
32
|
+
fixLongDivisionWhenReady: jest.fn(),
|
|
33
|
+
fixLongDivision: jest.fn(),
|
|
34
|
+
}));
|
|
35
|
+
|
|
36
|
+
/* eslint-disable import/first */
|
|
37
|
+
import Input from '../mf/input';
|
|
38
|
+
import Static from '../mf/static';
|
|
39
|
+
import { LatexLabel } from '../keypad';
|
|
40
|
+
import { trackLongDivision, fixLongDivisionWhenReady } from '../longdiv';
|
|
41
|
+
/* eslint-enable import/first */
|
|
42
|
+
|
|
43
|
+
beforeEach(() => {
|
|
44
|
+
trackLongDivision.mockClear();
|
|
45
|
+
fixLongDivisionWhenReady.mockClear();
|
|
46
|
+
mockUntrack.mockClear();
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
describe('Input (the editor)', () => {
|
|
50
|
+
it('tracks the mathfield on mount and stops on unmount', async () => {
|
|
51
|
+
const input = new Input({});
|
|
52
|
+
|
|
53
|
+
input.holderRef = { current: document.createElement('span') };
|
|
54
|
+
await input.componentDidMount();
|
|
55
|
+
|
|
56
|
+
expect(trackLongDivision).toHaveBeenCalledWith(input.mathField);
|
|
57
|
+
|
|
58
|
+
input.componentWillUnmount();
|
|
59
|
+
|
|
60
|
+
expect(mockUntrack).toHaveBeenCalledTimes(1);
|
|
61
|
+
expect(input.untrackLongDivision).toBeUndefined();
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
describe('Static', () => {
|
|
66
|
+
it('tracks the answer-block field on mount and stops on unmount', async () => {
|
|
67
|
+
const s = new Static({ latex: 'x=\\MathQuillMathField[r1]{}' });
|
|
68
|
+
|
|
69
|
+
s.holderRef = { current: document.createElement('span') };
|
|
70
|
+
s.createLiveRegion = () => {};
|
|
71
|
+
await s.componentDidMount();
|
|
72
|
+
|
|
73
|
+
expect(trackLongDivision).toHaveBeenCalledWith(s.mathField);
|
|
74
|
+
|
|
75
|
+
s.componentWillUnmount();
|
|
76
|
+
|
|
77
|
+
expect(mockUntrack).toHaveBeenCalledTimes(1);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
// Display mode: the markup reaches the DOM through state, so it is corrected
|
|
81
|
+
// once React has written it.
|
|
82
|
+
it('corrects static markup after it is rendered', () => {
|
|
83
|
+
const s = new Static({ latex: '5\\longdiv{144}' });
|
|
84
|
+
const holder = document.createElement('span');
|
|
85
|
+
|
|
86
|
+
s.holderRef = { current: holder };
|
|
87
|
+
s.state = { markup: '<span>new</span>' };
|
|
88
|
+
s.componentDidUpdate({ latex: '5\\longdiv{144}' }, { markup: '' });
|
|
89
|
+
|
|
90
|
+
expect(fixLongDivisionWhenReady).toHaveBeenCalledWith(holder);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('does not redo it when the markup did not change', () => {
|
|
94
|
+
const s = new Static({ latex: 'x' });
|
|
95
|
+
|
|
96
|
+
s.holderRef = { current: document.createElement('span') };
|
|
97
|
+
s.state = { markup: '<span>same</span>' };
|
|
98
|
+
s.componentDidUpdate({ latex: 'x' }, { markup: '<span>same</span>' });
|
|
99
|
+
|
|
100
|
+
expect(fixLongDivisionWhenReady).not.toHaveBeenCalled();
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('leaves a live field to the tracker', () => {
|
|
104
|
+
const s = new Static({ latex: 'x' });
|
|
105
|
+
|
|
106
|
+
s.holderRef = { current: document.createElement('span') };
|
|
107
|
+
s.mathField = document.createElement('math-field');
|
|
108
|
+
s.state = { markup: '<span>new</span>' };
|
|
109
|
+
s.componentDidUpdate({ latex: 'x' }, { markup: '' });
|
|
110
|
+
|
|
111
|
+
expect(fixLongDivisionWhenReady).not.toHaveBeenCalled();
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
describe('keypad label', () => {
|
|
116
|
+
it('corrects the label once mounted', async () => {
|
|
117
|
+
const label = new LatexLabel({ latex: '\\longdiv{}' });
|
|
118
|
+
const el = document.createElement('span');
|
|
119
|
+
|
|
120
|
+
label.labelRef = { current: el };
|
|
121
|
+
await label.componentDidMount();
|
|
122
|
+
|
|
123
|
+
expect(fixLongDivisionWhenReady).toHaveBeenCalledWith(el);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it('corrects it again when the label changes', () => {
|
|
127
|
+
const label = new LatexLabel({ latex: '\\longdiv{}' });
|
|
128
|
+
const el = document.createElement('span');
|
|
129
|
+
|
|
130
|
+
label.labelRef = { current: el };
|
|
131
|
+
label.setState = () => {};
|
|
132
|
+
label.state = { markup: '<span>other</span>' };
|
|
133
|
+
label.componentDidUpdate({ latex: '\\longdiv{}' }, { markup: '<span>first</span>' });
|
|
134
|
+
|
|
135
|
+
expect(fixLongDivisionWhenReady).toHaveBeenCalledWith(el);
|
|
136
|
+
});
|
|
137
|
+
});
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
import { fixLongDivision, fixLongDivisionWhenReady, trackLongDivision } from '../longdiv';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Markup captured verbatim from MathLive 0.110.0 `convertLatexToMarkup` in
|
|
5
|
+
* Chrome. In `12\enclose{longdiv}{144}` MathLive drops the dividend 0.45em
|
|
6
|
+
* (`top:0.45em`), sizes the box 2.1em wide while the viewBox is 2.54 wide, and
|
|
7
|
+
* ends the bar at 1.8 although the dividend runs to 0.3 + 0.45 + 1.5 = 2.25.
|
|
8
|
+
*/
|
|
9
|
+
const LONGDIV =
|
|
10
|
+
'<span class="ML__latex"><span class="ML__strut" style="height:0.8em"></span><span class="ML__strut--bottom" style="height:1.25em;vertical-align:-0.44em"></span><span class="ML__base"><span class="ML__cmr">12</span><span style="position:relative;height:1.24em;display:inline-block;margin-left:0.3em;margin-right:0.3em;width:1.95em"><span class="ML__notation" style="box-sizing:border-box;left:calc(-1 * 1px - 0.3em);height:calc( 1.24444em + 1px);top:0em;width:calc(2.1em + 1px);background-color:transparent"><span style="display: inline-block;height:1.24em;vertical-align:0.44em;"></span><svg style="position:absolute;overflow:visible;height:1.24em;top:0;left:0;width:100%;z-index:2;" stroke-width="0.06em" stroke="currentColor" stroke-linecap="round" viewBox="0 0 2.54 1.24"><line x1="0.3" y1="0.3" x2="1.8" y2="0.3" vector-effect="non-scaling-stroke"></line><path d="M 0.3 0.3 a0.3 0.46997, 0, 1, 1, 0 0.93994 " stroke-width="0.004em" stroke="currentColor" fill="none"/></svg></span><span style="position:relative;display:inline-block;height:0.65em;width:1.5em;margin-left:0.45em;top:0.45em"><span class="ML__cmr">144</span></span></span></span></span>';
|
|
11
|
+
|
|
12
|
+
// \enclose{box}{x}: another notation, which must be left alone
|
|
13
|
+
const BOX =
|
|
14
|
+
'<span class="ML__latex"><span class="ML__strut" style="height:1.04em"></span><span class="ML__base"><span style="position:relative;height:1.03em;display:inline-block;margin-left:0.3em;margin-right:0.3em;width:0.58em"><span class="ML__notation" style="box-sizing:border-box;left:calc(-1 * 1px - 0.3em);height:calc( 1.03056em + 1px);top:0em;width:calc(1.17153em + 1px);background-color:transparent;border:1px solid red"></span><span style="position:relative;display:inline-block;height:0.44em;width:0.58em"><span class="ML__mathit">x</span></span></span></span></span>';
|
|
15
|
+
|
|
16
|
+
const mount = (html) => {
|
|
17
|
+
const el = document.createElement('div');
|
|
18
|
+
|
|
19
|
+
el.innerHTML = html;
|
|
20
|
+
document.body.appendChild(el);
|
|
21
|
+
|
|
22
|
+
return el;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const parts = (el) => {
|
|
26
|
+
const notation = el.querySelector('.ML__notation');
|
|
27
|
+
|
|
28
|
+
return {
|
|
29
|
+
notation,
|
|
30
|
+
box: notation.parentElement,
|
|
31
|
+
dividend: notation.nextElementSibling,
|
|
32
|
+
line: notation.querySelector('line'),
|
|
33
|
+
};
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
afterEach(() => {
|
|
37
|
+
document.body.innerHTML = '';
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
describe('fixLongDivision', () => {
|
|
41
|
+
it('puts the dividend back on the baseline', () => {
|
|
42
|
+
const el = mount(LONGDIV);
|
|
43
|
+
|
|
44
|
+
fixLongDivision(el);
|
|
45
|
+
|
|
46
|
+
const { dividend, box } = parts(el);
|
|
47
|
+
|
|
48
|
+
expect(dividend.style.top).toEqual('0px');
|
|
49
|
+
// the drawing keeps its place relative to the dividend, and the line grows
|
|
50
|
+
// so the field does not clip the bar
|
|
51
|
+
expect(box.style.paddingTop).toEqual('0.45em');
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('sizes the box to the viewBox, so the drawing is not scaled down', () => {
|
|
55
|
+
const el = mount(LONGDIV);
|
|
56
|
+
|
|
57
|
+
fixLongDivision(el);
|
|
58
|
+
|
|
59
|
+
expect(parts(el).notation.style.width).toEqual('calc(2.54em + 1px)');
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('runs the bar to the end of the dividend', () => {
|
|
63
|
+
const el = mount(LONGDIV);
|
|
64
|
+
|
|
65
|
+
fixLongDivision(el);
|
|
66
|
+
|
|
67
|
+
// no layout under jsdom, so from MathLive's numbers: start + gap + width
|
|
68
|
+
expect(parseFloat(parts(el).line.getAttribute('x2'))).toBeCloseTo(0.3 + 0.45 + 1.5, 6);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
// MathLive's width leaves out the spacing around `,` and `+`, so when the
|
|
72
|
+
// markup is laid out the end is measured instead.
|
|
73
|
+
it('measures the bar end from the rendered dividend when laid out', () => {
|
|
74
|
+
const el = mount(LONGDIV);
|
|
75
|
+
const { notation, dividend } = parts(el);
|
|
76
|
+
const svg = notation.querySelector('svg');
|
|
77
|
+
const glyph = dividend.querySelector('.ML__cmr');
|
|
78
|
+
|
|
79
|
+
// the svg is 254px wide for a 2.54-unit viewBox: 100px per unit
|
|
80
|
+
svg.getBoundingClientRect = () => ({ left: 100, width: 254, right: 354 });
|
|
81
|
+
glyph.getBoundingClientRect = () => ({ left: 145, right: 340, width: 195 });
|
|
82
|
+
|
|
83
|
+
fixLongDivision(el);
|
|
84
|
+
|
|
85
|
+
expect(parseFloat(parts(el).line.getAttribute('x2'))).toBeCloseTo(2.4, 6);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('ignores the caret when measuring', () => {
|
|
89
|
+
const el = mount(LONGDIV);
|
|
90
|
+
const { notation, dividend } = parts(el);
|
|
91
|
+
const caret = document.createElement('span');
|
|
92
|
+
|
|
93
|
+
caret.className = 'ML__caret';
|
|
94
|
+
dividend.appendChild(caret);
|
|
95
|
+
notation.querySelector('svg').getBoundingClientRect = () => ({ left: 100, width: 254, right: 354 });
|
|
96
|
+
dividend.querySelector('.ML__cmr').getBoundingClientRect = () => ({ left: 145, right: 340, width: 195 });
|
|
97
|
+
caret.getBoundingClientRect = () => ({ left: 400, right: 402, width: 2 });
|
|
98
|
+
|
|
99
|
+
fixLongDivision(el);
|
|
100
|
+
|
|
101
|
+
expect(parseFloat(parts(el).line.getAttribute('x2'))).toBeCloseTo(2.4, 6);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('can run again without drifting', () => {
|
|
105
|
+
const el = mount(LONGDIV);
|
|
106
|
+
|
|
107
|
+
fixLongDivision(el);
|
|
108
|
+
const first = { ...parts(el), x2: parts(el).line.getAttribute('x2') };
|
|
109
|
+
|
|
110
|
+
fixLongDivision(el);
|
|
111
|
+
const second = parts(el);
|
|
112
|
+
|
|
113
|
+
expect(second.line.getAttribute('x2')).toEqual(first.x2);
|
|
114
|
+
expect(second.box.style.paddingTop).toEqual('0.45em');
|
|
115
|
+
expect(second.notation.style.width).toEqual('calc(2.54em + 1px)');
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('leaves other enclose notations alone', () => {
|
|
119
|
+
const el = mount(BOX);
|
|
120
|
+
const before = el.innerHTML;
|
|
121
|
+
|
|
122
|
+
expect(fixLongDivision(el)).toBe(0);
|
|
123
|
+
expect(el.innerHTML).toEqual(before);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it('counts what it corrected', () => {
|
|
127
|
+
expect(fixLongDivision(mount(LONGDIV + LONGDIV))).toBe(2);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it('is safe on nothing', () => {
|
|
131
|
+
expect(fixLongDivision(null)).toBe(0);
|
|
132
|
+
expect(fixLongDivision(mount('<span>plain</span>'))).toBe(0);
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
describe('fixLongDivisionWhenReady', () => {
|
|
137
|
+
it('corrects now, and again once the fonts have loaded', async () => {
|
|
138
|
+
let fontsLoaded;
|
|
139
|
+
const original = document.fonts;
|
|
140
|
+
|
|
141
|
+
Object.defineProperty(document, 'fonts', {
|
|
142
|
+
configurable: true,
|
|
143
|
+
value: { ready: new Promise((resolve) => (fontsLoaded = resolve)) },
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
try {
|
|
147
|
+
const el = mount(LONGDIV);
|
|
148
|
+
|
|
149
|
+
fixLongDivisionWhenReady(el);
|
|
150
|
+
expect(parts(el).dividend.style.top).toEqual('0px');
|
|
151
|
+
|
|
152
|
+
// a re-render before the fonts arrive...
|
|
153
|
+
el.innerHTML = LONGDIV;
|
|
154
|
+
fontsLoaded();
|
|
155
|
+
await Promise.resolve();
|
|
156
|
+
await Promise.resolve();
|
|
157
|
+
|
|
158
|
+
// ...is corrected once they do
|
|
159
|
+
expect(parts(el).dividend.style.top).toEqual('0px');
|
|
160
|
+
} finally {
|
|
161
|
+
Object.defineProperty(document, 'fonts', { configurable: true, value: original });
|
|
162
|
+
}
|
|
163
|
+
});
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
describe('trackLongDivision', () => {
|
|
167
|
+
const field = () => {
|
|
168
|
+
const host = document.createElement('div');
|
|
169
|
+
|
|
170
|
+
host.attachShadow({ mode: 'open' });
|
|
171
|
+
host.shadowRoot.innerHTML = LONGDIV;
|
|
172
|
+
document.body.appendChild(host);
|
|
173
|
+
|
|
174
|
+
return host;
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
it('corrects the field straight away', () => {
|
|
178
|
+
const host = field();
|
|
179
|
+
|
|
180
|
+
trackLongDivision(host);
|
|
181
|
+
|
|
182
|
+
expect(parts(host.shadowRoot).dividend.style.top).toEqual('0px');
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
// jest.setup.js replaces MutationObserver with a no-op for every test, so this
|
|
186
|
+
// installs one that records what it was asked to observe and can be fired.
|
|
187
|
+
describe('re-rendering', () => {
|
|
188
|
+
let observers;
|
|
189
|
+
const RealObserver = global.MutationObserver;
|
|
190
|
+
|
|
191
|
+
beforeEach(() => {
|
|
192
|
+
observers = [];
|
|
193
|
+
global.MutationObserver = class {
|
|
194
|
+
constructor(callback) {
|
|
195
|
+
this.callback = callback;
|
|
196
|
+
this.disconnected = false;
|
|
197
|
+
observers.push(this);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
observe(target, options) {
|
|
201
|
+
this.target = target;
|
|
202
|
+
this.options = options;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
disconnect() {
|
|
206
|
+
this.disconnected = true;
|
|
207
|
+
}
|
|
208
|
+
};
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
afterEach(() => {
|
|
212
|
+
global.MutationObserver = RealObserver;
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
it('watches the whole shadow tree for MathLive rebuilding it', () => {
|
|
216
|
+
const host = field();
|
|
217
|
+
|
|
218
|
+
trackLongDivision(host);
|
|
219
|
+
|
|
220
|
+
expect(observers).toHaveLength(1);
|
|
221
|
+
expect(observers[0].target).toBe(host.shadowRoot);
|
|
222
|
+
expect(observers[0].options).toEqual({ childList: true, subtree: true });
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
// MathLive rebuilds its shadow DOM on every keystroke, resize and font load.
|
|
226
|
+
it('re-corrects after MathLive re-renders', () => {
|
|
227
|
+
const host = field();
|
|
228
|
+
|
|
229
|
+
trackLongDivision(host);
|
|
230
|
+
host.shadowRoot.innerHTML = LONGDIV;
|
|
231
|
+
expect(parts(host.shadowRoot).dividend.style.top).toEqual('0.45em');
|
|
232
|
+
|
|
233
|
+
observers[0].callback([]);
|
|
234
|
+
|
|
235
|
+
expect(parts(host.shadowRoot).dividend.style.top).toEqual('0px');
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
// The corrections change attributes only, so observing childList alone
|
|
239
|
+
// cannot loop.
|
|
240
|
+
it('does not observe attributes', () => {
|
|
241
|
+
trackLongDivision(field());
|
|
242
|
+
|
|
243
|
+
expect(observers[0].options.attributes).toBeUndefined();
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
it('stops after teardown', () => {
|
|
247
|
+
const untrack = trackLongDivision(field());
|
|
248
|
+
|
|
249
|
+
untrack();
|
|
250
|
+
|
|
251
|
+
expect(observers[0].disconnected).toBe(true);
|
|
252
|
+
});
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
it('is a no-op without a shadow root', () => {
|
|
256
|
+
expect(trackLongDivision(undefined)).toBeUndefined();
|
|
257
|
+
expect(trackLongDivision(document.createElement('div'))).toBeUndefined();
|
|
258
|
+
});
|
|
259
|
+
});
|