@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
package/src/mf/index.js CHANGED
@@ -6,6 +6,8 @@ import CommonMqStyles, {
6
6
  commonMqFontStyles,
7
7
  commonMqKeyboardStyles,
8
8
  longdivStyles,
9
+ rootIndexStyles,
10
+ parallelStyles,
9
11
  supsubStyles,
10
12
  placeholderStyles,
11
13
  } from './common-styles';
@@ -21,6 +23,8 @@ export {
21
23
  commonMqFontStyles,
22
24
  commonMqKeyboardStyles,
23
25
  longdivStyles,
26
+ rootIndexStyles,
27
+ parallelStyles,
24
28
  supsubStyles,
25
29
  placeholderStyles,
26
30
  };
package/src/mf/input.jsx CHANGED
@@ -2,7 +2,13 @@ import React from 'react';
2
2
  import PropTypes from 'prop-types';
3
3
  import { styled } from '@mui/material/styles';
4
4
  import debug from 'debug';
5
- import { loadMathLive, getMacros } from '../mathlive-instance';
5
+ import {
6
+ loadMathLive,
7
+ getMacros,
8
+ MATH_MODE_SPACE,
9
+ applyShadowStyles,
10
+ trackPlaceholderCaret,
11
+ } from '../mathlive-instance';
6
12
  import { toMathLive, fromMathLive, keyToAction, KEYSTROKES } from '../latex-bridge';
7
13
 
8
14
  const log = debug('pie-lib:math-input-mathlive:input');
@@ -49,9 +55,16 @@ export class Input extends React.Component {
49
55
  }
50
56
 
51
57
  async componentDidMount() {
58
+ // MathLive loads asynchronously, so this method resumes after an await and
59
+ // has to know whether it is still the current mount. React StrictMode calls
60
+ // mount -> unmount -> mount on the SAME instance in dev, so a boolean
61
+ // "unmounted" flag would latch and block the remount from ever creating a
62
+ // field. A generation counter identifies the live mount instead: only the
63
+ // newest one proceeds, and a real unmount invalidates all pending ones.
64
+ const generation = (this.mountGeneration = (this.mountGeneration || 0) + 1);
52
65
  const ml = await loadMathLive();
53
66
 
54
- if (!ml || !this.holderRef.current) {
67
+ if (generation !== this.mountGeneration || !ml || !this.holderRef.current || this.mathField) {
55
68
  return;
56
69
  }
57
70
 
@@ -64,15 +77,30 @@ export class Input extends React.Component {
64
77
 
65
78
  // pie drives input through its own keypad, so suppress MathLive's.
66
79
  this.mathField.mathVirtualKeyboardPolicy = 'manual';
80
+ // Without this the spacebar does nothing: MathLive's mathModeSpace
81
+ // defaults to an empty string.
82
+ this.mathField.mathModeSpace = MATH_MODE_SPACE;
83
+ // After the exponent key the cursor must stay in the exponent until it is moved
84
+ // MathLive's `smartSuperscript` (on by
85
+ // default) jumps out of an empty superscript after a single digit, which breaks
86
+ // typing x^23 or 10^6 and is inconsistent with non-digit exponents.
87
+ this.mathField.smartSuperscript = false;
88
+
89
+ // The mathfield renders in shadow DOM, so page CSS cannot reach it - the
90
+ // stretchy-accent fix has to be injected there directly.
91
+ applyShadowStyles(this.mathField);
92
+
93
+ // A selected `\placeholder{}` gets no MathLive caret; draw one.
94
+ this.untrackCaret = trackPlaceholderCaret(this.mathField);
67
95
 
68
96
  this.mathField.addEventListener('input', this.onInputEdit);
69
- this.holderRef.current.appendChild(this.mathField);
97
+
98
+ // Belt and braces: drop anything a previous mount may have left behind.
99
+ this.holderRef.current.replaceChildren(this.mathField);
70
100
 
71
101
  if (this.props.innerRef) {
72
102
  this.props.innerRef(this);
73
103
  }
74
-
75
- this.setState?.({});
76
104
  }
77
105
 
78
106
  componentDidUpdate(prevProps) {
@@ -99,6 +127,15 @@ export class Input extends React.Component {
99
127
  }
100
128
 
101
129
  componentWillUnmount() {
130
+ // Invalidate any mount still awaiting the MathLive load, so it does not
131
+ // append a field into a holder that is going away.
132
+ this.mountGeneration = (this.mountGeneration || 0) + 1;
133
+
134
+ if (this.untrackCaret) {
135
+ this.untrackCaret();
136
+ this.untrackCaret = undefined;
137
+ }
138
+
102
139
  if (this.mathField) {
103
140
  this.mathField.removeEventListener('input', this.onInputEdit);
104
141
  this.mathField.remove();
package/src/mf/static.jsx CHANGED
@@ -2,9 +2,16 @@ import React from 'react';
2
2
  import PropTypes from 'prop-types';
3
3
  import { styled } from '@mui/material/styles';
4
4
  import debug from 'debug';
5
- import { loadMathLive, getMacros, latexToMarkup } from '../mathlive-instance';
5
+ import {
6
+ loadMathLive,
7
+ getMacros,
8
+ latexToMarkup,
9
+ MATH_MODE_SPACE,
10
+ applyShadowStyles,
11
+ trackPlaceholderCaret,
12
+ } from '../mathlive-instance';
6
13
  import { toMathLive, fromMathLive, fieldIds } from '../latex-bridge';
7
- import { placeholderStyles } from './common-styles';
14
+ import { placeholderStyles, rootIndexStyles, parallelStyles } from './common-styles';
8
15
 
9
16
  const log = debug('pie-lib:math-input-mathlive:static');
10
17
 
@@ -12,6 +19,10 @@ const Holder = styled('span')({
12
19
  display: 'inline-block',
13
20
  // Shared static-math rules: muted empty-slot glyphs and svg containment.
14
21
  ...placeholderStyles,
22
+ // Readable radical index - display mode renders in the light DOM, so the
23
+ // shadow-root copy of this rule does not reach it.
24
+ ...rootIndexStyles,
25
+ ...parallelStyles,
15
26
  '& math-field': {
16
27
  display: 'inline-block',
17
28
  border: 'none',
@@ -64,28 +75,51 @@ export default class Static extends React.Component {
64
75
  }
65
76
 
66
77
  async componentDidMount() {
78
+ // See the note in mf/input.jsx: a generation counter, not a boolean, so a
79
+ // StrictMode mount -> unmount -> mount on the same instance still ends up
80
+ // with exactly one field.
81
+ const generation = (this.mountGeneration = (this.mountGeneration || 0) + 1);
82
+
67
83
  if (!this.isInteractive()) {
68
84
  await loadMathLive();
69
- this.renderMarkup();
85
+
86
+ if (generation === this.mountGeneration) {
87
+ this.renderMarkup();
88
+ }
89
+
70
90
  return;
71
91
  }
72
92
 
73
93
  const ml = await loadMathLive();
74
94
 
75
- if (!ml || !this.holderRef.current) {
95
+ if (generation !== this.mountGeneration || !ml || !this.holderRef.current || this.mathField) {
76
96
  return;
77
97
  }
78
98
 
79
99
  this.mathField = new ml.MathfieldElement({ macros: getMacros() });
80
100
  this.mathField.readOnly = true;
81
101
  this.mathField.mathVirtualKeyboardPolicy = 'manual';
102
+ // Without this the spacebar does nothing: MathLive's mathModeSpace
103
+ // defaults to an empty string.
104
+ this.mathField.mathModeSpace = MATH_MODE_SPACE;
105
+ // Same as the editor: stay in the exponent after a digit (see mf/input.jsx).
106
+ this.mathField.smartSuperscript = false;
82
107
  this.mathField.value = toMathLive(this.props.latex);
83
108
 
109
+ // The mathfield renders in shadow DOM, so page CSS cannot reach it - the
110
+ // stretchy-accent fix has to be injected there directly.
111
+ applyShadowStyles(this.mathField);
112
+
113
+ // A selected `\placeholder{}` gets no MathLive caret; draw one. Answer
114
+ // blocks are typed into with the same keypad, so they grow placeholders too.
115
+ this.untrackCaret = trackPlaceholderCaret(this.mathField);
116
+
84
117
  this.mathField.addEventListener('input', this.onPromptInput);
85
118
  this.mathField.addEventListener('focusin', this.onPromptFocus);
86
119
  this.mathField.addEventListener('keydown', this.onKeyDown);
87
120
 
88
- this.holderRef.current.appendChild(this.mathField);
121
+ // Belt and braces: drop anything a previous mount may have left behind.
122
+ this.holderRef.current.replaceChildren(this.mathField);
89
123
 
90
124
  this.createLiveRegion();
91
125
  }
@@ -103,6 +137,14 @@ export default class Static extends React.Component {
103
137
  }
104
138
 
105
139
  componentWillUnmount() {
140
+ // Invalidate any mount still awaiting the MathLive load.
141
+ this.mountGeneration = (this.mountGeneration || 0) + 1;
142
+
143
+ if (this.untrackCaret) {
144
+ this.untrackCaret();
145
+ this.untrackCaret = undefined;
146
+ }
147
+
106
148
  if (this.mathField) {
107
149
  this.mathField.removeEventListener('input', this.onPromptInput);
108
150
  this.mathField.removeEventListener('focusin', this.onPromptFocus);
@@ -234,6 +276,13 @@ export default class Static extends React.Component {
234
276
  promptHandle(id) {
235
277
  return {
236
278
  id,
279
+ // MathQuill's inner fields could be focused directly; keep that.
280
+ focus: () => {
281
+ if (this.mathField) {
282
+ this.mathField.focus();
283
+ this.focusPrompt(id);
284
+ }
285
+ },
237
286
  latex: (value) => {
238
287
  if (!this.mathField) {
239
288
  return '';
@@ -331,8 +380,77 @@ export default class Static extends React.Component {
331
380
  }
332
381
  };
333
382
 
383
+ /** The id of the prompt whose range contains `position`, if any. */
384
+ promptContaining(position) {
385
+ if (!this.mathField || typeof position !== 'number') {
386
+ return undefined;
387
+ }
388
+
389
+ return (this.mathField.getPrompts() || []).find((id) => {
390
+ const range = this.mathField.getPromptRange(id);
391
+
392
+ return range && position >= range[0] && position <= range[1];
393
+ });
394
+ }
395
+
396
+ /**
397
+ * Put a blinking caret inside an answer block.
398
+ *
399
+ * MathLive only makes the caret visible while the field element carries the
400
+ * `ML__focused` class, and it adds that class during a *render*, gated on
401
+ * `isSelectionEditable && hasFocus()`. In a read-only field only prompt
402
+ * interiors are editable, so the two things both have to be true:
403
+ *
404
+ * - the selection must sit inside a prompt, and
405
+ * - a render must run afterwards.
406
+ *
407
+ * Assigning `selection` satisfies the first but not the second - it updates
408
+ * the model without re-rendering, so the class stays off and the caret span
409
+ * remains `visibility: hidden`. Executing any command does trigger the
410
+ * render, and `scrollIntoView` is the one with no other effect: it keeps the
411
+ * caret on screen, which the preceding `focus()` already does anyway.
412
+ *
413
+ * @param {string} [id] prompt to focus; defaults to the first one
414
+ */
415
+ focusPrompt(id) {
416
+ if (!this.mathField) {
417
+ return;
418
+ }
419
+
420
+ const ids = this.mathField.getPrompts() || [];
421
+ const target = id && ids.indexOf(id) !== -1 ? id : ids[0];
422
+
423
+ if (!target) {
424
+ return;
425
+ }
426
+
427
+ const range = this.mathField.getPromptRange(target);
428
+
429
+ if (!range) {
430
+ return;
431
+ }
432
+
433
+ this.mathField.selection = { ranges: [[range[0], range[0]]] };
434
+
435
+ if (typeof this.mathField.executeCommand === 'function') {
436
+ this.mathField.executeCommand('scrollIntoView');
437
+ }
438
+ }
439
+
334
440
  focus() {
335
- this.mathField && this.mathField.focus();
441
+ if (!this.mathField) {
442
+ return;
443
+ }
444
+
445
+ this.mathField.focus();
446
+
447
+ // A bare focus() leaves the selection outside every prompt, which in a
448
+ // read-only field is not editable - the block looked focused but had no
449
+ // cursor. Clicking a prompt already works, because MathLive's own pointer
450
+ // handling moves the selection and re-renders.
451
+ if (!this.promptContaining(this.mathField.position)) {
452
+ this.focusPrompt();
453
+ }
336
454
  }
337
455
 
338
456
  blur() {
package/CHANGELOG.md DELETED
@@ -1,30 +0,0 @@
1
- # Change Log
2
-
3
- All notable changes to this project will be documented in this file.
4
- See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
5
-
6
- ## [1.1.4](https://github.com/pie-framework/pie-lib/compare/@pie-lib/math-input-mathlive@1.1.3...@pie-lib/math-input-mathlive@1.1.4) (2026-09-22)
7
-
8
- **Note:** Version bump only for package @pie-lib/math-input-mathlive
9
-
10
- ## [1.1.3](https://github.com/pie-framework/pie-lib/compare/@pie-lib/math-input-mathlive@1.1.2...@pie-lib/math-input-mathlive@1.1.3) (2026-09-02)
11
-
12
- **Note:** Version bump only for package @pie-lib/math-input-mathlive
13
-
14
- ## [1.1.2](https://github.com/pie-framework/pie-lib/compare/@pie-lib/math-input-mathlive@1.1.1...@pie-lib/math-input-mathlive@1.1.2) (2026-08-26)
15
-
16
- **Note:** Version bump only for package @pie-lib/math-input-mathlive
17
-
18
- ## [1.1.1](https://github.com/pie-framework/pie-lib/compare/@pie-lib/math-input-mathlive@1.1.0...@pie-lib/math-input-mathlive@1.1.1) (2026-08-21)
19
-
20
- **Note:** Version bump only for package @pie-lib/math-input-mathlive
21
-
22
- # 1.1.0 (2026-08-13)
23
-
24
- ### Bug Fixes
25
-
26
- - bump math-input-mathlive version to allow canary workflow to generate next on develop ([1ae0088](https://github.com/pie-framework/pie-lib/commit/1ae0088e3b8985aa65933d6a35d94f609cfb9420))
27
-
28
- ### Features
29
-
30
- - implemented math-input-mathlive [PIE-158] ([0d0aa07](https://github.com/pie-framework/pie-lib/commit/0d0aa07cd946b758cb280add754615690542eca3))
package/LICENSE.md DELETED
@@ -1,5 +0,0 @@
1
- Copyright 2019 CoreSpring Inc
2
-
3
- Permission to use, copy, modify, and/or distribute this software for any purpose with or without fee is hereby granted, provided that the above copyright notice and this permission notice appear in all copies.
4
-
5
- THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.