@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.
Files changed (51) hide show
  1. package/lib/__tests__/composites.test.js +1 -1
  2. package/lib/__tests__/drop-in-compat.test.js +1 -1
  3. package/lib/__tests__/index.test.js +1 -1
  4. package/lib/__tests__/input-imperative.test.js +1 -1
  5. package/lib/__tests__/keypad-label.test.js +99 -1
  6. package/lib/__tests__/keypad-render.test.js +1 -1
  7. package/lib/__tests__/latex-bridge.test.js +22 -1
  8. package/lib/__tests__/mathlive-instance.test.js +137 -1
  9. package/lib/__tests__/mount-race.test.js +4 -1
  10. package/lib/__tests__/static-prompts.test.js +124 -1
  11. package/lib/__tests__/tmp-markup.test.js +26 -0
  12. package/lib/keypad/index.js +4 -4
  13. package/lib/keypad/index.js.map +1 -1
  14. package/lib/keys/misc.js +9 -2
  15. package/lib/keys/misc.js.map +1 -1
  16. package/lib/latex-bridge.js +30 -0
  17. package/lib/latex-bridge.js.map +1 -1
  18. package/lib/mathlive-instance.js +72 -3
  19. package/lib/mathlive-instance.js.map +1 -1
  20. package/lib/mf/common-styles.js +41 -6
  21. package/lib/mf/common-styles.js.map +1 -1
  22. package/lib/mf/index.js +6 -0
  23. package/lib/mf/index.js.map +1 -1
  24. package/lib/mf/input.js +7 -0
  25. package/lib/mf/input.js.map +1 -1
  26. package/lib/mf/static.js +84 -3
  27. package/lib/mf/static.js.map +1 -1
  28. package/package.json +1 -1
  29. package/src/__tests__/keypad-label.test.jsx +131 -5
  30. package/src/__tests__/latex-bridge.test.js +22 -0
  31. package/src/__tests__/mathlive-instance.test.js +150 -0
  32. package/src/__tests__/mount-race.test.jsx +1 -0
  33. package/src/__tests__/static-prompts.test.jsx +113 -0
  34. package/src/keypad/index.jsx +4 -1
  35. package/src/keys/misc.js +8 -2
  36. package/src/latex-bridge.js +34 -0
  37. package/src/mathlive-instance.js +80 -1
  38. package/src/mf/common-styles.js +45 -4
  39. package/src/mf/index.js +2 -0
  40. package/src/mf/input.jsx +15 -1
  41. package/src/mf/static.jsx +98 -3
  42. package/lib/__tests__/composites.test.js.map +0 -1
  43. package/lib/__tests__/drop-in-compat.test.js.map +0 -1
  44. package/lib/__tests__/index.test.js.map +0 -1
  45. package/lib/__tests__/input-imperative.test.js.map +0 -1
  46. package/lib/__tests__/keypad-label.test.js.map +0 -1
  47. package/lib/__tests__/keypad-render.test.js.map +0 -1
  48. package/lib/__tests__/latex-bridge.test.js.map +0 -1
  49. package/lib/__tests__/mathlive-instance.test.js.map +0 -1
  50. package/lib/__tests__/mount-race.test.js.map +0 -1
  51. 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
- ['\\frac{}{}', '\\overline{}', 'x^{}', 'x_{}', '\\sqrt{}', '\\sqrt[{}]{}', '\\abs{}', '\\log_{}', '\\longdiv{}'].forEach(
22
- (latex) => {
23
- expect(latexForDisplay(latex)).not.toContain('{}');
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">&#8730;</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
  });
@@ -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({
@@ -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
  };
@@ -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
- const SHADOW_CSS = '.ML__latex .ML__center:has(.ML__stretchy){margin-left:0 !important}';
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.