@pie-lib/math-input-mathlive 1.1.3 → 1.1.5-beta.1

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 (43) hide show
  1. package/README.md +14 -5
  2. package/lib/fonts/embedded.js +120 -0
  3. package/lib/fonts/index.js +81 -0
  4. package/lib/fonts/index.js.map +1 -0
  5. package/lib/keypad/index.js +15 -17
  6. package/lib/keypad/index.js.map +1 -1
  7. package/lib/keys/geometry.js +7 -4
  8. package/lib/keys/geometry.js.map +1 -1
  9. package/lib/keys/misc.js +9 -2
  10. package/lib/keys/misc.js.map +1 -1
  11. package/lib/latex-bridge.js +48 -3
  12. package/lib/latex-bridge.js.map +1 -1
  13. package/lib/mathlive-instance.js +186 -15
  14. package/lib/mathlive-instance.js.map +1 -1
  15. package/lib/mf/common-styles.js +74 -10
  16. package/lib/mf/common-styles.js.map +1 -1
  17. package/lib/mf/index.js +6 -0
  18. package/lib/mf/index.js.map +1 -1
  19. package/lib/mf/input.js +29 -5
  20. package/lib/mf/input.js.map +1 -1
  21. package/lib/mf/static.js +105 -7
  22. package/lib/mf/static.js.map +1 -1
  23. package/package.json +3 -4
  24. package/scripts/generate-embedded-fonts.js +94 -0
  25. package/src/fonts/embedded.js +116 -0
  26. package/src/fonts/index.js +71 -0
  27. package/src/keypad/index.jsx +15 -14
  28. package/src/keys/geometry.js +7 -2
  29. package/src/keys/misc.js +8 -2
  30. package/src/latex-bridge.js +52 -3
  31. package/src/mathlive-instance.js +201 -14
  32. package/src/mf/common-styles.js +78 -8
  33. package/src/mf/index.js +2 -0
  34. package/src/mf/input.jsx +37 -5
  35. package/src/mf/static.jsx +121 -6
  36. package/CHANGELOG.md +0 -26
  37. package/LICENSE.md +0 -5
  38. package/src/__tests__/drop-in-compat.test.jsx +0 -112
  39. package/src/__tests__/index.test.jsx +0 -109
  40. package/src/__tests__/input-imperative.test.jsx +0 -144
  41. package/src/__tests__/keypad-label.test.jsx +0 -57
  42. package/src/__tests__/latex-bridge.test.js +0 -230
  43. package/src/__tests__/mathlive-instance.test.js +0 -181
@@ -0,0 +1,71 @@
1
+ import debug from 'debug';
2
+
3
+ import { EMBEDDED_FONTS, MATHLIVE_VERSION } from './embedded';
4
+
5
+ const log = debug('pie-lib:math-input-mathlive:fonts');
6
+
7
+ let registered = false;
8
+
9
+ /** The families MathLive needs; derived from the generated table. */
10
+ export const EMBEDDED_FONT_FAMILIES = Array.from(new Set(EMBEDDED_FONTS.map((f) => f.family)));
11
+
12
+ export { MATHLIVE_VERSION };
13
+
14
+ /**
15
+ * Put the embedded KaTeX faces into `document.fonts`.
16
+ *
17
+ * MathLive's `loadFonts()` short-circuits to "ready" as soon as every family it
18
+ * needs is already in `document.fonts`, so registering here means it never
19
+ * requests a woff2 over the network. That matters because MathLive composes some
20
+ * glyphs from Private Use Area codepoints that exist only in these fonts - `\ne`
21
+ * is U+E020 overlapped on `=` - so a blocked or missing font file does not
22
+ * degrade to a plainer glyph, it renders tofu. Hosts that ship JS-only bundles
23
+ * (pie-elements) have no stylesheet to carry `@font-face` and no asset path to
24
+ * serve, and a CSP that omits the CDN left exactly that broken.
25
+ *
26
+ * Skipped when the document already has every family - a host that serves the
27
+ * fonts itself, via `configureFonts` or by importing `mathlive/fonts.css`, wins.
28
+ *
29
+ * Idempotent, browser-only, and never throws: a font problem must not take the
30
+ * editor down.
31
+ *
32
+ * @returns {boolean} whether the faces are available in the document
33
+ */
34
+ export const registerEmbeddedFonts = () => {
35
+ if (registered) {
36
+ return true;
37
+ }
38
+
39
+ if (typeof document === 'undefined' || !document.fonts || typeof FontFace === 'undefined') {
40
+ return false;
41
+ }
42
+
43
+ try {
44
+ const present = new Set(Array.from(document.fonts).map((f) => f.family));
45
+
46
+ if (EMBEDDED_FONT_FAMILIES.every((family) => present.has(family))) {
47
+ log('the document already provides every KaTeX family - leaving it alone');
48
+ registered = true;
49
+
50
+ return true;
51
+ }
52
+
53
+ EMBEDDED_FONTS.forEach(({ family, src, descriptors }) => {
54
+ document.fonts.add(new FontFace(family, src, descriptors));
55
+ });
56
+
57
+ log('registered %d embedded KaTeX faces (mathlive@%s)', EMBEDDED_FONTS.length, MATHLIVE_VERSION);
58
+ registered = true;
59
+
60
+ return true;
61
+ } catch (e) {
62
+ log('could not register the embedded fonts: %s', e && e.message);
63
+
64
+ return false;
65
+ }
66
+ };
67
+
68
+ /** Test seam: forget that the faces were registered. */
69
+ export const resetEmbeddedFontsForTests = () => {
70
+ registered = false;
71
+ };
@@ -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');
@@ -29,22 +29,20 @@ const LabelHolder = styled('span')(() => ({
29
29
  pointerEvents: 'none',
30
30
  textTransform: 'none',
31
31
  color: color.text(),
32
- display: 'inline-flex',
33
- alignItems: 'center',
34
- justifyContent: 'center',
32
+ // Deliberately inline-block, NOT inline-flex: MathLive's markup relies on
33
+ // normal inline flow. Making its spans flex items collapses their width, and
34
+ // stretchy accents render an <svg width="100%" preserveAspectRatio="none">
35
+ // that then squeezes into a spike instead of spanning the content.
36
+ display: 'inline-block',
37
+ textAlign: 'center',
35
38
  lineHeight: 1,
36
- // Never let a label size its grid column. Stretchy accents
37
- // (\overrightarrow, \overleftrightarrow, \overarc) render an <svg> that
38
- // mathlive-static.css positions absolutely at width:100%; if that stylesheet
39
- // is missing the svg lays out statically and expands without bound, dragging
40
- // the whole column across the screen. `overflow: hidden` also collapses the
41
- // label's min-content contribution, so the grid can't blow out either way.
39
+ // Keep a label from sizing its grid column. `overflow: hidden` also collapses
40
+ // the label's min-content contribution, so a wide accent cannot drag the
41
+ // column across the screen. The svg's own positioning is left to
42
+ // mathlive-static.css (injected by the package) - duplicating it here broke
43
+ // the accent's containing block.
42
44
  maxWidth: '100%',
43
45
  overflow: 'hidden',
44
- '& svg': {
45
- position: 'absolute',
46
- width: '100%',
47
- },
48
46
  // Answer-block / empty-slot boxes inside a label.
49
47
  '& .ML__placeholder': {
50
48
  backgroundColor: color.keypadEmptyPlaceholder ? color.keypadEmptyPlaceholder() : undefined,
@@ -55,6 +53,9 @@ const LabelHolder = styled('span')(() => ({
55
53
  '& [data-pie-empty]': {
56
54
  opacity: 0.5,
57
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,
58
59
  }));
59
60
 
60
61
  export class LatexLabel extends React.Component {
@@ -14,7 +14,10 @@ export const overRightArrow = set({
14
14
 
15
15
  export const overLeftRightArrow = set({
16
16
  name: 'Segment',
17
- latex: '\\overleftrightarrow{\\overline{}}', // used this notation to display the pink box
17
+ // MathQuill needed a nested \overline{} to draw the empty box; MathLive
18
+ // renders that as a real line, so the label showed a spurious bar under the
19
+ // arrows. `withVisibleEmptySlots` supplies the box for `{}` instead.
20
+ latex: '\\overleftrightarrow{}',
18
21
  symbol: 'AB',
19
22
  command: '\\overleftrightarrow',
20
23
  });
@@ -51,7 +54,9 @@ export const angle = set({
51
54
  });
52
55
  export const overArc = set({
53
56
  name: 'Over arc',
54
- latex: '\\overarc{\\overline{}}', // used this notation to display the pink box
57
+ // See the note on Segment above: the nested \overline{} drew a straight line
58
+ // instead of leaving the arc visible.
59
+ latex: '\\overarc{}',
55
60
  command: '\\overarc',
56
61
  });
57
62
  export const measureOfAngle = set({
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({
@@ -55,11 +55,19 @@ 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;
64
72
 
65
73
  /**
@@ -106,6 +114,40 @@ export const toNativeCommands = (latex) => {
106
114
  out = replaceAbs(out);
107
115
  out = out.replace(/\\longdiv\{/g, '\\enclose{longdiv}{');
108
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
+ }
109
151
 
110
152
  return out;
111
153
  };
@@ -240,8 +282,15 @@ export const fromMathLive = (latex) => {
240
282
  * placeholders in the inserted latex.
241
283
  */
242
284
  const COMMANDS_WITH_ARGS = {
285
+ // An explicit "blank over blank" key: both slots start empty.
243
286
  '\\frac': '\\frac{#?}{#?}',
244
- '/': '\\frac{#?}{#?}',
287
+ // Division, which must behave like MathQuill's `/`: whatever precedes the
288
+ // cursor becomes the numerator and the caret lands in the denominator. `#@`
289
+ // is MathLive's token for "the selection, or the item before the cursor" -
290
+ // this is exactly the template MathLive binds to the `/` key itself. Using
291
+ // `#?` here instead produced an empty fraction and left the typed number
292
+ // stranded outside it.
293
+ '/': '\\frac{#@}{#?}',
245
294
  '\\sqrt': '\\sqrt{#?}',
246
295
  '\\nthroot': '\\sqrt[#?]{#?}',
247
296
  '\\overline': '\\overline{#?}',
@@ -1,5 +1,7 @@
1
1
  import debug from 'debug';
2
2
  import { NEWLINE_EMBED, toMathLive, withVisibleEmptySlots } from './latex-bridge';
3
+ import { registerEmbeddedFonts } from './fonts';
4
+ import { ROOT_INDEX_CSS } from './mf/common-styles';
3
5
 
4
6
  const log = debug('pie-lib:math-input-mathlive:instance');
5
7
 
@@ -46,26 +48,66 @@ const absoluteFontsDirectory = (dir) => {
46
48
  }
47
49
  };
48
50
 
51
+ /**
52
+ * Last-resort CDN for the KaTeX fonts.
53
+ *
54
+ * Some symbols are composed from Private Use Area glyphs that exist only in
55
+ * those fonts: `\\neq` is U+E020 (a negation slash) overlapped on `=`, and
56
+ * `\\nsim` / `\\ncong` work the same way. With no fonts the browser substitutes
57
+ * an arbitrary glyph for U+E020, so those keys render as garbage rather than
58
+ * simply falling back to a plainer face.
59
+ *
60
+ * The fonts normally come from inside this package (see `./fonts`), so this URL
61
+ * is only reached on a browser without the FontFace API, where MathLive has to
62
+ * fetch the woff2 files itself. The version comes from MathLive, so the fonts
63
+ * always match the engine.
64
+ */
65
+ const FALLBACK_MATHLIVE_VERSION = '0.110.0';
66
+
67
+ const cdnFontsDirectory = () => {
68
+ const version = (mathlive && mathlive.version && mathlive.version.mathlive) || FALLBACK_MATHLIVE_VERSION;
69
+
70
+ // NOTE: `/fonts`, not `/dist/fonts` - the published package root holds them.
71
+ return `https://unpkg.com/mathlive@${version}/fonts`;
72
+ };
73
+
49
74
  /**
50
75
  * Tell the package where the MathLive fonts are served from.
51
76
  *
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.
77
+ * Optional: by default the fonts come from inside this package (see `./fonts`),
78
+ * so nothing has to be served or configured. Call this before `loadMathLive()`
79
+ * to point MathLive at your own copy instead - worth doing if the host already
80
+ * serves the KaTeX woff2 files and would rather not carry them twice.
55
81
  *
56
- * The fonts ship inside the `mathlive` package (`node_modules/mathlive/fonts`)
57
- * and must be copied into whatever the host app serves statically.
82
+ * - a path/URL: served by you; the embedded fonts are not registered
83
+ * - `null`: skip font loading entirely; note `\\neq`, `\\nsim` and `\\ncong`
84
+ * then render incorrectly, being built from Private Use Area
85
+ * glyphs that only these fonts have
86
+ * - not called: the embedded fonts, with the CDN as a fallback for browsers
87
+ * without the FontFace API
58
88
  *
59
- * @param {string|null} dir
89
+ * @param {string|null} [dir]
60
90
  */
61
91
  export const configureFonts = (dir) => {
62
92
  fontsDirectory = dir;
63
93
 
64
94
  if (mathlive) {
65
- mathlive.MathfieldElement.fontsDirectory = absoluteFontsDirectory(dir);
95
+ mathlive.MathfieldElement.fontsDirectory = dir === undefined ? cdnFontsDirectory() : absoluteFontsDirectory(dir);
66
96
  }
67
97
  };
68
98
 
99
+ /**
100
+ * What the spacebar inserts in math mode.
101
+ *
102
+ * MathLive's `mathModeSpace` defaults to an empty string, which makes the
103
+ * spacebar a no-op - users cannot type a space at all. `\\ ` (a LaTeX control
104
+ * space) is what MathQuill serialised spaces as, so existing content and the
105
+ * MathJax renderer in @pie-lib/math-rendering both already understand it.
106
+ *
107
+ * A literal " " is not usable: LaTeX skips raw whitespace.
108
+ */
109
+ export const MATH_MODE_SPACE = '\\ ';
110
+
69
111
  /**
70
112
  * Custom macros for the pie-specific commands that the MathQuill fork
71
113
  * understood but stock LaTeX does not.
@@ -125,22 +167,167 @@ let loadWarned = false;
125
167
 
126
168
  /** Apply post-load configuration exactly once. */
127
169
  const configureLoaded = () => {
128
- // 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.
131
- if (fontsDirectory === undefined) {
170
+ // No host path configured: use the fonts that ship inside this package. Once
171
+ // they are in `document.fonts`, MathLive's own loader short-circuits and never
172
+ // requests a woff2 - so math renders offline and under a CSP that allows no
173
+ // font origin at all.
174
+ if (fontsDirectory === undefined && !registerEmbeddedFonts()) {
132
175
  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.',
176
+ 'embedded fonts unavailable (no FontFace API) - falling back to the CDN (%s). ' +
177
+ 'Serve the fonts yourself and call configureFonts("/path/to/fonts") to avoid the network request.',
178
+ cdnFontsDirectory(),
135
179
  );
136
180
  }
137
181
 
182
+ // Always replace MathLive's relative `'./fonts/'` default: it throws
183
+ // "Invalid base URL" when bundled. The host's copy if one was configured,
184
+ // otherwise the CDN, which is now only reached if the embedded fonts could not
185
+ // be registered.
138
186
  mathlive.MathfieldElement.fontsDirectory =
139
- fontsDirectory === undefined ? null : absoluteFontsDirectory(fontsDirectory);
187
+ fontsDirectory === undefined ? cdnFontsDirectory() : absoluteFontsDirectory(fontsDirectory);
140
188
 
141
189
  injectCoreStylesheet();
142
190
  };
143
191
 
192
+ /**
193
+ * CSS that has to live *inside* a mathfield's shadow root.
194
+ *
195
+ * A live `<math-field>` renders in shadow DOM (`attachShadow({mode:'open'})`),
196
+ * so nothing in the host page's stylesheets reaches it. The stretchy-accent
197
+ * problem therefore needs fixing twice: once for static markup (see
198
+ * `placeholderStyles`) and once here for the editor.
199
+ *
200
+ * MathLive centres an accent with `margin-left: <half the content width>` as an
201
+ * inline style. For a stretchy accent that also shrinks its containing block
202
+ * (`.ML__center` is position:relative), so the arc ends up half-width and offset
203
+ * - visibly failing to span the text underneath.
204
+ *
205
+ * `:has(.ML__stretchy)` keeps the fix to stretchy accents; single-glyph accents
206
+ * (`\hat{x}`, `\vec{x}`) still need their centring offset.
207
+ */
208
+ /**
209
+ * Marker put on the host `<math-field>` while a `\placeholder{}` is selected,
210
+ * so the caret rules below apply to a placeholder and nothing else.
211
+ */
212
+ export const PLACEHOLDER_CARET_CLASS = 'pie-caret-in-placeholder';
213
+
214
+ /** What MathLive serialises a `#?` slot as. */
215
+ const PLACEHOLDER_LATEX = '\\placeholder{}';
216
+
217
+ /**
218
+ * A blinking caret inside a selected placeholder box.
219
+ *
220
+ * A keypad key such as `\frac` inserts `\frac{#?}{#?}`. MathLive turns each
221
+ * `#?` into a `\placeholder{}` atom and *selects* the first one. That atom is a
222
+ * leaf - the offsets around it are "before" and "after", there is no position
223
+ * inside it - so MathLive renders **no caret element at all** and shows only
224
+ * the selection highlight. The box therefore looked focused (blue) but dead,
225
+ * where MathQuill used to blink a cursor inside its empty box.
226
+ *
227
+ * So the caret is drawn here. `:not(:has(.ML__selected))` picks the innermost
228
+ * selected element (MathLive puts the class on a wrapper *and* the glyph span,
229
+ * which would otherwise give two carets), and the `ML__caret-blink` keyframes
230
+ * plus `--_caret-color` are MathLive's own, already in this shadow root.
231
+ *
232
+ * Typing still replaces the whole placeholder, which for an empty box is
233
+ * indistinguishable from inserting at the caret.
234
+ */
235
+ const PLACEHOLDER_CARET_CSS = [
236
+ `:host(.${PLACEHOLDER_CARET_CLASS}) .ML__selected:not(:has(.ML__selected)){position:relative}`,
237
+ `:host(.${PLACEHOLDER_CARET_CLASS}) .ML__selected:not(:has(.ML__selected))::after{`,
238
+ 'content:"";position:absolute;left:50%;top:12%;height:76%;',
239
+ 'border-right:2px solid var(--_caret-color, currentColor);',
240
+ 'animation:ML__caret-blink 1.05s step-end forwards infinite}',
241
+ ].join('');
242
+
243
+ // A live mathfield needs the readable radical index too: `rootIndexStyles` is
244
+ // written for static markup in the host page, which cannot cross the shadow
245
+ // boundary.
246
+ const SHADOW_CSS =
247
+ '.ML__latex .ML__center:has(.ML__stretchy){margin-left:0 !important}' + ROOT_INDEX_CSS + PLACEHOLDER_CARET_CSS;
248
+
249
+ /**
250
+ * Keep {@link PLACEHOLDER_CARET_CLASS} in step with the selection.
251
+ *
252
+ * Gating on the class is what keeps the caret specific to placeholders: an
253
+ * ordinary selection (`x+1`) must not sprout one. MathLive fires
254
+ * `selection-change` for every caret move, including the one that lands on the
255
+ * placeholder straight after an insert.
256
+ *
257
+ * @param {HTMLElement} mathField
258
+ * @returns {function|undefined} teardown, to call on unmount
259
+ */
260
+ export const trackPlaceholderCaret = (mathField) => {
261
+ if (!mathField || typeof mathField.addEventListener !== 'function') {
262
+ return undefined;
263
+ }
264
+
265
+ const update = () => {
266
+ let selected = '';
267
+
268
+ try {
269
+ // A collapsed selection already has a real MathLive caret.
270
+ if (!mathField.selectionIsCollapsed && mathField.selection) {
271
+ selected = mathField.getValue(mathField.selection, 'latex');
272
+ }
273
+ } catch (e) {
274
+ log('could not read the selection: %s', e && e.message);
275
+ }
276
+
277
+ if (mathField.classList) {
278
+ mathField.classList.toggle(PLACEHOLDER_CARET_CLASS, selected === PLACEHOLDER_LATEX);
279
+ }
280
+ };
281
+
282
+ mathField.addEventListener('selection-change', update);
283
+ update();
284
+
285
+ return () => mathField.removeEventListener('selection-change', update);
286
+ };
287
+
288
+ /**
289
+ * Apply {@link SHADOW_CSS} inside a mathfield's shadow root.
290
+ *
291
+ * Uses `adoptedStyleSheets` when available (which is how MathLive loads its own
292
+ * stylesheets) and falls back to appending a <style> node.
293
+ *
294
+ * @param {HTMLElement} mathField
295
+ */
296
+ export const applyShadowStyles = (mathField) => {
297
+ const root = mathField && mathField.shadowRoot;
298
+
299
+ if (!root) {
300
+ return;
301
+ }
302
+
303
+ try {
304
+ if ('adoptedStyleSheets' in root && typeof CSSStyleSheet !== 'undefined') {
305
+ // Do not re-add on a re-render.
306
+ if (root.__pieAccentSheet) {
307
+ return;
308
+ }
309
+
310
+ const sheet = new CSSStyleSheet();
311
+
312
+ sheet.replaceSync(SHADOW_CSS);
313
+ root.adoptedStyleSheets = [...root.adoptedStyleSheets, sheet];
314
+ root.__pieAccentSheet = sheet;
315
+
316
+ return;
317
+ }
318
+
319
+ if (!root.querySelector('style[data-pie-accent]')) {
320
+ const style = document.createElement('style');
321
+
322
+ style.setAttribute('data-pie-accent', '');
323
+ style.textContent = SHADOW_CSS;
324
+ root.appendChild(style);
325
+ }
326
+ } catch (e) {
327
+ log('could not apply shadow styles to the mathfield: %s', e && e.message);
328
+ }
329
+ };
330
+
144
331
  /**
145
332
  * Ensure MathLive's "core" stylesheet is in the document.
146
333
  *
@@ -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,47 @@ 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.
45
61
  */
46
- export const supsubStyles = {};
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
+ * Superscript/subscript sizing. MathLive follows TeX metrics for scripts, so the
80
+ * MathQuill-era corrections are gone; the radical index is the one place where a
81
+ * TeX-correct size reads too small in the authoring UI.
82
+ */
83
+ export const supsubStyles = {
84
+ ...rootIndexStyles,
85
+ };
47
86
 
48
87
  /** Empty placeholder boxes (answer blocks / open slots). */
49
88
  export const placeholderStyles = {
@@ -57,13 +96,42 @@ export const placeholderStyles = {
57
96
  '& [data-pie-empty]': {
58
97
  opacity: 0.5,
59
98
  },
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': {
99
+ // Stretchy accents (\overarc, \overleftrightarrow, \overrightarrow) render an
100
+ // <svg width="100%" preserveAspectRatio="none"> inside a `.ML__stretchy`. That
101
+ // span is absolutely positioned, so its immediate wrapper computes to 0px and
102
+ // the width has to resolve against a *positioned* ancestor. Pin that to
103
+ // `.ML__base`, whose width already equals the accent's content (measured:
104
+ // 9.85px for a 0.56em slot), so the arc spans exactly its content.
105
+ //
106
+ // Do NOT write a bare `& svg` rule instead: MathLive's own rule is
107
+ // descendant-scoped (`.ML__latex .ML__stretchy svg`), and a global one
108
+ // re-parents every svg in the subtree.
109
+ '& .ML__base': {
110
+ position: 'relative',
111
+ },
112
+ '& .ML__stretchy': {
64
113
  position: 'absolute',
114
+ left: 0,
65
115
  width: '100%',
66
116
  },
117
+ // MathLive centres an accent by shifting it right half the content width -
118
+ // `margin-left: 0.28em` for a 0.56em slot - which assumes a glyph with its own
119
+ // side bearings. Over a synthetic empty slot it just pushes the arc right.
120
+ //
121
+ // `!important` is required: MathLive writes this as an INLINE style
122
+ // (`style="top:-3.4em;margin-left:0.28em"`), which a class cannot override.
123
+ //
124
+ // It also fixes the width. `.ML__center` is the stretchy's containing block
125
+ // (MathLive gives `.ML__vlist > span` position:relative), and its width is the
126
+ // base minus that margin - measured 9.85px - 4.93px = 4.93px, exactly half,
127
+ // which is why the arc came out half-width AND offset right.
128
+ //
129
+ // Scoped with `:has(.ML__stretchy)`: only stretchy accents are affected.
130
+ // A single-glyph accent (`\hat{x}`, `\vec{x}`) genuinely needs its centring
131
+ // offset, and must keep it.
132
+ '& .ML__center:has(.ML__stretchy)': {
133
+ marginLeft: '0 !important',
134
+ },
67
135
  };
68
136
 
69
137
  /**
@@ -73,6 +141,7 @@ export const placeholderStyles = {
73
141
  export const commonMqKeyboardStyles = {
74
142
  ...commonMqFontStyles,
75
143
  ...longdivStyles,
144
+ ...rootIndexStyles,
76
145
  ...placeholderStyles,
77
146
  touchAction: 'manipulation',
78
147
  '& math-field': {
@@ -114,6 +183,7 @@ export const commonKeyboardStyles = commonMqKeyboardStyles;
114
183
  export default {
115
184
  commonMqFontStyles,
116
185
  longdivStyles,
186
+ rootIndexStyles,
117
187
  supsubStyles,
118
188
  commonMqKeyboardStyles,
119
189
  placeholderStyles,
package/src/mf/index.js CHANGED
@@ -6,6 +6,7 @@ import CommonMqStyles, {
6
6
  commonMqFontStyles,
7
7
  commonMqKeyboardStyles,
8
8
  longdivStyles,
9
+ rootIndexStyles,
9
10
  supsubStyles,
10
11
  placeholderStyles,
11
12
  } from './common-styles';
@@ -21,6 +22,7 @@ export {
21
22
  commonMqFontStyles,
22
23
  commonMqKeyboardStyles,
23
24
  longdivStyles,
25
+ rootIndexStyles,
24
26
  supsubStyles,
25
27
  placeholderStyles,
26
28
  };