@pie-lib/math-input-mathlive 1.0.9-next.6637 → 1.0.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 (51) hide show
  1. package/README.md +14 -5
  2. package/lib/__tests__/composites.test.js +255 -0
  3. package/lib/__tests__/composites.test.js.map +1 -0
  4. package/lib/__tests__/drop-in-compat.test.js +133 -0
  5. package/lib/__tests__/drop-in-compat.test.js.map +1 -0
  6. package/lib/__tests__/index.test.js +118 -0
  7. package/lib/__tests__/index.test.js.map +1 -0
  8. package/lib/__tests__/input-imperative.test.js +155 -0
  9. package/lib/__tests__/input-imperative.test.js.map +1 -0
  10. package/lib/__tests__/keypad-label.test.js +118 -0
  11. package/lib/__tests__/keypad-label.test.js.map +1 -0
  12. package/lib/__tests__/keypad-render.test.js +206 -0
  13. package/lib/__tests__/keypad-render.test.js.map +1 -0
  14. package/lib/__tests__/latex-bridge.test.js +280 -0
  15. package/lib/__tests__/latex-bridge.test.js.map +1 -0
  16. package/lib/__tests__/mathlive-instance.test.js +279 -0
  17. package/lib/__tests__/mathlive-instance.test.js.map +1 -0
  18. package/lib/__tests__/mount-race.test.js +195 -0
  19. package/lib/__tests__/mount-race.test.js.map +1 -0
  20. package/lib/__tests__/static-prompts.test.js +282 -0
  21. package/lib/__tests__/static-prompts.test.js.map +1 -0
  22. package/lib/keypad/index.js +11 -13
  23. package/lib/keypad/index.js.map +1 -1
  24. package/lib/keys/geometry.js +7 -4
  25. package/lib/keys/geometry.js.map +1 -1
  26. package/lib/latex-bridge.js +18 -3
  27. package/lib/latex-bridge.js.map +1 -1
  28. package/lib/mathlive-instance.js +103 -11
  29. package/lib/mathlive-instance.js.map +1 -1
  30. package/lib/mf/common-styles.js +33 -4
  31. package/lib/mf/common-styles.js.map +1 -1
  32. package/lib/mf/input.js +22 -5
  33. package/lib/mf/input.js.map +1 -1
  34. package/lib/mf/static.js +21 -4
  35. package/lib/mf/static.js.map +1 -1
  36. package/package.json +3 -4
  37. package/src/__tests__/composites.test.jsx +220 -0
  38. package/src/__tests__/keypad-label.test.jsx +81 -14
  39. package/src/__tests__/keypad-render.test.jsx +123 -0
  40. package/src/__tests__/latex-bridge.test.js +21 -0
  41. package/src/__tests__/mathlive-instance.test.js +87 -0
  42. package/src/__tests__/mount-race.test.jsx +130 -0
  43. package/src/__tests__/static-prompts.test.jsx +251 -0
  44. package/src/keypad/index.jsx +11 -13
  45. package/src/keys/geometry.js +7 -2
  46. package/src/latex-bridge.js +18 -3
  47. package/src/mathlive-instance.js +111 -11
  48. package/src/mf/common-styles.js +33 -4
  49. package/src/mf/input.jsx +23 -5
  50. package/src/mf/static.jsx +24 -4
  51. package/LICENSE.md +0 -5
@@ -0,0 +1,130 @@
1
+ /**
2
+ * MathLive loads asynchronously, so `componentDidMount` resumes after an await
3
+ * before appending a <math-field>. Two orderings must both hold:
4
+ *
5
+ * - StrictMode dev double-mount (mount -> unmount -> mount on the SAME
6
+ * instance) must end up with exactly ONE field. A boolean "unmounted" flag
7
+ * latches here and blocks the remount entirely - which is why this uses a
8
+ * generation counter.
9
+ * - A genuine unmount while the load is in flight must append nothing.
10
+ *
11
+ * MathLive cannot load under jsdom, so it is mocked with a minimal stub in
12
+ * order to actually count appended fields.
13
+ */
14
+ jest.mock('../mathlive-instance', () => {
15
+ // Must be a real DOM node: the component uses replaceChildren(), which
16
+ // rejects anything that is not a Node. Returning an element from the
17
+ // constructor replaces `this`, so callers get a genuine <math-field>.
18
+ class FakeMathfieldElement {
19
+ constructor() {
20
+ // `global` rather than `document`: jest forbids out-of-scope variables in
21
+ // a mock factory, and `global` is on its allow-list.
22
+ const el = global.document.createElement('math-field');
23
+
24
+ el.value = '';
25
+ el.getValue = () => el.value;
26
+
27
+ return el;
28
+ }
29
+ }
30
+
31
+ return {
32
+ loadMathLive: () => Promise.resolve({ MathfieldElement: FakeMathfieldElement }),
33
+ getMacros: () => ({}),
34
+ // must mirror the real export, otherwise the component sets `undefined`
35
+ MATH_MODE_SPACE: '\\ ',
36
+ applyShadowStyles: () => {},
37
+ latexToMarkup: (l) => `<span>${l}</span>`,
38
+ };
39
+ });
40
+
41
+ // eslint-disable-next-line import/first
42
+ import Input from '../mf/input';
43
+
44
+ const holderFor = (cmp) => {
45
+ const el = document.createElement('span');
46
+
47
+ cmp.holderRef = { current: el };
48
+
49
+ return el;
50
+ };
51
+
52
+ const fieldCount = (el) => Array.from(el.childNodes).filter((n) => n.tagName === 'MATH-FIELD').length;
53
+
54
+ describe('async mount race', () => {
55
+ it('StrictMode mount -> unmount -> mount yields exactly one field', async () => {
56
+ const input = new Input({});
57
+ const el = holderFor(input);
58
+
59
+ const first = input.componentDidMount();
60
+
61
+ input.componentWillUnmount();
62
+
63
+ const second = input.componentDidMount();
64
+
65
+ await Promise.all([first, second]);
66
+
67
+ expect(fieldCount(el)).toBe(1);
68
+ expect(input.mathField).toBeDefined();
69
+ });
70
+
71
+ it('appends nothing when unmounted while the load is in flight', async () => {
72
+ const input = new Input({});
73
+ const el = holderFor(input);
74
+
75
+ const pending = input.componentDidMount();
76
+
77
+ input.componentWillUnmount();
78
+
79
+ await pending;
80
+
81
+ expect(fieldCount(el)).toBe(0);
82
+ expect(input.mathField).toBeUndefined();
83
+ });
84
+
85
+ it('a normal single mount creates the field', async () => {
86
+ const input = new Input({});
87
+ const el = holderFor(input);
88
+
89
+ await input.componentDidMount();
90
+
91
+ expect(fieldCount(el)).toBe(1);
92
+ });
93
+
94
+ it('repeated mounts never accumulate fields', async () => {
95
+ const input = new Input({});
96
+ const el = holderFor(input);
97
+
98
+ await input.componentDidMount();
99
+ await input.componentDidMount();
100
+ await input.componentDidMount();
101
+
102
+ expect(fieldCount(el)).toBe(1);
103
+ });
104
+
105
+ // MathLive's mathModeSpace defaults to '', which makes the spacebar a no-op.
106
+ it('configures the spacebar so a space can be typed', async () => {
107
+ const input = new Input({});
108
+
109
+ holderFor(input);
110
+ await input.componentDidMount();
111
+
112
+ // a LaTeX control space - what MathQuill produced, and MathJax understands
113
+ expect(input.mathField.mathModeSpace).toEqual('\\ ');
114
+ expect(input.mathField.mathModeSpace).not.toEqual('');
115
+ });
116
+
117
+ it('reports itself through innerRef on mount and clears it on unmount', async () => {
118
+ const innerRef = jest.fn();
119
+ const input = new Input({ innerRef });
120
+
121
+ holderFor(input);
122
+ await input.componentDidMount();
123
+
124
+ expect(innerRef).toHaveBeenCalledWith(input);
125
+
126
+ input.componentWillUnmount();
127
+
128
+ expect(innerRef).toHaveBeenLastCalledWith(null);
129
+ });
130
+ });
@@ -0,0 +1,251 @@
1
+ import Static from '../mf/static';
2
+
3
+ /**
4
+ * The answer-block contract: `\MathQuillMathField[id]{}` in stored latex becomes
5
+ * a MathLive prompt, and sub-field values/focus are surfaced through the same
6
+ * callbacks @pie-lib/math-input exposed (`onSubFieldChange`, `onSubFieldFocus`,
7
+ * `getFieldName`, `setInput`).
8
+ *
9
+ * Driven against a stub mathfield: a real <math-field> needs a browser, and what
10
+ * matters here is the mapping, not MathLive's rendering.
11
+ */
12
+ const stubField = (values = {}, selection = null) => ({
13
+ values: { ...values },
14
+ getPrompts() {
15
+ return Object.keys(this.values);
16
+ },
17
+ getPromptValue(id) {
18
+ return this.values[id];
19
+ },
20
+ setPromptValue(id, v) {
21
+ this.values[id] = v;
22
+ },
23
+ getPromptRange(id) {
24
+ const i = Object.keys(this.values).indexOf(id);
25
+
26
+ return [i * 10, i * 10 + 5];
27
+ },
28
+ selection,
29
+ addEventListener() {},
30
+ removeEventListener() {},
31
+ focus() {
32
+ this.focused = true;
33
+ },
34
+ blur() {
35
+ this.blurred = true;
36
+ },
37
+ remove() {},
38
+ });
39
+
40
+ const make = (props = {}) => new Static({ latex: 'x=\\MathQuillMathField[r1]{}', getFieldName: () => {}, ...props });
41
+
42
+ describe('Static: answer blocks / prompts', () => {
43
+ describe('isInteractive', () => {
44
+ it('is interactive when the latex has answer blocks', () => {
45
+ expect(make({ latex: 'x=\\MathQuillMathField[r1]{}' }).isInteractive()).toBe(true);
46
+ expect(make({ latex: 'x=\\placeholder[r1]{}' }).isInteractive()).toBe(true);
47
+ });
48
+
49
+ it('is display-only for plain latex', () => {
50
+ expect(make({ latex: '\\frac{1}{2}' }).isInteractive()).toBe(false);
51
+ expect(make({ latex: '' }).isInteractive()).toBe(false);
52
+ });
53
+ });
54
+
55
+ describe('values', () => {
56
+ it('returns {} before the field exists', () => {
57
+ expect(make().values()).toEqual({});
58
+ });
59
+
60
+ it('maps each prompt id to its stored-form latex', () => {
61
+ const s = make();
62
+
63
+ s.mathField = stubField({ r1: '7', r2: '\\placeholder{}' });
64
+
65
+ // \placeholder must not leak into stored values
66
+ expect(s.values()).toEqual({ r1: '7', r2: '' });
67
+ });
68
+ });
69
+
70
+ describe('promptHandle / innerFields', () => {
71
+ it('exposes a MathQuill-field-like handle with get/set latex', () => {
72
+ const s = make();
73
+
74
+ s.mathField = stubField({ r1: '7' });
75
+
76
+ const h = s.promptHandle('r1');
77
+
78
+ expect(h.id).toEqual('r1');
79
+ expect(h.latex()).toEqual('7');
80
+
81
+ h.latex('9');
82
+ expect(s.mathField.values.r1).toEqual('9');
83
+ });
84
+
85
+ it('handle latex() is safe with no field', () => {
86
+ expect(make().promptHandle('r1').latex()).toEqual('');
87
+ });
88
+
89
+ it('innerFields returns one handle per prompt', () => {
90
+ const s = make();
91
+
92
+ s.mathField = stubField({ r1: '1', r2: '2' });
93
+
94
+ expect(s.innerFields.map((f) => f.id)).toEqual(['r1', 'r2']);
95
+ expect(make().innerFields).toEqual([]);
96
+ });
97
+ });
98
+
99
+ describe('onSubFieldChange', () => {
100
+ it('fires only for prompts whose value changed', () => {
101
+ const onSubFieldChange = jest.fn();
102
+ const s = make({ onSubFieldChange });
103
+
104
+ s.mathField = stubField({ r1: '1', r2: '2' });
105
+ s.promptValues = { r1: '1', r2: '2' };
106
+
107
+ s.mathField.values.r2 = '5';
108
+ s.onPromptInput();
109
+
110
+ expect(onSubFieldChange).toHaveBeenCalledTimes(1);
111
+ expect(onSubFieldChange).toHaveBeenCalledWith('r2', '5');
112
+ });
113
+
114
+ it('uses getFieldName when it returns a name', () => {
115
+ const onSubFieldChange = jest.fn();
116
+ const s = make({ onSubFieldChange, getFieldName: (handle) => 'named-' + handle.id });
117
+
118
+ s.mathField = stubField({ r1: '1' });
119
+ s.promptValues = {};
120
+
121
+ s.onPromptInput();
122
+
123
+ expect(onSubFieldChange).toHaveBeenCalledWith('named-r1', '1');
124
+ });
125
+
126
+ it('still tracks values when no callback is supplied', () => {
127
+ const s = make();
128
+
129
+ s.mathField = stubField({ r1: '3' });
130
+ s.onPromptInput();
131
+
132
+ expect(s.promptValues).toEqual({ r1: '3' });
133
+ });
134
+ });
135
+
136
+ describe('onSubFieldFocus', () => {
137
+ it('reports the prompt containing the selection, plus setInput', () => {
138
+ const onSubFieldFocus = jest.fn();
139
+ const setInput = jest.fn();
140
+ const s = make({ onSubFieldFocus, setInput });
141
+
142
+ // r2 occupies offsets 10..15
143
+ s.mathField = stubField({ r1: '', r2: '' }, { ranges: [[11, 11]] });
144
+ s.onPromptFocus();
145
+
146
+ expect(onSubFieldFocus).toHaveBeenCalledTimes(1);
147
+ expect(onSubFieldFocus.mock.calls[0][0]).toEqual('r2');
148
+ expect(onSubFieldFocus.mock.calls[0][1].id).toEqual('r2');
149
+ expect(setInput).toHaveBeenCalled();
150
+ });
151
+
152
+ it('does nothing without a selection', () => {
153
+ const onSubFieldFocus = jest.fn();
154
+ const s = make({ onSubFieldFocus });
155
+
156
+ s.mathField = stubField({ r1: '' }, null);
157
+ s.onPromptFocus();
158
+
159
+ expect(onSubFieldFocus).not.toHaveBeenCalled();
160
+ });
161
+
162
+ it('does not throw when the field is missing', () => {
163
+ const s = make({ onSubFieldFocus: jest.fn() });
164
+
165
+ expect(() => s.onPromptFocus()).not.toThrow();
166
+ });
167
+ });
168
+
169
+ describe('accessibility announcements', () => {
170
+ afterEach(() => {
171
+ document.body.innerHTML = '';
172
+ });
173
+
174
+ it('creates a polite off-screen live region', () => {
175
+ const s = make();
176
+
177
+ s.createLiveRegion();
178
+
179
+ expect(s.liveRegion.getAttribute('aria-live')).toEqual('polite');
180
+ expect(s.liveRegion.getAttribute('aria-atomic')).toEqual('true');
181
+ expect(document.body.contains(s.liveRegion)).toBe(true);
182
+
183
+ s.removeLiveRegion();
184
+ expect(s.liveRegion).toBeUndefined();
185
+ });
186
+
187
+ it('does not create a second region', () => {
188
+ const s = make();
189
+
190
+ s.createLiveRegion();
191
+ const first = s.liveRegion;
192
+
193
+ s.createLiveRegion();
194
+ expect(s.liveRegion).toBe(first);
195
+ s.removeLiveRegion();
196
+ });
197
+
198
+ it('announces when typed input expands into a command', () => {
199
+ const s = make();
200
+
201
+ s.createLiveRegion();
202
+ s.promptValues = { r1: 'x' };
203
+ // grew by more than one char AND gained a backslash => a conversion
204
+ s.announceConversion({ r1: 'x\\frac{}{}' });
205
+
206
+ expect(s.liveRegion.textContent).toEqual('Converted to math symbol');
207
+ s.removeLiveRegion();
208
+ });
209
+
210
+ it('does not announce ordinary typing', () => {
211
+ const s = make();
212
+
213
+ s.createLiveRegion();
214
+ s.promptValues = { r1: 'x' };
215
+ s.announceConversion({ r1: 'xy' });
216
+
217
+ expect(s.liveRegion.textContent).toEqual('');
218
+ s.removeLiveRegion();
219
+ });
220
+
221
+ it('does not announce a deletion', () => {
222
+ const s = make();
223
+
224
+ s.createLiveRegion();
225
+ s.promptValues = { r1: 'x\\frac{}{}' };
226
+ s.onKeyDown({ key: 'Backspace' });
227
+ s.announceConversion({ r1: 'x' });
228
+
229
+ expect(s.liveRegion.textContent).toEqual('');
230
+ s.removeLiveRegion();
231
+ });
232
+ });
233
+
234
+ describe('focus / blur', () => {
235
+ it('delegates to the field and is safe without one', () => {
236
+ const s = make();
237
+
238
+ expect(() => {
239
+ s.focus();
240
+ s.blur();
241
+ }).not.toThrow();
242
+
243
+ s.mathField = stubField();
244
+ s.focus();
245
+ s.blur();
246
+
247
+ expect(s.mathField.focused).toBe(true);
248
+ expect(s.mathField.blurred).toBe(true);
249
+ });
250
+ });
251
+ });
@@ -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,
@@ -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({
@@ -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
  /**
@@ -240,8 +248,15 @@ export const fromMathLive = (latex) => {
240
248
  * placeholders in the inserted latex.
241
249
  */
242
250
  const COMMANDS_WITH_ARGS = {
251
+ // An explicit "blank over blank" key: both slots start empty.
243
252
  '\\frac': '\\frac{#?}{#?}',
244
- '/': '\\frac{#?}{#?}',
253
+ // Division, which must behave like MathQuill's `/`: whatever precedes the
254
+ // cursor becomes the numerator and the caret lands in the denominator. `#@`
255
+ // is MathLive's token for "the selection, or the item before the cursor" -
256
+ // this is exactly the template MathLive binds to the `/` key itself. Using
257
+ // `#?` here instead produced an empty fraction and left the typed number
258
+ // stranded outside it.
259
+ '/': '\\frac{#@}{#?}',
245
260
  '\\sqrt': '\\sqrt{#?}',
246
261
  '\\nthroot': '\\sqrt[#?]{#?}',
247
262
  '\\overline': '\\overline{#?}',
@@ -46,26 +46,63 @@ const absoluteFontsDirectory = (dir) => {
46
46
  }
47
47
  };
48
48
 
49
+ /**
50
+ * Version-pinned CDN fallback for the KaTeX fonts.
51
+ *
52
+ * Some symbols are composed from Private Use Area glyphs that exist only in
53
+ * those fonts: `\\neq` is U+E020 (a negation slash) overlapped on `=`, and
54
+ * `\\nsim` / `\\ncong` work the same way. With no fonts the browser substitutes
55
+ * an arbitrary glyph for U+E020, so those keys render as garbage rather than
56
+ * simply falling back to a plainer face.
57
+ *
58
+ * The version comes from MathLive itself, so the fonts always match the engine.
59
+ * Hosts that would rather not depend on a third party should serve the fonts and
60
+ * call `configureFonts('/their/path')`, or pass `null` to opt out.
61
+ */
62
+ const FALLBACK_MATHLIVE_VERSION = '0.110.0';
63
+
64
+ const cdnFontsDirectory = () => {
65
+ const version = (mathlive && mathlive.version && mathlive.version.mathlive) || FALLBACK_MATHLIVE_VERSION;
66
+
67
+ // NOTE: `/fonts`, not `/dist/fonts` - the published package root holds them.
68
+ return `https://unpkg.com/mathlive@${version}/fonts`;
69
+ };
70
+
49
71
  /**
50
72
  * Tell the package where the MathLive fonts are served from.
51
73
  *
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.
74
+ * Call before `loadMathLive()`.
75
+ *
76
+ * - a path/URL: served by you, no third-party request
77
+ * - `null`: skip font loading; note `\\neq`, `\\nsim` and `\\ncong` then
78
+ * render incorrectly, being built from Private Use Area glyphs
79
+ * - not called: falls back to a version-pinned CDN
55
80
  *
56
81
  * The fonts ship inside the `mathlive` package (`node_modules/mathlive/fonts`)
57
- * and must be copied into whatever the host app serves statically.
82
+ * ready to copy into whatever the host app serves statically.
58
83
  *
59
- * @param {string|null} dir
84
+ * @param {string|null} [dir]
60
85
  */
61
86
  export const configureFonts = (dir) => {
62
87
  fontsDirectory = dir;
63
88
 
64
89
  if (mathlive) {
65
- mathlive.MathfieldElement.fontsDirectory = absoluteFontsDirectory(dir);
90
+ mathlive.MathfieldElement.fontsDirectory = dir === undefined ? cdnFontsDirectory() : absoluteFontsDirectory(dir);
66
91
  }
67
92
  };
68
93
 
94
+ /**
95
+ * What the spacebar inserts in math mode.
96
+ *
97
+ * MathLive's `mathModeSpace` defaults to an empty string, which makes the
98
+ * spacebar a no-op - users cannot type a space at all. `\\ ` (a LaTeX control
99
+ * space) is what MathQuill serialised spaces as, so existing content and the
100
+ * MathJax renderer in @pie-lib/math-rendering both already understand it.
101
+ *
102
+ * A literal " " is not usable: LaTeX skips raw whitespace.
103
+ */
104
+ export const MATH_MODE_SPACE = '\\ ';
105
+
69
106
  /**
70
107
  * Custom macros for the pie-specific commands that the MathQuill fork
71
108
  * understood but stock LaTeX does not.
@@ -126,21 +163,84 @@ let loadWarned = false;
126
163
  /** Apply post-load configuration exactly once. */
127
164
  const configureLoaded = () => {
128
165
  // 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.
166
+ // "Invalid base URL" when bundled. Use the host's copy if one was configured,
167
+ // otherwise the version-pinned CDN.
131
168
  if (fontsDirectory === undefined) {
132
169
  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.',
170
+ 'no fontsDirectory configured - falling back to the CDN (%s). ' +
171
+ 'Serve the fonts yourself and call configureFonts("/path/to/fonts") to avoid the network request, ' +
172
+ 'or configureFonts(null) to opt out.',
173
+ cdnFontsDirectory(),
135
174
  );
136
175
  }
137
176
 
138
177
  mathlive.MathfieldElement.fontsDirectory =
139
- fontsDirectory === undefined ? null : absoluteFontsDirectory(fontsDirectory);
178
+ fontsDirectory === undefined ? cdnFontsDirectory() : absoluteFontsDirectory(fontsDirectory);
140
179
 
141
180
  injectCoreStylesheet();
142
181
  };
143
182
 
183
+ /**
184
+ * CSS that has to live *inside* a mathfield's shadow root.
185
+ *
186
+ * A live `<math-field>` renders in shadow DOM (`attachShadow({mode:'open'})`),
187
+ * so nothing in the host page's stylesheets reaches it. The stretchy-accent
188
+ * problem therefore needs fixing twice: once for static markup (see
189
+ * `placeholderStyles`) and once here for the editor.
190
+ *
191
+ * MathLive centres an accent with `margin-left: <half the content width>` as an
192
+ * inline style. For a stretchy accent that also shrinks its containing block
193
+ * (`.ML__center` is position:relative), so the arc ends up half-width and offset
194
+ * - visibly failing to span the text underneath.
195
+ *
196
+ * `:has(.ML__stretchy)` keeps the fix to stretchy accents; single-glyph accents
197
+ * (`\hat{x}`, `\vec{x}`) still need their centring offset.
198
+ */
199
+ const SHADOW_CSS = '.ML__latex .ML__center:has(.ML__stretchy){margin-left:0 !important}';
200
+
201
+ /**
202
+ * Apply {@link SHADOW_CSS} inside a mathfield's shadow root.
203
+ *
204
+ * Uses `adoptedStyleSheets` when available (which is how MathLive loads its own
205
+ * stylesheets) and falls back to appending a <style> node.
206
+ *
207
+ * @param {HTMLElement} mathField
208
+ */
209
+ export const applyShadowStyles = (mathField) => {
210
+ const root = mathField && mathField.shadowRoot;
211
+
212
+ if (!root) {
213
+ return;
214
+ }
215
+
216
+ try {
217
+ if ('adoptedStyleSheets' in root && typeof CSSStyleSheet !== 'undefined') {
218
+ // Do not re-add on a re-render.
219
+ if (root.__pieAccentSheet) {
220
+ return;
221
+ }
222
+
223
+ const sheet = new CSSStyleSheet();
224
+
225
+ sheet.replaceSync(SHADOW_CSS);
226
+ root.adoptedStyleSheets = [...root.adoptedStyleSheets, sheet];
227
+ root.__pieAccentSheet = sheet;
228
+
229
+ return;
230
+ }
231
+
232
+ if (!root.querySelector('style[data-pie-accent]')) {
233
+ const style = document.createElement('style');
234
+
235
+ style.setAttribute('data-pie-accent', '');
236
+ style.textContent = SHADOW_CSS;
237
+ root.appendChild(style);
238
+ }
239
+ } catch (e) {
240
+ log('could not apply shadow styles to the mathfield: %s', e && e.message);
241
+ }
242
+ };
243
+
144
244
  /**
145
245
  * Ensure MathLive's "core" stylesheet is in the document.
146
246
  *
@@ -57,13 +57,42 @@ export const placeholderStyles = {
57
57
  '& [data-pie-empty]': {
58
58
  opacity: 0.5,
59
59
  },
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': {
60
+ // Stretchy accents (\overarc, \overleftrightarrow, \overrightarrow) render an
61
+ // <svg width="100%" preserveAspectRatio="none"> inside a `.ML__stretchy`. That
62
+ // span is absolutely positioned, so its immediate wrapper computes to 0px and
63
+ // the width has to resolve against a *positioned* ancestor. Pin that to
64
+ // `.ML__base`, whose width already equals the accent's content (measured:
65
+ // 9.85px for a 0.56em slot), so the arc spans exactly its content.
66
+ //
67
+ // Do NOT write a bare `& svg` rule instead: MathLive's own rule is
68
+ // descendant-scoped (`.ML__latex .ML__stretchy svg`), and a global one
69
+ // re-parents every svg in the subtree.
70
+ '& .ML__base': {
71
+ position: 'relative',
72
+ },
73
+ '& .ML__stretchy': {
64
74
  position: 'absolute',
75
+ left: 0,
65
76
  width: '100%',
66
77
  },
78
+ // MathLive centres an accent by shifting it right half the content width -
79
+ // `margin-left: 0.28em` for a 0.56em slot - which assumes a glyph with its own
80
+ // side bearings. Over a synthetic empty slot it just pushes the arc right.
81
+ //
82
+ // `!important` is required: MathLive writes this as an INLINE style
83
+ // (`style="top:-3.4em;margin-left:0.28em"`), which a class cannot override.
84
+ //
85
+ // It also fixes the width. `.ML__center` is the stretchy's containing block
86
+ // (MathLive gives `.ML__vlist > span` position:relative), and its width is the
87
+ // base minus that margin - measured 9.85px - 4.93px = 4.93px, exactly half,
88
+ // which is why the arc came out half-width AND offset right.
89
+ //
90
+ // Scoped with `:has(.ML__stretchy)`: only stretchy accents are affected.
91
+ // A single-glyph accent (`\hat{x}`, `\vec{x}`) genuinely needs its centring
92
+ // offset, and must keep it.
93
+ '& .ML__center:has(.ML__stretchy)': {
94
+ marginLeft: '0 !important',
95
+ },
67
96
  };
68
97
 
69
98
  /**