@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.
Files changed (50) hide show
  1. package/README.md +14 -5
  2. package/lib/__tests__/composites.test.js +255 -0
  3. package/lib/__tests__/drop-in-compat.test.js +133 -0
  4. package/lib/__tests__/embedded-fonts.test.js +121 -0
  5. package/lib/__tests__/index.test.js +118 -0
  6. package/lib/__tests__/input-imperative.test.js +155 -0
  7. package/lib/__tests__/keypad-label.test.js +231 -0
  8. package/lib/__tests__/keypad-render.test.js +206 -0
  9. package/lib/__tests__/latex-bridge.test.js +386 -0
  10. package/lib/__tests__/mathlive-instance.test.js +419 -0
  11. package/lib/__tests__/mount-race.test.js +218 -0
  12. package/lib/__tests__/static-prompts.test.js +405 -0
  13. package/lib/__tests__/tmp-markup.test.js +26 -0
  14. package/lib/keypad/index.js +50 -28
  15. package/lib/keypad/index.js.map +1 -1
  16. package/lib/keys/geometry.js +7 -4
  17. package/lib/keys/geometry.js.map +1 -1
  18. package/lib/keys/misc.js +9 -2
  19. package/lib/keys/misc.js.map +1 -1
  20. package/lib/latex-bridge.js +207 -7
  21. package/lib/latex-bridge.js.map +1 -1
  22. package/lib/mathlive-instance.js +176 -12
  23. package/lib/mathlive-instance.js.map +1 -1
  24. package/lib/mf/common-styles.js +89 -10
  25. package/lib/mf/common-styles.js.map +1 -1
  26. package/lib/mf/index.js +12 -0
  27. package/lib/mf/index.js.map +1 -1
  28. package/lib/mf/input.js +34 -5
  29. package/lib/mf/input.js.map +1 -1
  30. package/lib/mf/static.js +107 -7
  31. package/lib/mf/static.js.map +1 -1
  32. package/package.json +3 -4
  33. package/src/__tests__/composites.test.jsx +220 -0
  34. package/src/__tests__/keypad-label.test.jsx +229 -2
  35. package/src/__tests__/keypad-render.test.jsx +123 -0
  36. package/src/__tests__/latex-bridge.test.js +159 -0
  37. package/src/__tests__/mathlive-instance.test.js +237 -0
  38. package/src/__tests__/mount-race.test.jsx +142 -0
  39. package/src/__tests__/static-prompts.test.jsx +364 -0
  40. package/src/keypad/index.jsx +33 -17
  41. package/src/keys/geometry.js +7 -2
  42. package/src/keys/misc.js +8 -2
  43. package/src/latex-bridge.js +223 -7
  44. package/src/mathlive-instance.js +196 -11
  45. package/src/mf/common-styles.js +97 -8
  46. package/src/mf/index.js +4 -0
  47. package/src/mf/input.jsx +42 -5
  48. package/src/mf/static.jsx +124 -6
  49. package/CHANGELOG.md +0 -30
  50. package/LICENSE.md +0 -5
@@ -55,12 +55,49 @@ export const DEFAULT_FIELD_ID = 'r1';
55
55
  *
56
56
  * `\placeholder{}` is not usable here: in static markup it renders as a
57
57
  * non-breaking space, because its editable box only exists inside a live
58
- * mathfield. So an actual glyph is required.
58
+ * mathfield. So something with real width is required.
59
+ *
60
+ * It must be a `\rule`, not a unicode glyph. A glyph's width comes from font
61
+ * metrics, and U+25AB is not in the KaTeX fonts - the browser substitutes a
62
+ * wider fallback while MathLive still sizes surrounding accents from its own
63
+ * metrics. That left stretchy accents (`\overarc`, `\overleftrightarrow`)
64
+ * visibly narrower than the box they were meant to span. A rule carries an
65
+ * explicit em width, so accents size correctly - and a filled box is closer to
66
+ * MathQuill's `.mq-empty` anyway.
59
67
  *
60
68
  * Live mathfields need none of this - MathLive manages empty slots itself.
61
69
  */
62
- const EMPTY_SLOT = '\\htmlData{pie-empty=1}{\\unicode{"25AB}}';
70
+ const EMPTY_SLOT = '\\htmlData{pie-empty=1}{\\rule{0.55em}{0.55em}}';
63
71
  const EMPTY_GROUP_REGEX = /\{\s*\}/g;
72
+ // The segment key (`\overline{}`). The keypad centres every label in its cell, and
73
+ // a thin bar is a much shorter label than the ray and line keys' stretchy arrows,
74
+ // so by default both its bar and its box sit off from theirs: the bar is lower
75
+ // than the arrows' shafts, and the box higher than theirs. Two zero-width struts
76
+ // fix that, found by rendering the three keys at 14-28px and measuring:
77
+ // - inside the bar, above the box: pushes the bar up to the height of the
78
+ // arrows' shafts (the bar-to-box distance then matches theirs);
79
+ // - before the bar, from the baseline: raises the label's top, which moves the
80
+ // whole label down so the box lands where theirs does.
81
+ const EMPTY_OVERLINE_REGEX = /\\overline\{\s*\}/g;
82
+ const EMPTY_OVERLINE_BAR_STRUT = '\\rule{0em}{0.87em}';
83
+ const EMPTY_OVERLINE_LABEL_STRUT = '\\rule{0em}{1.3em}';
84
+ // Empty delimiter pairs: `\abs{}` is converted to `\left|\right|`, and the
85
+ // parenthesis / bracket keys are `\left(\right)` / `\left[\right]`. None has a
86
+ // `{}` group for the rule above to catch, so they would render as bare fences.
87
+ const EMPTY_FENCE_REGEX = /\\left([([|])\s*\\right([)\]|])/g;
88
+ // The same muted fill as the other slots (`pie-empty`), but a tall rectangle
89
+ // rather than a square. It also sets the fences' height: `\left...\right` stretch
90
+ // to their content, so a taller slot gives taller parentheses.
91
+ //
92
+ // A `\rule` stands on the baseline, but the fences are centred on the math axis
93
+ // (0.25em above it), so an un-lifted box sat high between them. `[-0.25em]`
94
+ // lowers it by (axis - height / 2) = 0.25 - 1 / 2, centring it on the axis.
95
+ //
96
+ // Lowering it alone also shrinks the fences, which size themselves to their
97
+ // content (MathLive switches to short bars). The zero-width `\rule{0em}{1em}`
98
+ // strut keeps the content as tall as before, so the bars keep their height and
99
+ // only the box moves.
100
+ const EMPTY_FENCE_SLOT = '\\rule{0em}{1em}\\htmlData{pie-empty=1}{\\rule[-0.25em]{0.55em}{1em}}';
64
101
 
65
102
  /**
66
103
  * Make empty argument slots visible, for static rendering.
@@ -71,7 +108,11 @@ const EMPTY_GROUP_REGEX = /\{\s*\}/g;
71
108
  * @param {string} latex
72
109
  * @returns {string}
73
110
  */
74
- export const withVisibleEmptySlots = (latex) => (latex || '').replace(EMPTY_GROUP_REGEX, `{${EMPTY_SLOT}}`);
111
+ export const withVisibleEmptySlots = (latex) =>
112
+ (latex || '')
113
+ .replace(EMPTY_OVERLINE_REGEX, `${EMPTY_OVERLINE_LABEL_STRUT}\\overline{${EMPTY_OVERLINE_BAR_STRUT}${EMPTY_SLOT}}`)
114
+ .replace(EMPTY_GROUP_REGEX, `{${EMPTY_SLOT}}`)
115
+ .replace(EMPTY_FENCE_REGEX, (_m, open, close) => `\\left${open}${EMPTY_FENCE_SLOT}\\right${close}`);
75
116
 
76
117
  /**
77
118
  * Pie commands that take an argument, and their native MathLive equivalents.
@@ -106,6 +147,40 @@ export const toNativeCommands = (latex) => {
106
147
  out = replaceAbs(out);
107
148
  out = out.replace(/\\longdiv\{/g, '\\enclose{longdiv}{');
108
149
  out = out.replace(/\\overarc\{/g, '\\overparen{');
150
+ out = escapeBareComments(out);
151
+
152
+ return out;
153
+ };
154
+
155
+ /**
156
+ * Escape an unescaped `%`.
157
+ *
158
+ * In real LaTeX `%` starts a comment, so MathLive discards it *and everything
159
+ * after it*: `50%` renders as `50`, and `x%y` as just `x`. MathQuill had no
160
+ * such notion - it treated `%` as a symbol - so latex from anywhere outside the
161
+ * keypad may still carry a bare one.
162
+ *
163
+ * Only a `%` preceded by an even number of backslashes is bare: `\%` is already
164
+ * escaped, while the `%` in `\\%` (a line break, then a comment) is not.
165
+ *
166
+ * @param {string} latex
167
+ * @returns {string}
168
+ */
169
+ const escapeBareComments = (latex) => {
170
+ if (latex.indexOf('%') === -1) {
171
+ return latex;
172
+ }
173
+
174
+ let out = '';
175
+ // Consecutive backslashes immediately before the current character.
176
+ let backslashes = 0;
177
+
178
+ for (let i = 0; i < latex.length; i++) {
179
+ const ch = latex[i];
180
+
181
+ out += ch === '%' && backslashes % 2 === 0 ? '\\%' : ch;
182
+ backslashes = ch === '\\' ? backslashes + 1 : 0;
183
+ }
109
184
 
110
185
  return out;
111
186
  };
@@ -194,6 +269,138 @@ export const toMathLive = (latex) => {
194
269
  return out;
195
270
  };
196
271
 
272
+ /**
273
+ * Keypad command names that MathQuill treated as ALIASES of a different command.
274
+ *
275
+ * MathQuill registered `\\divide` as another name for `\\div` (and
276
+ * `\\perpendicular` for `\\perp`), so pressing the key stored the real command.
277
+ * MathLive only knows these names through `PIE_MACROS`, and serialises a macro
278
+ * under its own name - so `\\divide` reached storage verbatim, where MathJax
279
+ * (@pie-lib/math-rendering) has no such command and renders it as red text.
280
+ *
281
+ * Every other keypad command already matches what MathQuill stored (`\\degree`,
282
+ * `\\nsim`, `\\ncong`, `\\nparallel`, `\\square`, `\\napprox`,
283
+ * `\\parallelogram`), and MathJax understands those.
284
+ */
285
+ const MATHQUILL_ALIASES = {
286
+ '\\divide': '\\div',
287
+ '\\perpendicular': '\\perp',
288
+ };
289
+
290
+ // Whole command names only: `\\divideontimes` is a real AMS symbol.
291
+ const MATHQUILL_ALIAS_REGEX = /\\(divide|perpendicular)(?![a-zA-Z])/g;
292
+
293
+ /**
294
+ * Primes. MathLive turns a prime that follows a base into a superscript atom and
295
+ * serialises it as `^{\\prime}`, or `^{\\doubleprime}` for the keypad's double
296
+ * prime key (`5''` comes back out as `5^{\\doubleprime}`). MathJax has no
297
+ * `\\doubleprime`, and two adjacent `^{\\prime}` groups - what typing `'` twice
298
+ * produces - are a "double exponent" error. MathQuill stored plain apostrophes,
299
+ * which both MathJax and MathLive read as primes, so store those.
300
+ *
301
+ * Only a superscript made of primes alone is rewritten: `x^{\\prime 2}` keeps
302
+ * its group, since it holds more than primes.
303
+ */
304
+ const PRIME_SUPERSCRIPT_REGEX = /\^\{((?:\s*\\(?:prime|doubleprime)(?![a-zA-Z]))+)\s*\}/g;
305
+ const BARE_DOUBLEPRIME_REGEX = /\\doubleprime(?![a-zA-Z])/g;
306
+
307
+ const primesToApostrophes = (primes) =>
308
+ // eslint-disable-next-line quotes
309
+ "'".repeat(
310
+ (primes.match(/\\prime(?![a-zA-Z])/g) || []).length + 2 * (primes.match(BARE_DOUBLEPRIME_REGEX) || []).length,
311
+ );
312
+
313
+ const BARE_PRIME_COMMAND_REGEX = /^\\(doubleprime|prime)(?![a-zA-Z])/;
314
+
315
+ /**
316
+ * Primes that are *not* in a superscript.
317
+ *
318
+ * MathLive emits bare `\prime` / `\doubleprime` for a `′` or `″` typed or pasted
319
+ * as a character (`5′6″` serialises as `5\prime6\doubleprime`). A bare `\prime`
320
+ * is a full-size glyph in MathJax, while `''` is a superscript, so storing
321
+ * `5\prime6''` drew the single prime visibly larger than each stroke of the
322
+ * double prime. Both become apostrophes, the superscript primes MathQuill
323
+ * stored, so they always match.
324
+ *
325
+ * Inside a script (`x^{\prime 2}`, `x^\prime`) a prime is already a
326
+ * superscript, and an apostrophe there would mean something else, so
327
+ * `\prime` is kept and `\doubleprime` (unknown to MathJax) becomes
328
+ * `\prime\prime`.
329
+ *
330
+ * @param {string} latex
331
+ * @returns {string}
332
+ */
333
+ const barePrimesToApostrophes = (latex) => {
334
+ if (latex.indexOf('prime') === -1) {
335
+ return latex;
336
+ }
337
+
338
+ // One entry per open brace: is that group a super/subscript?
339
+ const groups = [];
340
+ let out = '';
341
+ let i = 0;
342
+
343
+ while (i < latex.length) {
344
+ const ch = latex[i];
345
+
346
+ if (ch === '\\') {
347
+ const match = latex.slice(i).match(BARE_PRIME_COMMAND_REGEX);
348
+
349
+ if (match) {
350
+ const isDouble = match[1] === 'doubleprime';
351
+ // `x^\prime`: the command is itself the script.
352
+ const isScriptArgument = /[\^_]\s*$/.test(out);
353
+ const inScript = isScriptArgument || groups.indexOf(true) !== -1;
354
+
355
+ if (inScript) {
356
+ out += isDouble ? '\\prime\\prime' : '\\prime';
357
+ } else {
358
+ // eslint-disable-next-line quotes
359
+ out += isDouble ? "''" : "'";
360
+ }
361
+
362
+ i += match[0].length;
363
+ continue;
364
+ }
365
+
366
+ // Copy any other command, or an escaped character such as `\{`, whole.
367
+ const command = latex.slice(i).match(/^\\(?:[a-zA-Z]+|.)/);
368
+ const text = command ? command[0] : ch;
369
+
370
+ out += text;
371
+ i += text.length;
372
+ continue;
373
+ }
374
+
375
+ if (ch === '{') {
376
+ groups.push(/[\^_]\s*$/.test(out));
377
+ } else if (ch === '}') {
378
+ groups.pop();
379
+ }
380
+
381
+ out += ch;
382
+ i += 1;
383
+ }
384
+
385
+ return out;
386
+ };
387
+
388
+ /**
389
+ * Rewrite what MathLive serialises into the form MathQuill stored: alias
390
+ * command names, and primes.
391
+ *
392
+ * @param {string} latex
393
+ * @returns {string}
394
+ */
395
+ export const toCanonicalCommands = (latex) =>
396
+ typeof latex === 'string'
397
+ ? barePrimesToApostrophes(
398
+ latex
399
+ .replace(MATHQUILL_ALIAS_REGEX, (m) => MATHQUILL_ALIASES[m])
400
+ .replace(PRIME_SUPERSCRIPT_REGEX, (_m, primes) => primesToApostrophes(primes)),
401
+ )
402
+ : latex;
403
+
197
404
  /**
198
405
  * Convert latex out of MathLive back into the stored form, so items authored
199
406
  * with the MathLive editor remain readable by @pie-lib/math-rendering and by
@@ -231,7 +438,9 @@ export const fromMathLive = (latex) => {
231
438
  out = out.replace(BARE_PLACEHOLDER_REGEX, (_m, content) => content);
232
439
  out = out.replace(BARE_PLACEHOLDER_NO_ARG_REGEX, '');
233
440
 
234
- return out;
441
+ // Store the command MathQuill stored, not the alias - also heals latex that was
442
+ // saved with `\\divide` before the keypad resolved it.
443
+ return toCanonicalCommands(out);
235
444
  };
236
445
 
237
446
  /**
@@ -240,8 +449,15 @@ export const fromMathLive = (latex) => {
240
449
  * placeholders in the inserted latex.
241
450
  */
242
451
  const COMMANDS_WITH_ARGS = {
452
+ // An explicit "blank over blank" key: both slots start empty.
243
453
  '\\frac': '\\frac{#?}{#?}',
244
- '/': '\\frac{#?}{#?}',
454
+ // Division, which must behave like MathQuill's `/`: whatever precedes the
455
+ // cursor becomes the numerator and the caret lands in the denominator. `#@`
456
+ // is MathLive's token for "the selection, or the item before the cursor" -
457
+ // this is exactly the template MathLive binds to the `/` key itself. Using
458
+ // `#?` here instead produced an empty fraction and left the typed number
459
+ // stranded outside it.
460
+ '/': '\\frac{#@}{#?}',
245
461
  '\\sqrt': '\\sqrt{#?}',
246
462
  '\\nthroot': '\\sqrt[#?]{#?}',
247
463
  '\\overline': '\\overline{#?}',
@@ -298,7 +514,7 @@ export const keyToAction = (key) => {
298
514
  // applied them in sequence; MathLive inserts one latex string, so expand
299
515
  // each part and concatenate.
300
516
  const parts = Array.isArray(key.command) ? key.command : [key.command];
301
- const value = parts.map((c) => COMMANDS_WITH_ARGS[c] || c).join('');
517
+ const value = parts.map((c) => COMMANDS_WITH_ARGS[c] || MATHQUILL_ALIASES[c] || c).join('');
302
518
 
303
519
  return { type: 'insert', value };
304
520
  }
@@ -312,4 +528,4 @@ export const keyToAction = (key) => {
312
528
  return undefined;
313
529
  };
314
530
 
315
- export { COMMANDS_WITH_ARGS, KEYSTROKES };
531
+ export { COMMANDS_WITH_ARGS, KEYSTROKES, MATHQUILL_ALIASES };
@@ -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, PARALLEL_CSS } from './mf/common-styles';
3
4
 
4
5
  const log = debug('pie-lib:math-input-mathlive:instance');
5
6
 
@@ -46,26 +47,63 @@ const absoluteFontsDirectory = (dir) => {
46
47
  }
47
48
  };
48
49
 
50
+ /**
51
+ * Version-pinned CDN fallback for the KaTeX fonts.
52
+ *
53
+ * Some symbols are composed from Private Use Area glyphs that exist only in
54
+ * those fonts: `\\neq` is U+E020 (a negation slash) overlapped on `=`, and
55
+ * `\\nsim` / `\\ncong` work the same way. With no fonts the browser substitutes
56
+ * an arbitrary glyph for U+E020, so those keys render as garbage rather than
57
+ * simply falling back to a plainer face.
58
+ *
59
+ * The version comes from MathLive itself, so the fonts always match the engine.
60
+ * Hosts that would rather not depend on a third party should serve the fonts and
61
+ * call `configureFonts('/their/path')`, or pass `null` to opt out.
62
+ */
63
+ const FALLBACK_MATHLIVE_VERSION = '0.110.0';
64
+
65
+ const cdnFontsDirectory = () => {
66
+ const version = (mathlive && mathlive.version && mathlive.version.mathlive) || FALLBACK_MATHLIVE_VERSION;
67
+
68
+ // NOTE: `/fonts`, not `/dist/fonts` - the published package root holds them.
69
+ return `https://unpkg.com/mathlive@${version}/fonts`;
70
+ };
71
+
49
72
  /**
50
73
  * Tell the package where the MathLive fonts are served from.
51
74
  *
52
- * Call before `loadMathLive()`. Accepts an absolute URL, a root-relative path
53
- * (`/mathlive-fonts`) or a relative one; pass `null` to skip font loading and
54
- * fall back to system fonts.
75
+ * Call before `loadMathLive()`.
76
+ *
77
+ * - a path/URL: served by you, no third-party request
78
+ * - `null`: skip font loading; note `\\neq`, `\\nsim` and `\\ncong` then
79
+ * render incorrectly, being built from Private Use Area glyphs
80
+ * - not called: falls back to a version-pinned CDN
55
81
  *
56
82
  * The fonts ship inside the `mathlive` package (`node_modules/mathlive/fonts`)
57
- * and must be copied into whatever the host app serves statically.
83
+ * ready to copy into whatever the host app serves statically.
58
84
  *
59
- * @param {string|null} dir
85
+ * @param {string|null} [dir]
60
86
  */
61
87
  export const configureFonts = (dir) => {
62
88
  fontsDirectory = dir;
63
89
 
64
90
  if (mathlive) {
65
- mathlive.MathfieldElement.fontsDirectory = absoluteFontsDirectory(dir);
91
+ mathlive.MathfieldElement.fontsDirectory = dir === undefined ? cdnFontsDirectory() : absoluteFontsDirectory(dir);
66
92
  }
67
93
  };
68
94
 
95
+ /**
96
+ * What the spacebar inserts in math mode.
97
+ *
98
+ * MathLive's `mathModeSpace` defaults to an empty string, which makes the
99
+ * spacebar a no-op - users cannot type a space at all. `\\ ` (a LaTeX control
100
+ * space) is what MathQuill serialised spaces as, so existing content and the
101
+ * MathJax renderer in @pie-lib/math-rendering both already understand it.
102
+ *
103
+ * A literal " " is not usable: LaTeX skips raw whitespace.
104
+ */
105
+ export const MATH_MODE_SPACE = '\\ ';
106
+
69
107
  /**
70
108
  * Custom macros for the pie-specific commands that the MathQuill fork
71
109
  * understood but stock LaTeX does not.
@@ -78,6 +116,9 @@ export const PIE_MACROS = {
78
116
  // Argument-less symbols. Atomic is correct here: backspace should remove the
79
117
  // whole glyph, and there is nothing to type into.
80
118
  parallelogram: '\\htmlData{pie-macro=parallelogram}{\\unicode{"25B1}}',
119
+ // Same glyph as the built-in `\\parallel` (U+2225), tagged so it can be sized -
120
+ // see `parallelStyles`.
121
+ parallel: '\\htmlData{pie-macro=parallel}{\\unicode{"2225}}',
81
122
  napprox: '\\not\\approx',
82
123
  nsim: '\\not\\sim',
83
124
  ncong: '\\not\\cong',
@@ -126,21 +167,165 @@ let loadWarned = false;
126
167
  /** Apply post-load configuration exactly once. */
127
168
  const configureLoaded = () => {
128
169
  // Always replace MathLive's relative `'./fonts/'` default: it throws
129
- // "Invalid base URL" when bundled. Either point at the host app's copy or
130
- // disable font loading, both of which are safe.
170
+ // "Invalid base URL" when bundled. Use the host's copy if one was configured,
171
+ // otherwise the version-pinned CDN.
131
172
  if (fontsDirectory === undefined) {
132
173
  log(
133
- 'no fontsDirectory configured - MathLive will use fallback fonts. ' +
134
- 'Call configureFonts("/path/to/fonts") with a copy of node_modules/mathlive/fonts to enable them.',
174
+ 'no fontsDirectory configured - falling back to the CDN (%s). ' +
175
+ 'Serve the fonts yourself and call configureFonts("/path/to/fonts") to avoid the network request, ' +
176
+ 'or configureFonts(null) to opt out.',
177
+ cdnFontsDirectory(),
135
178
  );
136
179
  }
137
180
 
138
181
  mathlive.MathfieldElement.fontsDirectory =
139
- fontsDirectory === undefined ? null : absoluteFontsDirectory(fontsDirectory);
182
+ fontsDirectory === undefined ? cdnFontsDirectory() : absoluteFontsDirectory(fontsDirectory);
140
183
 
141
184
  injectCoreStylesheet();
142
185
  };
143
186
 
187
+ /**
188
+ * CSS that has to live *inside* a mathfield's shadow root.
189
+ *
190
+ * A live `<math-field>` renders in shadow DOM (`attachShadow({mode:'open'})`),
191
+ * so nothing in the host page's stylesheets reaches it. The stretchy-accent
192
+ * problem therefore needs fixing twice: once for static markup (see
193
+ * `placeholderStyles`) and once here for the editor.
194
+ *
195
+ * MathLive centres an accent with `margin-left: <half the content width>` as an
196
+ * inline style. For a stretchy accent that also shrinks its containing block
197
+ * (`.ML__center` is position:relative), so the arc ends up half-width and offset
198
+ * - visibly failing to span the text underneath.
199
+ *
200
+ * `:has(.ML__stretchy)` keeps the fix to stretchy accents; single-glyph accents
201
+ * (`\hat{x}`, `\vec{x}`) still need their centring offset.
202
+ */
203
+ /**
204
+ * Marker put on the host `<math-field>` while a `\placeholder{}` is selected,
205
+ * so the caret rules below apply to a placeholder and nothing else.
206
+ */
207
+ export const PLACEHOLDER_CARET_CLASS = 'pie-caret-in-placeholder';
208
+
209
+ /** What MathLive serialises a `#?` slot as. */
210
+ const PLACEHOLDER_LATEX = '\\placeholder{}';
211
+
212
+ /**
213
+ * A blinking caret inside a selected placeholder box.
214
+ *
215
+ * A keypad key such as `\frac` inserts `\frac{#?}{#?}`. MathLive turns each
216
+ * `#?` into a `\placeholder{}` atom and *selects* the first one. That atom is a
217
+ * leaf - the offsets around it are "before" and "after", there is no position
218
+ * inside it - so MathLive renders **no caret element at all** and shows only
219
+ * the selection highlight. The box therefore looked focused (blue) but dead,
220
+ * where MathQuill used to blink a cursor inside its empty box.
221
+ *
222
+ * So the caret is drawn here. `:not(:has(.ML__selected))` picks the innermost
223
+ * selected element (MathLive puts the class on a wrapper *and* the glyph span,
224
+ * which would otherwise give two carets), and the `ML__caret-blink` keyframes
225
+ * plus `--_caret-color` are MathLive's own, already in this shadow root.
226
+ *
227
+ * Typing still replaces the whole placeholder, which for an empty box is
228
+ * indistinguishable from inserting at the caret.
229
+ */
230
+ const PLACEHOLDER_CARET_CSS = [
231
+ `:host(.${PLACEHOLDER_CARET_CLASS}) .ML__selected:not(:has(.ML__selected)){position:relative}`,
232
+ `:host(.${PLACEHOLDER_CARET_CLASS}) .ML__selected:not(:has(.ML__selected))::after{`,
233
+ 'content:"";position:absolute;left:50%;top:12%;height:76%;',
234
+ 'border-right:2px solid var(--_caret-color, currentColor);',
235
+ 'animation:ML__caret-blink 1.05s step-end forwards infinite}',
236
+ ].join('');
237
+
238
+ // A live mathfield needs the readable radical index too: `rootIndexStyles` is
239
+ // written for static markup in the host page, which cannot cross the shadow
240
+ // boundary.
241
+ const SHADOW_CSS =
242
+ '.ML__latex .ML__center:has(.ML__stretchy){margin-left:0 !important}' +
243
+ ROOT_INDEX_CSS +
244
+ PARALLEL_CSS +
245
+ PLACEHOLDER_CARET_CSS;
246
+
247
+ /**
248
+ * Keep {@link PLACEHOLDER_CARET_CLASS} in step with the selection.
249
+ *
250
+ * Gating on the class is what keeps the caret specific to placeholders: an
251
+ * ordinary selection (`x+1`) must not sprout one. MathLive fires
252
+ * `selection-change` for every caret move, including the one that lands on the
253
+ * placeholder straight after an insert.
254
+ *
255
+ * @param {HTMLElement} mathField
256
+ * @returns {function|undefined} teardown, to call on unmount
257
+ */
258
+ export const trackPlaceholderCaret = (mathField) => {
259
+ if (!mathField || typeof mathField.addEventListener !== 'function') {
260
+ return undefined;
261
+ }
262
+
263
+ const update = () => {
264
+ let selected = '';
265
+
266
+ try {
267
+ // A collapsed selection already has a real MathLive caret.
268
+ if (!mathField.selectionIsCollapsed && mathField.selection) {
269
+ selected = mathField.getValue(mathField.selection, 'latex');
270
+ }
271
+ } catch (e) {
272
+ log('could not read the selection: %s', e && e.message);
273
+ }
274
+
275
+ if (mathField.classList) {
276
+ mathField.classList.toggle(PLACEHOLDER_CARET_CLASS, selected === PLACEHOLDER_LATEX);
277
+ }
278
+ };
279
+
280
+ mathField.addEventListener('selection-change', update);
281
+ update();
282
+
283
+ return () => mathField.removeEventListener('selection-change', update);
284
+ };
285
+
286
+ /**
287
+ * Apply {@link SHADOW_CSS} inside a mathfield's shadow root.
288
+ *
289
+ * Uses `adoptedStyleSheets` when available (which is how MathLive loads its own
290
+ * stylesheets) and falls back to appending a <style> node.
291
+ *
292
+ * @param {HTMLElement} mathField
293
+ */
294
+ export const applyShadowStyles = (mathField) => {
295
+ const root = mathField && mathField.shadowRoot;
296
+
297
+ if (!root) {
298
+ return;
299
+ }
300
+
301
+ try {
302
+ if ('adoptedStyleSheets' in root && typeof CSSStyleSheet !== 'undefined') {
303
+ // Do not re-add on a re-render.
304
+ if (root.__pieAccentSheet) {
305
+ return;
306
+ }
307
+
308
+ const sheet = new CSSStyleSheet();
309
+
310
+ sheet.replaceSync(SHADOW_CSS);
311
+ root.adoptedStyleSheets = [...root.adoptedStyleSheets, sheet];
312
+ root.__pieAccentSheet = sheet;
313
+
314
+ return;
315
+ }
316
+
317
+ if (!root.querySelector('style[data-pie-accent]')) {
318
+ const style = document.createElement('style');
319
+
320
+ style.setAttribute('data-pie-accent', '');
321
+ style.textContent = SHADOW_CSS;
322
+ root.appendChild(style);
323
+ }
324
+ } catch (e) {
325
+ log('could not apply shadow styles to the mathfield: %s', e && e.message);
326
+ }
327
+ };
328
+
144
329
  /**
145
330
  * Ensure MathLive's "core" stylesheet is in the document.
146
331
  *
@@ -13,7 +13,9 @@
13
13
  * Most are intentionally near-empty: MathLive sizes scripts, radicals and
14
14
  * fences correctly on its own, so the MathQuill-era corrections are not needed.
15
15
  * They stay as documented extension points rather than being deleted, so the
16
- * destructuring above keeps working.
16
+ * destructuring above keeps working. The exception is the radical index, which
17
+ * MathLive sizes to TeX metrics that read too small here - see
18
+ * `rootIndexStyles`.
17
19
  */
18
20
 
19
21
  /**
@@ -40,10 +42,66 @@ export const longdivStyles = {
40
42
  };
41
43
 
42
44
  /**
43
- * Superscript/subscript sizing. MathLive follows TeX metrics, so no overrides
44
- * are required.
45
+ * How the index of a radical is sized.
46
+ *
47
+ * MathLive follows TeX and sets the index of `\sqrt[n]{x}` in scriptscriptstyle,
48
+ * which it emits as an inline `font-size: 50%` on the span wrapping the index's
49
+ * glyphs. At authoring sizes that leaves the index - on the nth-root key, and in
50
+ * any radical the author types - too small to read next to the radicand, so it is
51
+ * scaled up to 80% - 1.6x what MathLive asks for.
52
+ *
53
+ * Only that innermost span is resized. `.ML__sqrt-index` itself keeps its font
54
+ * size on purpose: the vlist inside it tucks the index into the radical's crook
55
+ * with em-based `top`/`height` offsets, and scaling those lifts the index clear
56
+ * of the radical instead of just enlarging it.
57
+ *
58
+ * The child chain is spelled out so that nothing nested deeper inside the index
59
+ * (a fraction's numerator, another radical) is caught by the same rule, and
60
+ * `!important` is required because the 50% is an INLINE style.
61
+ */
62
+ const ROOT_INDEX_SELECTOR = '.ML__sqrt-index > .ML__vlist-t > .ML__vlist-r > .ML__vlist > span > [style*="font-size"]';
63
+ const ROOT_INDEX_FONT_SIZE = '80%';
64
+
65
+ export const rootIndexStyles = {
66
+ [`& ${ROOT_INDEX_SELECTOR}`]: {
67
+ fontSize: `${ROOT_INDEX_FONT_SIZE} !important`,
68
+ },
69
+ };
70
+
71
+ /**
72
+ * The same rule as a CSS string, for a live mathfield's shadow root - see
73
+ * `SHADOW_CSS` in mathlive-instance.js. Static markup is styled by
74
+ * {@link rootIndexStyles}.
75
+ */
76
+ export const ROOT_INDEX_CSS = `${ROOT_INDEX_SELECTOR}{font-size:${ROOT_INDEX_FONT_SIZE} !important}`;
77
+
78
+ /**
79
+ * Size of the parallel sign (`\parallel`, tagged `data-pie-macro="parallel"` by
80
+ * `PIE_MACROS`). The legacy editor enlarged it (`updateSpans` set 32px) because
81
+ * the bars read too small next to the letters of `AB ∥ CD`; MathLive draws it at
82
+ * the surrounding size, so it is scaled up here. Not applied to keypad labels.
45
83
  */
46
- export const supsubStyles = {};
84
+ const PARALLEL_SELECTOR = '[data-pie-macro="parallel"]';
85
+ const PARALLEL_FONT_SIZE = '1.4em';
86
+
87
+ export const parallelStyles = {
88
+ [`& ${PARALLEL_SELECTOR}`]: {
89
+ fontSize: PARALLEL_FONT_SIZE,
90
+ },
91
+ };
92
+
93
+ /** The same rule as a CSS string, for a live mathfield's shadow root. */
94
+ export const PARALLEL_CSS = `${PARALLEL_SELECTOR}{font-size:${PARALLEL_FONT_SIZE}}`;
95
+
96
+ /**
97
+ * Superscript/subscript sizing. MathLive follows TeX metrics for scripts, so the
98
+ * MathQuill-era corrections are gone; the radical index is the one place where a
99
+ * TeX-correct size reads too small in the authoring UI.
100
+ */
101
+ export const supsubStyles = {
102
+ ...rootIndexStyles,
103
+ ...parallelStyles,
104
+ };
47
105
 
48
106
  /** Empty placeholder boxes (answer blocks / open slots). */
49
107
  export const placeholderStyles = {
@@ -57,13 +115,42 @@ export const placeholderStyles = {
57
115
  '& [data-pie-empty]': {
58
116
  opacity: 0.5,
59
117
  },
60
- // Stretchy accents render an <svg>; mathlive-static.css positions it
61
- // absolutely at width:100%. Repeated here so static math cannot blow out its
62
- // container if that stylesheet is missing.
63
- '& svg': {
118
+ // Stretchy accents (\overarc, \overleftrightarrow, \overrightarrow) render an
119
+ // <svg width="100%" preserveAspectRatio="none"> inside a `.ML__stretchy`. That
120
+ // span is absolutely positioned, so its immediate wrapper computes to 0px and
121
+ // the width has to resolve against a *positioned* ancestor. Pin that to
122
+ // `.ML__base`, whose width already equals the accent's content (measured:
123
+ // 9.85px for a 0.56em slot), so the arc spans exactly its content.
124
+ //
125
+ // Do NOT write a bare `& svg` rule instead: MathLive's own rule is
126
+ // descendant-scoped (`.ML__latex .ML__stretchy svg`), and a global one
127
+ // re-parents every svg in the subtree.
128
+ '& .ML__base': {
129
+ position: 'relative',
130
+ },
131
+ '& .ML__stretchy': {
64
132
  position: 'absolute',
133
+ left: 0,
65
134
  width: '100%',
66
135
  },
136
+ // MathLive centres an accent by shifting it right half the content width -
137
+ // `margin-left: 0.28em` for a 0.56em slot - which assumes a glyph with its own
138
+ // side bearings. Over a synthetic empty slot it just pushes the arc right.
139
+ //
140
+ // `!important` is required: MathLive writes this as an INLINE style
141
+ // (`style="top:-3.4em;margin-left:0.28em"`), which a class cannot override.
142
+ //
143
+ // It also fixes the width. `.ML__center` is the stretchy's containing block
144
+ // (MathLive gives `.ML__vlist > span` position:relative), and its width is the
145
+ // base minus that margin - measured 9.85px - 4.93px = 4.93px, exactly half,
146
+ // which is why the arc came out half-width AND offset right.
147
+ //
148
+ // Scoped with `:has(.ML__stretchy)`: only stretchy accents are affected.
149
+ // A single-glyph accent (`\hat{x}`, `\vec{x}`) genuinely needs its centring
150
+ // offset, and must keep it.
151
+ '& .ML__center:has(.ML__stretchy)': {
152
+ marginLeft: '0 !important',
153
+ },
67
154
  };
68
155
 
69
156
  /**
@@ -73,6 +160,7 @@ export const placeholderStyles = {
73
160
  export const commonMqKeyboardStyles = {
74
161
  ...commonMqFontStyles,
75
162
  ...longdivStyles,
163
+ ...rootIndexStyles,
76
164
  ...placeholderStyles,
77
165
  touchAction: 'manipulation',
78
166
  '& math-field': {
@@ -114,6 +202,7 @@ export const commonKeyboardStyles = commonMqKeyboardStyles;
114
202
  export default {
115
203
  commonMqFontStyles,
116
204
  longdivStyles,
205
+ rootIndexStyles,
117
206
  supsubStyles,
118
207
  commonMqKeyboardStyles,
119
208
  placeholderStyles,