@pie-lib/math-input-mathlive 1.1.4 → 1.1.5-beta.10
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/README.md +14 -5
- package/lib/__tests__/composites.test.js +255 -0
- package/lib/__tests__/drop-in-compat.test.js +133 -0
- package/lib/__tests__/embedded-fonts.test.js +121 -0
- package/lib/__tests__/index.test.js +118 -0
- package/lib/__tests__/input-imperative.test.js +155 -0
- package/lib/__tests__/keypad-label.test.js +231 -0
- package/lib/__tests__/keypad-render.test.js +206 -0
- package/lib/__tests__/latex-bridge.test.js +386 -0
- package/lib/__tests__/mathlive-instance.test.js +419 -0
- package/lib/__tests__/mount-race.test.js +218 -0
- package/lib/__tests__/static-prompts.test.js +405 -0
- package/lib/__tests__/tmp-markup.test.js +26 -0
- package/lib/keypad/index.js +50 -28
- package/lib/keypad/index.js.map +1 -1
- package/lib/keys/geometry.js +7 -4
- package/lib/keys/geometry.js.map +1 -1
- package/lib/keys/misc.js +9 -2
- package/lib/keys/misc.js.map +1 -1
- package/lib/latex-bridge.js +207 -7
- package/lib/latex-bridge.js.map +1 -1
- package/lib/mathlive-instance.js +176 -12
- package/lib/mathlive-instance.js.map +1 -1
- package/lib/mf/common-styles.js +89 -10
- package/lib/mf/common-styles.js.map +1 -1
- package/lib/mf/index.js +12 -0
- package/lib/mf/index.js.map +1 -1
- package/lib/mf/input.js +34 -5
- package/lib/mf/input.js.map +1 -1
- package/lib/mf/static.js +107 -7
- package/lib/mf/static.js.map +1 -1
- package/package.json +3 -4
- package/src/__tests__/composites.test.jsx +220 -0
- package/src/__tests__/keypad-label.test.jsx +229 -2
- package/src/__tests__/keypad-render.test.jsx +123 -0
- package/src/__tests__/latex-bridge.test.js +159 -0
- package/src/__tests__/mathlive-instance.test.js +237 -0
- package/src/__tests__/mount-race.test.jsx +142 -0
- package/src/__tests__/static-prompts.test.jsx +364 -0
- package/src/keypad/index.jsx +33 -17
- package/src/keys/geometry.js +7 -2
- package/src/keys/misc.js +8 -2
- package/src/latex-bridge.js +223 -7
- package/src/mathlive-instance.js +196 -11
- package/src/mf/common-styles.js +97 -8
- package/src/mf/index.js +4 -0
- package/src/mf/input.jsx +42 -5
- package/src/mf/static.jsx +124 -6
- package/CHANGELOG.md +0 -30
- package/LICENSE.md +0 -5
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { render } from '@testing-library/react';
|
|
3
|
+
|
|
4
|
+
import KeyPad, { LatexLabel } from '../keypad';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Keypad behaviour that is new in this package: labels are static markup from
|
|
8
|
+
* convertLatexToMarkup (the MathQuill version created a live StaticMath per
|
|
9
|
+
* button), with a raw-latex fallback when the engine is unavailable.
|
|
10
|
+
*/
|
|
11
|
+
describe('LatexLabel', () => {
|
|
12
|
+
it('falls back to raw latex when MathLive is unavailable', () => {
|
|
13
|
+
// jsdom cannot load MathLive, so latexToMarkup returns '' - the button must
|
|
14
|
+
// still show something rather than render blank.
|
|
15
|
+
const { container } = render(<LatexLabel latex={'\\pi'} />);
|
|
16
|
+
|
|
17
|
+
expect(container.textContent).toContain('\\pi');
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('re-renders when the latex prop changes', () => {
|
|
21
|
+
const { container, rerender } = render(<LatexLabel latex={'\\pi'} />);
|
|
22
|
+
|
|
23
|
+
rerender(<LatexLabel latex={'\\theta'} />);
|
|
24
|
+
|
|
25
|
+
expect(container.textContent).toContain('\\theta');
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('does not warn more than once about a missing engine', async () => {
|
|
29
|
+
LatexLabel.warned = false;
|
|
30
|
+
|
|
31
|
+
const a = new LatexLabel({ latex: '\\pi' });
|
|
32
|
+
const b = new LatexLabel({ latex: '\\theta' });
|
|
33
|
+
|
|
34
|
+
a.state = { markup: '' };
|
|
35
|
+
b.state = { markup: '' };
|
|
36
|
+
a.setState = () => {};
|
|
37
|
+
b.setState = () => {};
|
|
38
|
+
|
|
39
|
+
await a.componentDidMount();
|
|
40
|
+
await b.componentDidMount();
|
|
41
|
+
|
|
42
|
+
expect(LatexLabel.warned).toBe(true);
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
describe('KeyPad', () => {
|
|
47
|
+
const key = (over = {}) => ({ name: 'x', label: 'x', write: 'x', category: 'vars', ...over });
|
|
48
|
+
|
|
49
|
+
it('renders a button per key', () => {
|
|
50
|
+
const { container } = render(<KeyPad onPress={() => {}} baseSet={[[key(), key({ name: 'y', label: 'y' })]]} />);
|
|
51
|
+
|
|
52
|
+
expect(container.querySelectorAll('button').length).toBe(2);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('calls onPress with the whole key definition', () => {
|
|
56
|
+
const onPress = jest.fn();
|
|
57
|
+
const k = key();
|
|
58
|
+
const { container } = render(<KeyPad onPress={onPress} baseSet={[[k]]} />);
|
|
59
|
+
|
|
60
|
+
container.querySelector('button').click();
|
|
61
|
+
|
|
62
|
+
expect(onPress).toHaveBeenCalledWith(k);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('renders a latex key through LatexLabel', () => {
|
|
66
|
+
const { container } = render(<KeyPad onPress={() => {}} baseSet={[[{ name: 'pi', latex: '\\pi' }]]} />);
|
|
67
|
+
|
|
68
|
+
expect(container.textContent).toContain('\\pi');
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('disables the decimal separators when noDecimal is set', () => {
|
|
72
|
+
const dot = key({ name: '.', label: '.', write: '.' });
|
|
73
|
+
const comma = key({ name: ',', label: ',', write: ',' });
|
|
74
|
+
const { container } = render(<KeyPad onPress={() => {}} noDecimal baseSet={[[dot, comma]]} />);
|
|
75
|
+
|
|
76
|
+
const disabled = Array.from(container.querySelectorAll('button')).filter((b) => b.disabled);
|
|
77
|
+
|
|
78
|
+
expect(disabled.length).toBe(2);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('leaves the separators enabled otherwise', () => {
|
|
82
|
+
const dot = key({ name: '.', label: '.', write: '.' });
|
|
83
|
+
const { container } = render(<KeyPad onPress={() => {}} baseSet={[[dot]]} />);
|
|
84
|
+
|
|
85
|
+
expect(container.querySelector('button').disabled).toBe(false);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('renders a placeholder for empty grid slots', () => {
|
|
89
|
+
expect(() => render(<KeyPad onPress={() => {}} baseSet={[[null]]} />)).not.toThrow();
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('notifies setKeypadInteraction on pointer interaction', () => {
|
|
93
|
+
const setKeypadInteraction = jest.fn();
|
|
94
|
+
const { container } = render(
|
|
95
|
+
<KeyPad onPress={() => {}} setKeypadInteraction={setKeypadInteraction} baseSet={[[key()]]} />,
|
|
96
|
+
);
|
|
97
|
+
|
|
98
|
+
container.firstChild.dispatchEvent(new MouseEvent('mousedown', { bubbles: true }));
|
|
99
|
+
|
|
100
|
+
expect(setKeypadInteraction).toHaveBeenCalledWith(true);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('uses the mode as a class so mode-specific styling applies', () => {
|
|
104
|
+
const { container } = render(<KeyPad onPress={() => {}} mode="language" baseSet={[[key()]]} />);
|
|
105
|
+
|
|
106
|
+
expect(container.firstChild.className).toContain('language');
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('drops the base set for the modes that supply their own keys', () => {
|
|
110
|
+
const { container } = render(
|
|
111
|
+
<KeyPad onPress={() => {}} mode="language" baseSet={[[key(), key()]]} additionalKeys={[[key()]]} />,
|
|
112
|
+
);
|
|
113
|
+
|
|
114
|
+
// base set ignored, only the additional key renders
|
|
115
|
+
expect(container.querySelectorAll('button').length).toBe(1);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('unmounts without throwing', () => {
|
|
119
|
+
const { unmount } = render(<KeyPad onPress={() => {}} controlledKeypadMode baseSet={[[key()]]} />);
|
|
120
|
+
|
|
121
|
+
expect(() => unmount()).not.toThrow();
|
|
122
|
+
});
|
|
123
|
+
});
|
|
@@ -4,6 +4,8 @@ import {
|
|
|
4
4
|
fieldIds,
|
|
5
5
|
keyToAction,
|
|
6
6
|
toNativeCommands,
|
|
7
|
+
toCanonicalCommands,
|
|
8
|
+
MATHQUILL_ALIASES,
|
|
7
9
|
NEWLINE_EMBED,
|
|
8
10
|
DEFAULT_FIELD_ID,
|
|
9
11
|
} from '../latex-bridge';
|
|
@@ -133,6 +135,28 @@ describe('latex-bridge', () => {
|
|
|
133
135
|
expect(toNativeCommands('\\longdiv{1}\\longdiv{2}')).toEqual('\\enclose{longdiv}{1}\\enclose{longdiv}{2}');
|
|
134
136
|
});
|
|
135
137
|
|
|
138
|
+
// `%` starts a comment in real LaTeX, so MathLive drops it and everything
|
|
139
|
+
// after it. MathQuill had no comments, so latex authored under it - or by
|
|
140
|
+
// hand - can carry a bare `%` that would silently truncate the expression.
|
|
141
|
+
it('escapes a bare % so it cannot comment out the rest of the line', () => {
|
|
142
|
+
expect(toNativeCommands('%')).toEqual('\\%');
|
|
143
|
+
expect(toNativeCommands('50%')).toEqual('50\\%');
|
|
144
|
+
expect(toNativeCommands('x%y')).toEqual('x\\%y');
|
|
145
|
+
expect(toNativeCommands('a%b%c')).toEqual('a\\%b\\%c');
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it('leaves an already-escaped % alone', () => {
|
|
149
|
+
expect(toNativeCommands('\\%')).toEqual('\\%');
|
|
150
|
+
expect(toNativeCommands('50\\%')).toEqual('50\\%');
|
|
151
|
+
// idempotent: toMathLive may run over latex that came back out of a field
|
|
152
|
+
expect(toNativeCommands(toNativeCommands('50%'))).toEqual('50\\%');
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
// `\\` is a line break, not an escape, so the `%` after it is still bare.
|
|
156
|
+
it('counts backslashes to decide whether a % is escaped', () => {
|
|
157
|
+
expect(toNativeCommands('a\\\\%b')).toEqual('a\\\\\\%b');
|
|
158
|
+
});
|
|
159
|
+
|
|
136
160
|
it('leaves other latex untouched', () => {
|
|
137
161
|
['\\frac{1}{2}', '\\pi', '\\sqrt{x}', '\\overline{AB}'].forEach((l) => {
|
|
138
162
|
expect(toNativeCommands(l)).toEqual(l);
|
|
@@ -175,6 +199,120 @@ describe('latex-bridge', () => {
|
|
|
175
199
|
});
|
|
176
200
|
});
|
|
177
201
|
|
|
202
|
+
// MathQuill registered some keypad names as aliases of a different command:
|
|
203
|
+
// the divide key stored `\div`, not `\divide`. MathLive knows `\divide` only as
|
|
204
|
+
// a pie macro and serialises it under that name, so it reached storage and
|
|
205
|
+
// MathJax (which has no `\divide`) rendered it as red text.
|
|
206
|
+
describe('MathQuill command aliases', () => {
|
|
207
|
+
it('the divide and perpendicular keys insert the command MathQuill stored', () => {
|
|
208
|
+
/* eslint-disable global-require */
|
|
209
|
+
const { divide } = require('../keys/basic-operators');
|
|
210
|
+
// Exported under a pre-existing misspelling.
|
|
211
|
+
const { perpindicular: perpendicular } = require('../keys/geometry');
|
|
212
|
+
/* eslint-enable global-require */
|
|
213
|
+
|
|
214
|
+
expect(keyToAction(divide)).toEqual({ type: 'insert', value: '\\div' });
|
|
215
|
+
expect(keyToAction(perpendicular)).toEqual({ type: 'insert', value: '\\perp' });
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
it('stores the canonical command for latex that still carries an alias', () => {
|
|
219
|
+
// Typed into the field, or saved before the keypad resolved the alias.
|
|
220
|
+
expect(fromMathLive('1\\divide 6')).toEqual('1\\div 6');
|
|
221
|
+
expect(fromMathLive('a\\perpendicular b')).toEqual('a\\perp b');
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
it('only replaces whole command names', () => {
|
|
225
|
+
// \divideontimes is a real AMS symbol and must survive.
|
|
226
|
+
expect(toCanonicalCommands('a\\divideontimes b')).toEqual('a\\divideontimes b');
|
|
227
|
+
expect(toCanonicalCommands('\\divide\\divide')).toEqual('\\div\\div');
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
// The double prime key: MathLive serialises `5''` as `5^{\doubleprime}`,
|
|
231
|
+
// which MathJax cannot render (red `\doubleprime`). MathQuill stored `5''`.
|
|
232
|
+
it('stores the double prime key as apostrophes', () => {
|
|
233
|
+
expect(fromMathLive('5^{\\doubleprime}')).toEqual("5''");
|
|
234
|
+
expect(fromMathLive('x^{\\prime}')).toEqual("x'");
|
|
235
|
+
expect(fromMathLive('x^{\\prime\\prime\\prime}')).toEqual("x'''");
|
|
236
|
+
expect(fromMathLive('\\doubleprime')).toEqual("''");
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
// Typing `'` twice yields two superscript groups - a MathJax double-exponent
|
|
240
|
+
// error - so they collapse into apostrophes too.
|
|
241
|
+
it('collapses adjacent prime superscripts', () => {
|
|
242
|
+
expect(fromMathLive('x^{\\prime}^{\\prime}')).toEqual("x''");
|
|
243
|
+
expect(fromMathLive('5^{\\prime}^{\\doubleprime}')).toEqual("5'''");
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
it('leaves superscripts that hold more than primes alone', () => {
|
|
247
|
+
expect(fromMathLive('x^{\\prime 2}')).toEqual('x^{\\prime 2}');
|
|
248
|
+
expect(fromMathLive('x^{2}')).toEqual('x^{2}');
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
it('round-trips stored primes unchanged', () => {
|
|
252
|
+
expect(fromMathLive("12^{\\circ}30'15''")).toEqual("12^{\\circ}30'15''");
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
// `5′6″` typed or pasted as characters serialises as bare commands. A bare
|
|
256
|
+
// `\prime` is full size in MathJax while `''` is a superscript, so storing
|
|
257
|
+
// `5\prime6''` drew the single prime larger than each stroke of the double.
|
|
258
|
+
it('stores bare primes as apostrophes, so single and double match', () => {
|
|
259
|
+
expect(fromMathLive('5\\prime6\\doubleprime')).toEqual("5'6''");
|
|
260
|
+
expect(fromMathLive('5\\prime 6\\doubleprime')).toEqual("5' 6''");
|
|
261
|
+
expect(fromMathLive('\\prime')).toEqual("'");
|
|
262
|
+
expect(fromMathLive('\\frac{5\\prime}{2}')).toEqual("\\frac{5'}{2}");
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
// Inside a script a prime is already a superscript; an apostrophe there
|
|
266
|
+
// would be a prime *of* the script.
|
|
267
|
+
it('keeps primes that are inside a superscript or subscript', () => {
|
|
268
|
+
expect(fromMathLive('x^{\\prime 2}')).toEqual('x^{\\prime 2}');
|
|
269
|
+
expect(fromMathLive('x^\\prime')).toEqual('x^\\prime');
|
|
270
|
+
expect(fromMathLive('x_{\\prime}')).toEqual('x_{\\prime}');
|
|
271
|
+
// MathJax has no \doubleprime: spell it out rather than leave red text
|
|
272
|
+
expect(fromMathLive('x^{\\doubleprime 2}')).toEqual('x^{\\prime\\prime 2}');
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
it('closes the script scope at the matching brace', () => {
|
|
276
|
+
expect(fromMathLive('x^{2}\\prime')).toEqual("x^{2}'");
|
|
277
|
+
expect(fromMathLive('x^{\\frac{a}{b}}6\\doubleprime')).toEqual("x^{\\frac{a}{b}}6''");
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
it('does not touch commands that merely start with "prime"', () => {
|
|
281
|
+
expect(fromMathLive('\\primes')).toEqual('\\primes');
|
|
282
|
+
expect(fromMathLive('\\backprime')).toEqual('\\backprime');
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
it('no keypad key inserts an alias name', () => {
|
|
286
|
+
/* eslint-disable global-require */
|
|
287
|
+
const fs = require('fs');
|
|
288
|
+
const path = require('path');
|
|
289
|
+
/* eslint-enable global-require */
|
|
290
|
+
const keysDir = path.resolve(__dirname, '../keys');
|
|
291
|
+
const keys = [];
|
|
292
|
+
|
|
293
|
+
const collect = (v) => {
|
|
294
|
+
if (Array.isArray(v)) return v.forEach(collect);
|
|
295
|
+
if (v && typeof v === 'object' && (v.command || v.latex || v.write)) keys.push(v);
|
|
296
|
+
};
|
|
297
|
+
|
|
298
|
+
fs.readdirSync(keysDir)
|
|
299
|
+
.filter((f) => /\.js$/.test(f))
|
|
300
|
+
// eslint-disable-next-line global-require, import/no-dynamic-require
|
|
301
|
+
.forEach((f) => Object.values(require(path.join(keysDir, f))).forEach(collect));
|
|
302
|
+
|
|
303
|
+
expect(keys.length).toBeGreaterThan(50);
|
|
304
|
+
|
|
305
|
+
keys.forEach((k) => {
|
|
306
|
+
const action = keyToAction(k);
|
|
307
|
+
const value = (action && action.value) || '';
|
|
308
|
+
|
|
309
|
+
Object.keys(MATHQUILL_ALIASES).forEach((alias) => {
|
|
310
|
+
expect(new RegExp(`\\${alias}(?![a-zA-Z])`).test(value)).toBe(false);
|
|
311
|
+
});
|
|
312
|
+
});
|
|
313
|
+
});
|
|
314
|
+
});
|
|
315
|
+
|
|
178
316
|
describe('keyToAction', () => {
|
|
179
317
|
it('prefers latex when there is no command', () => {
|
|
180
318
|
expect(keyToAction({ latex: '\\pi', write: 'p' })).toEqual({ type: 'insert', value: '\\pi' });
|
|
@@ -190,6 +328,27 @@ describe('latex-bridge', () => {
|
|
|
190
328
|
expect(keyToAction({ command: '^' })).toEqual({ type: 'insert', value: '^{#?}' });
|
|
191
329
|
});
|
|
192
330
|
|
|
331
|
+
// MathQuill's `/` turned the preceding expression into the numerator: type
|
|
332
|
+
// 5, press the key, get 5/_ with the caret in the denominator. `#@` is
|
|
333
|
+
// MathLive's "selection, or the item before the cursor" token, and is what
|
|
334
|
+
// MathLive binds to `/` itself.
|
|
335
|
+
it('division takes the preceding item as the numerator', () => {
|
|
336
|
+
expect(keyToAction({ command: '/' })).toEqual({ type: 'insert', value: '\\frac{#@}{#?}' });
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
it('an explicit blank-over-blank fraction stays empty', () => {
|
|
340
|
+
// distinct from `/`: this key is meant to insert two empty slots
|
|
341
|
+
expect(keyToAction({ command: '\\frac' })).toEqual({ type: 'insert', value: '\\frac{#?}{#?}' });
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
it('the x-over-blank key routes through division', () => {
|
|
345
|
+
// eslint-disable-next-line global-require
|
|
346
|
+
const { xOverBlank } = require('../keys/fractions');
|
|
347
|
+
|
|
348
|
+
expect(xOverBlank.command).toEqual('/');
|
|
349
|
+
expect(keyToAction(xOverBlank).value).toEqual('\\frac{#@}{#?}');
|
|
350
|
+
});
|
|
351
|
+
|
|
193
352
|
it('passes argument-less commands through', () => {
|
|
194
353
|
expect(keyToAction({ command: '\\pi' })).toEqual({ type: 'insert', value: '\\pi' });
|
|
195
354
|
});
|
|
@@ -150,6 +150,46 @@ describe('mathlive-instance', () => {
|
|
|
150
150
|
expect(MathfieldElement.fontsDirectory.startsWith('http')).toBe(true);
|
|
151
151
|
});
|
|
152
152
|
|
|
153
|
+
// \neq, \nsim and \ncong are built from Private Use Area glyphs that only
|
|
154
|
+
// the KaTeX fonts contain, so "no fonts" renders garbage rather than
|
|
155
|
+
// degrading gracefully - hence a CDN fallback by default.
|
|
156
|
+
it('falls back to a version-pinned CDN when nothing is configured', () => {
|
|
157
|
+
const MathfieldElement = {};
|
|
158
|
+
|
|
159
|
+
instance.__setMathLiveForTest({ MathfieldElement, version: { mathlive: '9.9.9' } });
|
|
160
|
+
instance.configureFonts(undefined);
|
|
161
|
+
|
|
162
|
+
expect(MathfieldElement.fontsDirectory).toEqual('https://unpkg.com/mathlive@9.9.9/fonts');
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it('uses /fonts, not /dist/fonts (the latter 404s on unpkg)', () => {
|
|
166
|
+
const MathfieldElement = {};
|
|
167
|
+
|
|
168
|
+
instance.__setMathLiveForTest({ MathfieldElement, version: { mathlive: '0.110.0' } });
|
|
169
|
+
instance.configureFonts(undefined);
|
|
170
|
+
|
|
171
|
+
expect(MathfieldElement.fontsDirectory).toEqual('https://unpkg.com/mathlive@0.110.0/fonts');
|
|
172
|
+
expect(MathfieldElement.fontsDirectory).not.toContain('/dist/');
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it('a configured path wins over the CDN', () => {
|
|
176
|
+
const MathfieldElement = {};
|
|
177
|
+
|
|
178
|
+
instance.__setMathLiveForTest({ MathfieldElement, version: { mathlive: '0.110.0' } });
|
|
179
|
+
instance.configureFonts('/mathlive-fonts');
|
|
180
|
+
|
|
181
|
+
expect(MathfieldElement.fontsDirectory).not.toContain('unpkg.com');
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it('null still opts out entirely', () => {
|
|
185
|
+
const MathfieldElement = {};
|
|
186
|
+
|
|
187
|
+
instance.__setMathLiveForTest({ MathfieldElement, version: { mathlive: '0.110.0' } });
|
|
188
|
+
instance.configureFonts(null);
|
|
189
|
+
|
|
190
|
+
expect(MathfieldElement.fontsDirectory).toBeNull();
|
|
191
|
+
});
|
|
192
|
+
|
|
153
193
|
it('leaves a fully-qualified url untouched', () => {
|
|
154
194
|
const MathfieldElement = {};
|
|
155
195
|
|
|
@@ -169,6 +209,203 @@ describe('mathlive-instance', () => {
|
|
|
169
209
|
});
|
|
170
210
|
});
|
|
171
211
|
|
|
212
|
+
// A live <math-field> renders in shadow DOM, so nothing in the page's
|
|
213
|
+
// stylesheets reaches it - the stretchy-accent fix has to be injected there.
|
|
214
|
+
/**
|
|
215
|
+
* A keypad key like \frac inserts `\frac{#?}{#?}`; MathLive turns each `#?`
|
|
216
|
+
* into a `\placeholder{}` atom and *selects* the first one. Measured in
|
|
217
|
+
* Chromium: that atom is a leaf (its offsets are before/after, there is no
|
|
218
|
+
* position inside), so MathLive renders NO caret element and shows only the
|
|
219
|
+
* selection highlight - the box read as focused-but-dead. The caret is drawn
|
|
220
|
+
* by our own shadow CSS, gated on a class so it is placeholder-specific.
|
|
221
|
+
*/
|
|
222
|
+
describe('trackPlaceholderCaret', () => {
|
|
223
|
+
const fakeField = (selection, latex) => {
|
|
224
|
+
const classes = new Set();
|
|
225
|
+
|
|
226
|
+
return {
|
|
227
|
+
listeners: {},
|
|
228
|
+
selection,
|
|
229
|
+
selectionIsCollapsed: !selection,
|
|
230
|
+
getValue: () => latex,
|
|
231
|
+
classList: {
|
|
232
|
+
toggle: (name, on) => (on ? classes.add(name) : classes.delete(name)),
|
|
233
|
+
contains: (name) => classes.has(name),
|
|
234
|
+
},
|
|
235
|
+
addEventListener(name, fn) {
|
|
236
|
+
this.listeners[name] = fn;
|
|
237
|
+
},
|
|
238
|
+
removeEventListener(name) {
|
|
239
|
+
delete this.listeners[name];
|
|
240
|
+
},
|
|
241
|
+
};
|
|
242
|
+
};
|
|
243
|
+
|
|
244
|
+
it('marks the host when a placeholder is selected', () => {
|
|
245
|
+
const mf = fakeField({ ranges: [[1, 2]] }, '\\placeholder{}');
|
|
246
|
+
|
|
247
|
+
instance.trackPlaceholderCaret(mf);
|
|
248
|
+
|
|
249
|
+
expect(mf.classList.contains(instance.PLACEHOLDER_CARET_CLASS)).toBe(true);
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
it('does not mark an ordinary selection', () => {
|
|
253
|
+
const mf = fakeField({ ranges: [[0, 3]] }, 'x+1');
|
|
254
|
+
|
|
255
|
+
instance.trackPlaceholderCaret(mf);
|
|
256
|
+
|
|
257
|
+
expect(mf.classList.contains(instance.PLACEHOLDER_CARET_CLASS)).toBe(false);
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
it('does not mark a collapsed selection, which has a real caret already', () => {
|
|
261
|
+
const mf = fakeField(null, '\\placeholder{}');
|
|
262
|
+
|
|
263
|
+
instance.trackPlaceholderCaret(mf);
|
|
264
|
+
|
|
265
|
+
expect(mf.classList.contains(instance.PLACEHOLDER_CARET_CLASS)).toBe(false);
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
it('leaves a named prompt alone - it takes a real caret inside', () => {
|
|
269
|
+
const mf = fakeField({ ranges: [[1, 2]] }, '\\placeholder[r1]{}');
|
|
270
|
+
|
|
271
|
+
instance.trackPlaceholderCaret(mf);
|
|
272
|
+
|
|
273
|
+
expect(mf.classList.contains(instance.PLACEHOLDER_CARET_CLASS)).toBe(false);
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
it('re-evaluates whenever the selection moves', () => {
|
|
277
|
+
const mf = fakeField(null, '');
|
|
278
|
+
|
|
279
|
+
instance.trackPlaceholderCaret(mf);
|
|
280
|
+
expect(mf.classList.contains(instance.PLACEHOLDER_CARET_CLASS)).toBe(false);
|
|
281
|
+
|
|
282
|
+
// the caret lands on a placeholder
|
|
283
|
+
mf.selection = { ranges: [[1, 2]] };
|
|
284
|
+
mf.selectionIsCollapsed = false;
|
|
285
|
+
mf.getValue = () => '\\placeholder{}';
|
|
286
|
+
mf.listeners['selection-change']();
|
|
287
|
+
expect(mf.classList.contains(instance.PLACEHOLDER_CARET_CLASS)).toBe(true);
|
|
288
|
+
|
|
289
|
+
// ...and moves off it again
|
|
290
|
+
mf.selectionIsCollapsed = true;
|
|
291
|
+
mf.listeners['selection-change']();
|
|
292
|
+
expect(mf.classList.contains(instance.PLACEHOLDER_CARET_CLASS)).toBe(false);
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
it('returns a teardown that detaches the listener', () => {
|
|
296
|
+
const mf = fakeField(null, '');
|
|
297
|
+
const off = instance.trackPlaceholderCaret(mf);
|
|
298
|
+
|
|
299
|
+
expect(typeof mf.listeners['selection-change']).toEqual('function');
|
|
300
|
+
off();
|
|
301
|
+
expect(mf.listeners['selection-change']).toBeUndefined();
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
it('survives a field that throws while reading the selection', () => {
|
|
305
|
+
const mf = fakeField({ ranges: [[1, 2]] }, '');
|
|
306
|
+
|
|
307
|
+
mf.getValue = () => {
|
|
308
|
+
throw new Error('detached');
|
|
309
|
+
};
|
|
310
|
+
|
|
311
|
+
expect(() => instance.trackPlaceholderCaret(mf)).not.toThrow();
|
|
312
|
+
expect(mf.classList.contains(instance.PLACEHOLDER_CARET_CLASS)).toBe(false);
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
it('is a no-op without a field', () => {
|
|
316
|
+
expect(instance.trackPlaceholderCaret(undefined)).toBeUndefined();
|
|
317
|
+
expect(instance.trackPlaceholderCaret({})).toBeUndefined();
|
|
318
|
+
});
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
describe('applyShadowStyles', () => {
|
|
322
|
+
const host = () => {
|
|
323
|
+
const el = document.createElement('div');
|
|
324
|
+
|
|
325
|
+
el.attachShadow({ mode: 'open' });
|
|
326
|
+
|
|
327
|
+
return el;
|
|
328
|
+
};
|
|
329
|
+
|
|
330
|
+
it('is a no-op without a shadow root', () => {
|
|
331
|
+
expect(() => instance.applyShadowStyles(undefined)).not.toThrow();
|
|
332
|
+
expect(() => instance.applyShadowStyles(document.createElement('div'))).not.toThrow();
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
it('injects the accent fix into the shadow root', () => {
|
|
336
|
+
const el = host();
|
|
337
|
+
|
|
338
|
+
instance.applyShadowStyles(el);
|
|
339
|
+
|
|
340
|
+
const viaSheet = el.shadowRoot.__pieAccentSheet;
|
|
341
|
+
const viaStyle = el.shadowRoot.querySelector('style[data-pie-accent]');
|
|
342
|
+
const css = viaSheet ? 'adopted' : (viaStyle && viaStyle.textContent) || '';
|
|
343
|
+
|
|
344
|
+
expect(viaSheet || viaStyle).toBeTruthy();
|
|
345
|
+
|
|
346
|
+
if (viaStyle) {
|
|
347
|
+
expect(css).toContain('margin-left:0 !important');
|
|
348
|
+
// scoped so single-glyph accents keep their centring offset
|
|
349
|
+
expect(css).toContain(':has(.ML__stretchy)');
|
|
350
|
+
}
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
it('injects the placeholder caret rules', () => {
|
|
354
|
+
const el = host();
|
|
355
|
+
|
|
356
|
+
instance.applyShadowStyles(el);
|
|
357
|
+
|
|
358
|
+
const viaStyle = el.shadowRoot.querySelector('style[data-pie-accent]');
|
|
359
|
+
|
|
360
|
+
if (viaStyle) {
|
|
361
|
+
const css = viaStyle.textContent;
|
|
362
|
+
|
|
363
|
+
// gated on the host class, so an ordinary selection gets no caret
|
|
364
|
+
expect(css).toContain(`:host(.${instance.PLACEHOLDER_CARET_CLASS})`);
|
|
365
|
+
// MathLive's own blink keyframes and caret colour, already in this root
|
|
366
|
+
expect(css).toContain('ML__caret-blink');
|
|
367
|
+
expect(css).toContain('--_caret-color');
|
|
368
|
+
// the innermost selected element only: MathLive marks a wrapper AND the
|
|
369
|
+
// glyph span, which would otherwise render two carets
|
|
370
|
+
expect(css).toContain('.ML__selected:not(:has(.ML__selected))');
|
|
371
|
+
}
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
it('injects the radical index size fix', () => {
|
|
375
|
+
const el = host();
|
|
376
|
+
|
|
377
|
+
instance.applyShadowStyles(el);
|
|
378
|
+
|
|
379
|
+
const viaStyle = el.shadowRoot.querySelector('style[data-pie-accent]');
|
|
380
|
+
|
|
381
|
+
if (viaStyle) {
|
|
382
|
+
const css = viaStyle.textContent;
|
|
383
|
+
|
|
384
|
+
// MathLive's scriptscriptstyle index (an inline 50%) reads too small in
|
|
385
|
+
// the authoring UI; a mathfield's shadow root needs its own copy of the
|
|
386
|
+
// rule, since host styles do not cross the boundary. Compared against
|
|
387
|
+
// the exported rule, so the size stays defined in one place.
|
|
388
|
+
// eslint-disable-next-line global-require
|
|
389
|
+
const { ROOT_INDEX_CSS } = require('../mf/common-styles');
|
|
390
|
+
|
|
391
|
+
expect(ROOT_INDEX_CSS).toContain('.ML__sqrt-index >');
|
|
392
|
+
expect(css).toContain(ROOT_INDEX_CSS);
|
|
393
|
+
}
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
it('does not inject twice', () => {
|
|
397
|
+
const el = host();
|
|
398
|
+
|
|
399
|
+
instance.applyShadowStyles(el);
|
|
400
|
+
instance.applyShadowStyles(el);
|
|
401
|
+
|
|
402
|
+
const styles = el.shadowRoot.querySelectorAll('style[data-pie-accent]');
|
|
403
|
+
|
|
404
|
+
// either one adopted sheet, or exactly one style node
|
|
405
|
+
expect(el.shadowRoot.__pieAccentSheet ? 1 : styles.length).toBe(1);
|
|
406
|
+
});
|
|
407
|
+
});
|
|
408
|
+
|
|
172
409
|
describe('latexToMarkup', () => {
|
|
173
410
|
it('returns empty string when MathLive is not loaded', () => {
|
|
174
411
|
expect(instance.latexToMarkup('\\pi')).toEqual('');
|