@pie-lib/math-input-mathlive 1.0.11 → 1.0.13
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__/composites.test.js +1 -1
- package/lib/__tests__/drop-in-compat.test.js +1 -1
- package/lib/__tests__/index.test.js +1 -1
- package/lib/__tests__/input-imperative.test.js +1 -1
- package/lib/__tests__/keypad-label.test.js +99 -1
- package/lib/__tests__/keypad-render.test.js +1 -1
- package/lib/__tests__/latex-bridge.test.js +22 -1
- package/lib/__tests__/mathlive-instance.test.js +137 -1
- package/lib/__tests__/mount-race.test.js +4 -1
- package/lib/__tests__/static-prompts.test.js +124 -1
- package/lib/__tests__/tmp-markup.test.js +26 -0
- package/lib/keypad/index.js +4 -4
- package/lib/keypad/index.js.map +1 -1
- package/lib/keys/misc.js +9 -2
- package/lib/keys/misc.js.map +1 -1
- package/lib/latex-bridge.js +30 -0
- package/lib/latex-bridge.js.map +1 -1
- package/lib/mathlive-instance.js +72 -3
- package/lib/mathlive-instance.js.map +1 -1
- package/lib/mf/common-styles.js +41 -6
- package/lib/mf/common-styles.js.map +1 -1
- package/lib/mf/index.js +6 -0
- package/lib/mf/index.js.map +1 -1
- package/lib/mf/input.js +7 -0
- package/lib/mf/input.js.map +1 -1
- package/lib/mf/static.js +84 -3
- package/lib/mf/static.js.map +1 -1
- package/package.json +1 -1
- package/src/__tests__/keypad-label.test.jsx +131 -5
- package/src/__tests__/latex-bridge.test.js +22 -0
- package/src/__tests__/mathlive-instance.test.js +150 -0
- package/src/__tests__/mount-race.test.jsx +1 -0
- package/src/__tests__/static-prompts.test.jsx +113 -0
- package/src/keypad/index.jsx +4 -1
- package/src/keys/misc.js +8 -2
- package/src/latex-bridge.js +34 -0
- package/src/mathlive-instance.js +80 -1
- package/src/mf/common-styles.js +45 -4
- package/src/mf/index.js +2 -0
- package/src/mf/input.jsx +15 -1
- package/src/mf/static.jsx +98 -3
- package/lib/__tests__/composites.test.js.map +0 -1
- package/lib/__tests__/drop-in-compat.test.js.map +0 -1
- package/lib/__tests__/index.test.js.map +0 -1
- package/lib/__tests__/input-imperative.test.js.map +0 -1
- package/lib/__tests__/keypad-label.test.js.map +0 -1
- package/lib/__tests__/keypad-render.test.js.map +0 -1
- package/lib/__tests__/latex-bridge.test.js.map +0 -1
- package/lib/__tests__/mathlive-instance.test.js.map +0 -1
- package/lib/__tests__/mount-race.test.js.map +0 -1
- package/lib/__tests__/static-prompts.test.js.map +0 -1
|
@@ -18,11 +18,19 @@ describe('keypad label display normalisation', () => {
|
|
|
18
18
|
});
|
|
19
19
|
|
|
20
20
|
it('handles every empty-group key shape', () => {
|
|
21
|
-
[
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
},
|
|
25
|
-
|
|
21
|
+
[
|
|
22
|
+
'\\frac{}{}',
|
|
23
|
+
'\\overline{}',
|
|
24
|
+
'x^{}',
|
|
25
|
+
'x_{}',
|
|
26
|
+
'\\sqrt{}',
|
|
27
|
+
'\\sqrt[{}]{}',
|
|
28
|
+
'\\abs{}',
|
|
29
|
+
'\\log_{}',
|
|
30
|
+
'\\longdiv{}',
|
|
31
|
+
].forEach((latex) => {
|
|
32
|
+
expect(latexForDisplay(latex)).not.toContain('{}');
|
|
33
|
+
});
|
|
26
34
|
});
|
|
27
35
|
|
|
28
36
|
it('treats a whitespace-only group as empty', () => {
|
|
@@ -80,6 +88,79 @@ describe('keypad label display normalisation', () => {
|
|
|
80
88
|
expect(placeholderStyles['& .ML__center:has(.ML__stretchy)'].marginLeft).toEqual('0 !important');
|
|
81
89
|
});
|
|
82
90
|
|
|
91
|
+
// MathLive sets the index of \sqrt[n]{x} in scriptscriptstyle - an inline
|
|
92
|
+
// `font-size: 50%` - which is too small to read on the nth-root key and in
|
|
93
|
+
// authored radicals. It is scaled up to 80%.
|
|
94
|
+
describe('radical index size', () => {
|
|
95
|
+
// Real MathLive 0.110 output for `\sqrt[1]{2}`, trimmed to the index. The
|
|
96
|
+
// selector walks this exact chain, so this fixture is what pins it down.
|
|
97
|
+
const INDEX_MARKUP = `
|
|
98
|
+
<span class="ML__base">
|
|
99
|
+
<span class="ML__sqrt-index">
|
|
100
|
+
<span class="ML__vlist-t"><span class="ML__vlist-r"><span class="ML__vlist" style="height:0.78em">
|
|
101
|
+
<span style="top:-3.45em">
|
|
102
|
+
<span class="ML__pstrut" style="height:3em"></span>
|
|
103
|
+
<span id="index-size" style="height:0.33em;display:inline-block;font-size: 50%">
|
|
104
|
+
<span class="ML__cmr">1</span>
|
|
105
|
+
<span id="nested-size" style="font-size: 50%">nested</span>
|
|
106
|
+
</span>
|
|
107
|
+
</span>
|
|
108
|
+
</span></span></span>
|
|
109
|
+
</span>
|
|
110
|
+
<span class="ML__sqrt-sign"><span class="ML__delim-size1">√</span></span>
|
|
111
|
+
</span>`;
|
|
112
|
+
|
|
113
|
+
const rule = () => {
|
|
114
|
+
// eslint-disable-next-line global-require
|
|
115
|
+
const { rootIndexStyles } = require('../mf/common-styles');
|
|
116
|
+
const selectors = Object.keys(rootIndexStyles);
|
|
117
|
+
|
|
118
|
+
expect(selectors).toHaveLength(1);
|
|
119
|
+
|
|
120
|
+
return { selector: selectors[0], value: rootIndexStyles[selectors[0]].fontSize };
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
it("scales the index glyph to 80%, up from MathLive's 50%", () => {
|
|
124
|
+
// !important: the 50% is an inline style, which a class cannot override.
|
|
125
|
+
expect(rule().value).toEqual('80% !important');
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it('targets the span MathLive puts the inline font-size on', () => {
|
|
129
|
+
const holder = document.createElement('div');
|
|
130
|
+
|
|
131
|
+
holder.innerHTML = INDEX_MARKUP;
|
|
132
|
+
|
|
133
|
+
const matched = holder.querySelectorAll(rule().selector.replace(/^&\s*/, ''));
|
|
134
|
+
|
|
135
|
+
expect(Array.from(matched).map((el) => el.id)).toEqual(['index-size']);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it('leaves the radical index box itself unscaled', () => {
|
|
139
|
+
// Scaling `.ML__sqrt-index` would scale the em-based top/height offsets
|
|
140
|
+
// that tuck the index into the radical, lifting it clear of the crook.
|
|
141
|
+
const { selector } = rule();
|
|
142
|
+
|
|
143
|
+
expect(selector).not.toMatch(/\.ML__sqrt-index\s*\{?$/);
|
|
144
|
+
expect(selector.startsWith('& .ML__sqrt-index >')).toBe(true);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it('applies wherever math renders - keypad labels, static math, mathfield', () => {
|
|
148
|
+
// eslint-disable-next-line global-require
|
|
149
|
+
const cs = require('../mf/common-styles');
|
|
150
|
+
const { selector } = rule();
|
|
151
|
+
|
|
152
|
+
['commonKeyboardStyles', 'commonMathLiveStyles', 'supsubStyles'].forEach((name) => {
|
|
153
|
+
expect(cs[name][selector]).toBeDefined();
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
// The live mathfield renders in a shadow root, which host styles cannot
|
|
157
|
+
// reach, so the same rule ships as plain CSS for `applyShadowStyles`.
|
|
158
|
+
expect(cs.ROOT_INDEX_CSS).toContain('.ML__sqrt-index >');
|
|
159
|
+
// Derived, so the size lives in one place: the two copies cannot drift.
|
|
160
|
+
expect(cs.ROOT_INDEX_CSS).toContain(`font-size:${rule().value}`);
|
|
161
|
+
});
|
|
162
|
+
});
|
|
163
|
+
|
|
83
164
|
it('applies those rules wherever static math renders', () => {
|
|
84
165
|
// eslint-disable-next-line global-require
|
|
85
166
|
const cs = require('../mf/common-styles');
|
|
@@ -103,6 +184,51 @@ describe('keypad label display normalisation', () => {
|
|
|
103
184
|
});
|
|
104
185
|
});
|
|
105
186
|
|
|
187
|
+
// The Percent key showed as a blank button on the Advanced Algebra keypad
|
|
188
|
+
// (row 1, position 6). MathQuill defined `LatexCmds['%']` as a symbol that
|
|
189
|
+
// serialised to `\\%`, so its bare `%` label parsed fine; MathLive parses real
|
|
190
|
+
// LaTeX, where `%` opens a comment - the label rendered nothing, and pressing
|
|
191
|
+
// the key inserted a comment that swallowed the rest of the expression.
|
|
192
|
+
it('the percent key is escaped, not a latex comment', () => {
|
|
193
|
+
// eslint-disable-next-line global-require
|
|
194
|
+
const { percentage } = require('../keys/misc');
|
|
195
|
+
|
|
196
|
+
expect(percentage.latex).toEqual('\\%');
|
|
197
|
+
expect(keyToAction(percentage).value).toEqual('\\%');
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
// Any bare `%` in a key would fail the same way, silently.
|
|
201
|
+
it('no keypad key carries an unescaped %', () => {
|
|
202
|
+
// eslint-disable-next-line global-require
|
|
203
|
+
const { baseSet } = require('../keys');
|
|
204
|
+
// eslint-disable-next-line global-require
|
|
205
|
+
const { gradeSets } = require('../keys/grades');
|
|
206
|
+
|
|
207
|
+
const every = [...baseSet, ...gradeSets.map((g) => g.set)];
|
|
208
|
+
|
|
209
|
+
const walk = (node) => {
|
|
210
|
+
if (Array.isArray(node)) {
|
|
211
|
+
node.forEach(walk);
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
if (!node) {
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
['latex', 'write', 'command'].forEach((field) => {
|
|
220
|
+
const value = node[field];
|
|
221
|
+
|
|
222
|
+
if (typeof value === 'string') {
|
|
223
|
+
// a `%` is only safe when preceded by an odd run of backslashes
|
|
224
|
+
expect(value.replace(/\\./g, '')).not.toContain('%');
|
|
225
|
+
}
|
|
226
|
+
});
|
|
227
|
+
};
|
|
228
|
+
|
|
229
|
+
walk(every);
|
|
230
|
+
});
|
|
231
|
+
|
|
106
232
|
it('leaves latex without empty groups alone', () => {
|
|
107
233
|
['\\pi', '\\sin', 'x^2', '\\overline{AB}'].forEach((latex) => {
|
|
108
234
|
expect(latexForDisplay(latex)).toEqual(latex);
|
|
@@ -133,6 +133,28 @@ describe('latex-bridge', () => {
|
|
|
133
133
|
expect(toNativeCommands('\\longdiv{1}\\longdiv{2}')).toEqual('\\enclose{longdiv}{1}\\enclose{longdiv}{2}');
|
|
134
134
|
});
|
|
135
135
|
|
|
136
|
+
// `%` starts a comment in real LaTeX, so MathLive drops it and everything
|
|
137
|
+
// after it. MathQuill had no comments, so latex authored under it - or by
|
|
138
|
+
// hand - can carry a bare `%` that would silently truncate the expression.
|
|
139
|
+
it('escapes a bare % so it cannot comment out the rest of the line', () => {
|
|
140
|
+
expect(toNativeCommands('%')).toEqual('\\%');
|
|
141
|
+
expect(toNativeCommands('50%')).toEqual('50\\%');
|
|
142
|
+
expect(toNativeCommands('x%y')).toEqual('x\\%y');
|
|
143
|
+
expect(toNativeCommands('a%b%c')).toEqual('a\\%b\\%c');
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it('leaves an already-escaped % alone', () => {
|
|
147
|
+
expect(toNativeCommands('\\%')).toEqual('\\%');
|
|
148
|
+
expect(toNativeCommands('50\\%')).toEqual('50\\%');
|
|
149
|
+
// idempotent: toMathLive may run over latex that came back out of a field
|
|
150
|
+
expect(toNativeCommands(toNativeCommands('50%'))).toEqual('50\\%');
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
// `\\` is a line break, not an escape, so the `%` after it is still bare.
|
|
154
|
+
it('counts backslashes to decide whether a % is escaped', () => {
|
|
155
|
+
expect(toNativeCommands('a\\\\%b')).toEqual('a\\\\\\%b');
|
|
156
|
+
});
|
|
157
|
+
|
|
136
158
|
it('leaves other latex untouched', () => {
|
|
137
159
|
['\\frac{1}{2}', '\\pi', '\\sqrt{x}', '\\overline{AB}'].forEach((l) => {
|
|
138
160
|
expect(toNativeCommands(l)).toEqual(l);
|
|
@@ -211,6 +211,113 @@ describe('mathlive-instance', () => {
|
|
|
211
211
|
|
|
212
212
|
// A live <math-field> renders in shadow DOM, so nothing in the page's
|
|
213
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
|
+
|
|
214
321
|
describe('applyShadowStyles', () => {
|
|
215
322
|
const host = () => {
|
|
216
323
|
const el = document.createElement('div');
|
|
@@ -243,6 +350,49 @@ describe('mathlive-instance', () => {
|
|
|
243
350
|
}
|
|
244
351
|
});
|
|
245
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
|
+
|
|
246
396
|
it('does not inject twice', () => {
|
|
247
397
|
const el = host();
|
|
248
398
|
|
|
@@ -34,6 +34,7 @@ jest.mock('../mathlive-instance', () => {
|
|
|
34
34
|
// must mirror the real export, otherwise the component sets `undefined`
|
|
35
35
|
MATH_MODE_SPACE: '\\ ',
|
|
36
36
|
applyShadowStyles: () => {},
|
|
37
|
+
trackPlaceholderCaret: () => () => {},
|
|
37
38
|
latexToMarkup: (l) => `<span>${l}</span>`,
|
|
38
39
|
};
|
|
39
40
|
});
|
|
@@ -26,6 +26,13 @@ const stubField = (values = {}, selection = null) => ({
|
|
|
26
26
|
return [i * 10, i * 10 + 5];
|
|
27
27
|
},
|
|
28
28
|
selection,
|
|
29
|
+
// Caret bookkeeping: MathLive exposes the caret offset as `position`, and any
|
|
30
|
+
// executeCommand triggers the render that reveals the caret.
|
|
31
|
+
position: undefined,
|
|
32
|
+
commands: [],
|
|
33
|
+
executeCommand(name) {
|
|
34
|
+
this.commands.push(name);
|
|
35
|
+
},
|
|
29
36
|
addEventListener() {},
|
|
30
37
|
removeEventListener() {},
|
|
31
38
|
focus() {
|
|
@@ -248,4 +255,110 @@ describe('Static: answer blocks / prompts', () => {
|
|
|
248
255
|
expect(s.mathField.blurred).toBe(true);
|
|
249
256
|
});
|
|
250
257
|
});
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* MathLive shows the caret only while the field carries `ML__focused`, which
|
|
261
|
+
* it adds during a render gated on `isSelectionEditable && hasFocus()`. In a
|
|
262
|
+
* read-only field only prompt interiors are editable, so after a bare
|
|
263
|
+
* `focus()` - whose selection sits outside every prompt - the answer block
|
|
264
|
+
* looked focused but had no blinking cursor. Verified in Chromium: the caret
|
|
265
|
+
* span goes from visibility:hidden to visible once the selection is inside a
|
|
266
|
+
* prompt AND a render has run.
|
|
267
|
+
*/
|
|
268
|
+
describe('caret placement', () => {
|
|
269
|
+
it('puts a collapsed caret in the first prompt on focus', () => {
|
|
270
|
+
const s = make();
|
|
271
|
+
|
|
272
|
+
s.mathField = stubField({ r1: '', r2: '' });
|
|
273
|
+
s.focus();
|
|
274
|
+
|
|
275
|
+
// stub ranges are [0,5] for r1, [10,15] for r2
|
|
276
|
+
expect(s.mathField.selection).toEqual({ ranges: [[0, 0]] });
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
it('forces the render that makes the caret visible', () => {
|
|
280
|
+
const s = make();
|
|
281
|
+
|
|
282
|
+
s.mathField = stubField({ r1: '' });
|
|
283
|
+
s.focus();
|
|
284
|
+
|
|
285
|
+
// assigning `selection` alone updates the model without re-rendering, so
|
|
286
|
+
// ML__focused would never be added
|
|
287
|
+
expect(s.mathField.commands).toEqual(['scrollIntoView']);
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
it('does not move a caret that is already inside a prompt', () => {
|
|
291
|
+
const s = make();
|
|
292
|
+
|
|
293
|
+
s.mathField = stubField({ r1: '', r2: '' });
|
|
294
|
+
s.mathField.position = 12; // inside r2
|
|
295
|
+
s.focus();
|
|
296
|
+
|
|
297
|
+
expect(s.mathField.selection).toBe(null);
|
|
298
|
+
expect(s.mathField.commands).toEqual([]);
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
it('targets a specific prompt', () => {
|
|
302
|
+
const s = make();
|
|
303
|
+
|
|
304
|
+
s.mathField = stubField({ r1: '', r2: '' });
|
|
305
|
+
s.focusPrompt('r2');
|
|
306
|
+
|
|
307
|
+
expect(s.mathField.selection).toEqual({ ranges: [[10, 10]] });
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
it('falls back to the first prompt for an unknown id', () => {
|
|
311
|
+
const s = make();
|
|
312
|
+
|
|
313
|
+
s.mathField = stubField({ r1: '', r2: '' });
|
|
314
|
+
s.focusPrompt('nope');
|
|
315
|
+
|
|
316
|
+
expect(s.mathField.selection).toEqual({ ranges: [[0, 0]] });
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
it('is a no-op when there are no prompts', () => {
|
|
320
|
+
const s = make();
|
|
321
|
+
|
|
322
|
+
s.mathField = stubField({});
|
|
323
|
+
|
|
324
|
+
expect(() => s.focusPrompt()).not.toThrow();
|
|
325
|
+
expect(s.mathField.selection).toBe(null);
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
it('is safe without a field', () => {
|
|
329
|
+
expect(() => make().focusPrompt('r1')).not.toThrow();
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
describe('promptContaining', () => {
|
|
333
|
+
it('finds the prompt whose range holds the offset, inclusive', () => {
|
|
334
|
+
const s = make();
|
|
335
|
+
|
|
336
|
+
s.mathField = stubField({ r1: '', r2: '' });
|
|
337
|
+
|
|
338
|
+
expect(s.promptContaining(0)).toEqual('r1');
|
|
339
|
+
expect(s.promptContaining(5)).toEqual('r1');
|
|
340
|
+
expect(s.promptContaining(10)).toEqual('r2');
|
|
341
|
+
expect(s.promptContaining(7)).toBeUndefined();
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
it('treats a missing position as outside', () => {
|
|
345
|
+
const s = make();
|
|
346
|
+
|
|
347
|
+
s.mathField = stubField({ r1: '' });
|
|
348
|
+
|
|
349
|
+
expect(s.promptContaining(undefined)).toBeUndefined();
|
|
350
|
+
});
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
// MathQuill's inner fields could be focused directly.
|
|
354
|
+
it('promptHandle exposes focus() for its own prompt', () => {
|
|
355
|
+
const s = make();
|
|
356
|
+
|
|
357
|
+
s.mathField = stubField({ r1: '', r2: '' });
|
|
358
|
+
s.promptHandle('r2').focus();
|
|
359
|
+
|
|
360
|
+
expect(s.mathField.focused).toBe(true);
|
|
361
|
+
expect(s.mathField.selection).toEqual({ ranges: [[10, 10]] });
|
|
362
|
+
});
|
|
363
|
+
});
|
|
251
364
|
});
|
package/src/keypad/index.jsx
CHANGED
|
@@ -9,7 +9,7 @@ import { color } from '@pie-lib/render-ui';
|
|
|
9
9
|
|
|
10
10
|
import { baseSet } from '../keys';
|
|
11
11
|
import { sortKeys } from './keys-layout';
|
|
12
|
-
import { commonKeyboardStyles } from '../mf/common-styles';
|
|
12
|
+
import { commonKeyboardStyles, rootIndexStyles } from '../mf/common-styles';
|
|
13
13
|
import { loadMathLive, latexToMarkup } from '../mathlive-instance';
|
|
14
14
|
|
|
15
15
|
const log = debug('pie-lib:math-input-mathlive:keypad');
|
|
@@ -53,6 +53,9 @@ const LabelHolder = styled('span')(() => ({
|
|
|
53
53
|
'& [data-pie-empty]': {
|
|
54
54
|
opacity: 0.5,
|
|
55
55
|
},
|
|
56
|
+
// A readable index on the nth-root key. Carried by the label itself, not only
|
|
57
|
+
// by the keypad container, so it holds wherever a LatexLabel is mounted.
|
|
58
|
+
...rootIndexStyles,
|
|
56
59
|
}));
|
|
57
60
|
|
|
58
61
|
export class LatexLabel extends React.Component {
|
package/src/keys/misc.js
CHANGED
|
@@ -29,10 +29,16 @@ export const brackets = set({
|
|
|
29
29
|
command: '[',
|
|
30
30
|
});
|
|
31
31
|
|
|
32
|
+
// `%` must be escaped. MathQuill was not a real LaTeX parser: it defined
|
|
33
|
+
// `LatexCmds['%']` as a symbol that *serialised* to `\%`, so a bare `%` worked
|
|
34
|
+
// both as a label and as a command. MathLive parses LaTeX properly, where `%`
|
|
35
|
+
// begins a comment - it rendered nothing (a blank button) and, once inserted,
|
|
36
|
+
// silently swallowed the rest of the expression. `\%` is what MathQuill stored
|
|
37
|
+
// all along, so this changes the key only, not the saved latex.
|
|
32
38
|
export const percentage = set({
|
|
33
39
|
name: 'Percent',
|
|
34
|
-
latex: '
|
|
35
|
-
command: '
|
|
40
|
+
latex: '\\%',
|
|
41
|
+
command: '\\%',
|
|
36
42
|
});
|
|
37
43
|
|
|
38
44
|
export const approx = set({
|
package/src/latex-bridge.js
CHANGED
|
@@ -114,6 +114,40 @@ export const toNativeCommands = (latex) => {
|
|
|
114
114
|
out = replaceAbs(out);
|
|
115
115
|
out = out.replace(/\\longdiv\{/g, '\\enclose{longdiv}{');
|
|
116
116
|
out = out.replace(/\\overarc\{/g, '\\overparen{');
|
|
117
|
+
out = escapeBareComments(out);
|
|
118
|
+
|
|
119
|
+
return out;
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Escape an unescaped `%`.
|
|
124
|
+
*
|
|
125
|
+
* In real LaTeX `%` starts a comment, so MathLive discards it *and everything
|
|
126
|
+
* after it*: `50%` renders as `50`, and `x%y` as just `x`. MathQuill had no
|
|
127
|
+
* such notion - it treated `%` as a symbol - so latex from anywhere outside the
|
|
128
|
+
* keypad may still carry a bare one.
|
|
129
|
+
*
|
|
130
|
+
* Only a `%` preceded by an even number of backslashes is bare: `\%` is already
|
|
131
|
+
* escaped, while the `%` in `\\%` (a line break, then a comment) is not.
|
|
132
|
+
*
|
|
133
|
+
* @param {string} latex
|
|
134
|
+
* @returns {string}
|
|
135
|
+
*/
|
|
136
|
+
const escapeBareComments = (latex) => {
|
|
137
|
+
if (latex.indexOf('%') === -1) {
|
|
138
|
+
return latex;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
let out = '';
|
|
142
|
+
// Consecutive backslashes immediately before the current character.
|
|
143
|
+
let backslashes = 0;
|
|
144
|
+
|
|
145
|
+
for (let i = 0; i < latex.length; i++) {
|
|
146
|
+
const ch = latex[i];
|
|
147
|
+
|
|
148
|
+
out += ch === '%' && backslashes % 2 === 0 ? '\\%' : ch;
|
|
149
|
+
backslashes = ch === '\\' ? backslashes + 1 : 0;
|
|
150
|
+
}
|
|
117
151
|
|
|
118
152
|
return out;
|
|
119
153
|
};
|
package/src/mathlive-instance.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import debug from 'debug';
|
|
2
2
|
import { NEWLINE_EMBED, toMathLive, withVisibleEmptySlots } from './latex-bridge';
|
|
3
|
+
import { ROOT_INDEX_CSS } from './mf/common-styles';
|
|
3
4
|
|
|
4
5
|
const log = debug('pie-lib:math-input-mathlive:instance');
|
|
5
6
|
|
|
@@ -196,7 +197,85 @@ const configureLoaded = () => {
|
|
|
196
197
|
* `:has(.ML__stretchy)` keeps the fix to stretchy accents; single-glyph accents
|
|
197
198
|
* (`\hat{x}`, `\vec{x}`) still need their centring offset.
|
|
198
199
|
*/
|
|
199
|
-
|
|
200
|
+
/**
|
|
201
|
+
* Marker put on the host `<math-field>` while a `\placeholder{}` is selected,
|
|
202
|
+
* so the caret rules below apply to a placeholder and nothing else.
|
|
203
|
+
*/
|
|
204
|
+
export const PLACEHOLDER_CARET_CLASS = 'pie-caret-in-placeholder';
|
|
205
|
+
|
|
206
|
+
/** What MathLive serialises a `#?` slot as. */
|
|
207
|
+
const PLACEHOLDER_LATEX = '\\placeholder{}';
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* A blinking caret inside a selected placeholder box.
|
|
211
|
+
*
|
|
212
|
+
* A keypad key such as `\frac` inserts `\frac{#?}{#?}`. MathLive turns each
|
|
213
|
+
* `#?` into a `\placeholder{}` atom and *selects* the first one. That atom is a
|
|
214
|
+
* leaf - the offsets around it are "before" and "after", there is no position
|
|
215
|
+
* inside it - so MathLive renders **no caret element at all** and shows only
|
|
216
|
+
* the selection highlight. The box therefore looked focused (blue) but dead,
|
|
217
|
+
* where MathQuill used to blink a cursor inside its empty box.
|
|
218
|
+
*
|
|
219
|
+
* So the caret is drawn here. `:not(:has(.ML__selected))` picks the innermost
|
|
220
|
+
* selected element (MathLive puts the class on a wrapper *and* the glyph span,
|
|
221
|
+
* which would otherwise give two carets), and the `ML__caret-blink` keyframes
|
|
222
|
+
* plus `--_caret-color` are MathLive's own, already in this shadow root.
|
|
223
|
+
*
|
|
224
|
+
* Typing still replaces the whole placeholder, which for an empty box is
|
|
225
|
+
* indistinguishable from inserting at the caret.
|
|
226
|
+
*/
|
|
227
|
+
const PLACEHOLDER_CARET_CSS = [
|
|
228
|
+
`:host(.${PLACEHOLDER_CARET_CLASS}) .ML__selected:not(:has(.ML__selected)){position:relative}`,
|
|
229
|
+
`:host(.${PLACEHOLDER_CARET_CLASS}) .ML__selected:not(:has(.ML__selected))::after{`,
|
|
230
|
+
'content:"";position:absolute;left:50%;top:12%;height:76%;',
|
|
231
|
+
'border-right:2px solid var(--_caret-color, currentColor);',
|
|
232
|
+
'animation:ML__caret-blink 1.05s step-end forwards infinite}',
|
|
233
|
+
].join('');
|
|
234
|
+
|
|
235
|
+
// A live mathfield needs the readable radical index too: `rootIndexStyles` is
|
|
236
|
+
// written for static markup in the host page, which cannot cross the shadow
|
|
237
|
+
// boundary.
|
|
238
|
+
const SHADOW_CSS =
|
|
239
|
+
'.ML__latex .ML__center:has(.ML__stretchy){margin-left:0 !important}' + ROOT_INDEX_CSS + PLACEHOLDER_CARET_CSS;
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Keep {@link PLACEHOLDER_CARET_CLASS} in step with the selection.
|
|
243
|
+
*
|
|
244
|
+
* Gating on the class is what keeps the caret specific to placeholders: an
|
|
245
|
+
* ordinary selection (`x+1`) must not sprout one. MathLive fires
|
|
246
|
+
* `selection-change` for every caret move, including the one that lands on the
|
|
247
|
+
* placeholder straight after an insert.
|
|
248
|
+
*
|
|
249
|
+
* @param {HTMLElement} mathField
|
|
250
|
+
* @returns {function|undefined} teardown, to call on unmount
|
|
251
|
+
*/
|
|
252
|
+
export const trackPlaceholderCaret = (mathField) => {
|
|
253
|
+
if (!mathField || typeof mathField.addEventListener !== 'function') {
|
|
254
|
+
return undefined;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const update = () => {
|
|
258
|
+
let selected = '';
|
|
259
|
+
|
|
260
|
+
try {
|
|
261
|
+
// A collapsed selection already has a real MathLive caret.
|
|
262
|
+
if (!mathField.selectionIsCollapsed && mathField.selection) {
|
|
263
|
+
selected = mathField.getValue(mathField.selection, 'latex');
|
|
264
|
+
}
|
|
265
|
+
} catch (e) {
|
|
266
|
+
log('could not read the selection: %s', e && e.message);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
if (mathField.classList) {
|
|
270
|
+
mathField.classList.toggle(PLACEHOLDER_CARET_CLASS, selected === PLACEHOLDER_LATEX);
|
|
271
|
+
}
|
|
272
|
+
};
|
|
273
|
+
|
|
274
|
+
mathField.addEventListener('selection-change', update);
|
|
275
|
+
update();
|
|
276
|
+
|
|
277
|
+
return () => mathField.removeEventListener('selection-change', update);
|
|
278
|
+
};
|
|
200
279
|
|
|
201
280
|
/**
|
|
202
281
|
* Apply {@link SHADOW_CSS} inside a mathfield's shadow root.
|