@pie-lib/math-input-mathlive 1.1.5-beta.10 → 1.1.5-beta.12

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.
@@ -0,0 +1,247 @@
1
+ import { expandRange, trackWholeAtomSelection } from '../selection';
2
+
3
+ /**
4
+ * Offset tables captured from MathLive 0.110.0 `getElementInfo(i)` in Chrome,
5
+ * as [depth, latex] per offset. Offset `i` is the atom ending at caret `i`.
6
+ */
7
+ const table = (rows) => (i) => (rows[i] ? { depth: rows[i][0], latex: rows[i][1] } : undefined);
8
+
9
+ // 5x^2+2x+\frac{\pi}{2}-4
10
+ const POLY = table([
11
+ [0, ''],
12
+ [0, '5'],
13
+ [0, 'x'],
14
+ [1, ''],
15
+ [1, '2'],
16
+ [0, '^2'],
17
+ [0, '+'],
18
+ [0, '2'],
19
+ [0, 'x'],
20
+ [0, '+'],
21
+ [1, ''],
22
+ [1, '\\pi'],
23
+ [1, ''],
24
+ [1, '2'],
25
+ [0, '\\frac{\\pi}{2}'],
26
+ [0, '-'],
27
+ [0, '4'],
28
+ ]);
29
+ const POLY_LAST = 16;
30
+
31
+ // a+\sqrt{x+1}+b
32
+ const ROOT = table([
33
+ [0, ''],
34
+ [0, 'a'],
35
+ [0, '+'],
36
+ [1, ''],
37
+ [1, 'x'],
38
+ [1, '+'],
39
+ [1, '1'],
40
+ [0, '\\sqrt{x+1}'],
41
+ [0, '+'],
42
+ [0, 'b'],
43
+ ]);
44
+
45
+ // \frac{\frac{a}{b}}{c}
46
+ const NESTED = table([
47
+ [0, ''],
48
+ [1, ''],
49
+ [2, ''],
50
+ [2, 'a'],
51
+ [2, ''],
52
+ [2, 'b'],
53
+ [1, '\\frac{a}{b}'],
54
+ [1, ''],
55
+ [1, 'c'],
56
+ [0, '\\frac{\\frac{a}{b}}{c}'],
57
+ ]);
58
+
59
+ describe('expandRange', () => {
60
+ const poly = (a, b) => expandRange(a, b, POLY, POLY_LAST);
61
+
62
+ it('leaves a selection of whole sibling atoms alone', () => {
63
+ expect(poly(6, 7)).toEqual([6, 7]); // 2
64
+ expect(poly(6, 9)).toEqual([6, 9]); // 2x+
65
+ expect(poly(6, 15)).toEqual([6, 15]); // 2x+\frac{\pi}{2}-
66
+ expect(poly(0, 16)).toEqual([0, 16]);
67
+ });
68
+
69
+ // The reported case: five Shift+Right presses from before `2x` gave `2x+\pi`.
70
+ it('takes the whole fraction when the selection reaches into it', () => {
71
+ expect(poly(6, 10)).toEqual([6, 14]);
72
+ expect(poly(6, 11)).toEqual([6, 14]);
73
+ expect(poly(6, 13)).toEqual([6, 14]);
74
+ });
75
+
76
+ it('takes the whole fraction when the selection leaves it', () => {
77
+ expect(poly(11, 15)).toEqual([9, 15]); // from the pi to after the minus
78
+ });
79
+
80
+ it('crossing from numerator to denominator selects the fraction', () => {
81
+ expect(poly(11, 13)).toEqual([9, 14]);
82
+ expect(poly(11, 12)).toEqual([9, 14]);
83
+ });
84
+
85
+ it('a selection inside one branch stays inside it', () => {
86
+ expect(poly(10, 11)).toEqual([10, 11]); // \pi
87
+ expect(poly(12, 13)).toEqual([12, 13]); // the 2 of the denominator
88
+ });
89
+
90
+ // MathLive stores x^2 as `x` then a separate `^2` atom.
91
+ it('keeps a superscript with its base', () => {
92
+ expect(poly(3, 7)).toEqual([1, 7]); // x^2+2, not ^2+2
93
+ expect(poly(4, 6)).toEqual([1, 6]); // x^2+
94
+ });
95
+
96
+ it('accepts a reversed range', () => {
97
+ expect(poly(11, 6)).toEqual([6, 14]);
98
+ });
99
+
100
+ it('leaves a collapsed range alone', () => {
101
+ expect(poly(11, 11)).toEqual([11, 11]);
102
+ });
103
+
104
+ it('handles roots the same way', () => {
105
+ expect(expandRange(2, 4, ROOT, 9)).toEqual([2, 7]);
106
+ expect(expandRange(4, 6, ROOT, 9)).toEqual([4, 6]); // +1 inside the radicand
107
+ });
108
+
109
+ it('climbs as many levels as needed in nested fractions', () => {
110
+ expect(expandRange(2, 3, NESTED, 9)).toEqual([2, 3]); // a
111
+ expect(expandRange(3, 5, NESTED, 9)).toEqual([1, 6]); // a/b crossing -> inner fraction
112
+ expect(expandRange(6, 7, NESTED, 9)).toEqual([0, 9]); // inner -> outer denominator
113
+ });
114
+
115
+ it('tolerates missing info', () => {
116
+ expect(expandRange(1, 3, () => undefined, 5)).toEqual([1, 3]);
117
+ });
118
+ });
119
+
120
+ describe('trackWholeAtomSelection', () => {
121
+ const stubField = (range, direction = 'forward') => {
122
+ const listeners = {};
123
+
124
+ return {
125
+ selection: { ranges: [range], direction },
126
+ get selectionIsCollapsed() {
127
+ return this.selection.ranges[0][0] === this.selection.ranges[0][1];
128
+ },
129
+ lastOffset: POLY_LAST,
130
+ getElementInfo: POLY,
131
+ sets: 0,
132
+ addEventListener(type, fn) {
133
+ listeners[type] = fn;
134
+ },
135
+ removeEventListener(type, fn) {
136
+ if (listeners[type] === fn) {
137
+ delete listeners[type];
138
+ }
139
+ },
140
+ fire(type) {
141
+ if (listeners[type]) {
142
+ listeners[type]();
143
+ }
144
+ },
145
+ has(type) {
146
+ return !!listeners[type];
147
+ },
148
+ };
149
+ };
150
+
151
+ const track = (field) => {
152
+ // count assignments without breaking the getter
153
+ let current = field.selection;
154
+
155
+ Object.defineProperty(field, 'selection', {
156
+ get: () => current,
157
+ set: (v) => {
158
+ field.sets += 1;
159
+ current = v;
160
+ },
161
+ });
162
+
163
+ return trackWholeAtomSelection(field);
164
+ };
165
+
166
+ it('widens the selection on selection-change', () => {
167
+ const field = stubField([6, 11]);
168
+
169
+ track(field);
170
+ field.fire('selection-change');
171
+
172
+ expect(field.selection.ranges).toEqual([[6, 14]]);
173
+ });
174
+
175
+ it('preserves the direction, so Shift+Arrow keeps extending the same end', () => {
176
+ const field = stubField([11, 16], 'backward');
177
+
178
+ track(field);
179
+ field.fire('selection-change');
180
+
181
+ expect(field.selection).toEqual({ ranges: [[9, 16]], direction: 'backward' });
182
+ });
183
+
184
+ it('does not touch a selection that is already whole', () => {
185
+ const field = stubField([6, 9]);
186
+
187
+ track(field);
188
+ field.fire('selection-change');
189
+
190
+ expect(field.sets).toBe(0);
191
+ });
192
+
193
+ it('ignores a collapsed caret', () => {
194
+ const field = stubField([11, 11]);
195
+
196
+ track(field);
197
+ field.fire('selection-change');
198
+
199
+ expect(field.sets).toBe(0);
200
+ });
201
+
202
+ it('does not recurse when its own assignment fires selection-change', () => {
203
+ const field = stubField([6, 11]);
204
+ let current = field.selection;
205
+
206
+ Object.defineProperty(field, 'selection', {
207
+ get: () => current,
208
+ set: (v) => {
209
+ field.sets += 1;
210
+ current = v;
211
+ field.fire('selection-change');
212
+ },
213
+ });
214
+
215
+ trackWholeAtomSelection(field);
216
+ field.fire('selection-change');
217
+
218
+ expect(field.sets).toBe(1);
219
+ expect(field.selection.ranges).toEqual([[6, 14]]);
220
+ });
221
+
222
+ it('survives a field that throws', () => {
223
+ const field = stubField([6, 11]);
224
+
225
+ field.getElementInfo = () => {
226
+ throw new Error('boom');
227
+ };
228
+
229
+ track(field);
230
+
231
+ expect(() => field.fire('selection-change')).not.toThrow();
232
+ });
233
+
234
+ it('tears down its listener', () => {
235
+ const field = stubField([6, 11]);
236
+ const untrack = track(field);
237
+
238
+ expect(field.has('selection-change')).toBe(true);
239
+ untrack();
240
+ expect(field.has('selection-change')).toBe(false);
241
+ });
242
+
243
+ it('is a no-op without a field', () => {
244
+ expect(trackWholeAtomSelection(undefined)).toBeUndefined();
245
+ expect(trackWholeAtomSelection({})).toBeUndefined();
246
+ });
247
+ });
@@ -11,6 +11,7 @@ import { baseSet } from '../keys';
11
11
  import { sortKeys } from './keys-layout';
12
12
  import { commonKeyboardStyles, rootIndexStyles } from '../mf/common-styles';
13
13
  import { loadMathLive, latexToMarkup } from '../mathlive-instance';
14
+ import { fixLongDivisionWhenReady } from '../longdiv';
14
15
 
15
16
  const log = debug('pie-lib:math-input-mathlive:keypad');
16
17
 
@@ -81,11 +82,14 @@ export class LatexLabel extends React.Component {
81
82
  constructor(props) {
82
83
  super(props);
83
84
  this.state = { markup: latexToMarkup(props.latex) };
85
+ this.labelRef = React.createRef();
84
86
  }
85
87
 
86
88
  async componentDidMount() {
87
89
  // MathLive may not have finished loading when the keypad first mounts.
88
90
  if (this.state.markup) {
91
+ // The long-division key's label needs MathLive's drawing corrected.
92
+ fixLongDivisionWhenReady(this.labelRef.current);
89
93
  return;
90
94
  }
91
95
 
@@ -111,10 +115,14 @@ export class LatexLabel extends React.Component {
111
115
  }
112
116
  }
113
117
 
114
- componentDidUpdate(prevProps) {
118
+ componentDidUpdate(prevProps, prevState) {
115
119
  if (prevProps.latex !== this.props.latex) {
116
120
  this.setState({ markup: latexToMarkup(this.props.latex) });
117
121
  }
122
+
123
+ if (this.state.markup !== (prevState && prevState.markup)) {
124
+ fixLongDivisionWhenReady(this.labelRef.current);
125
+ }
118
126
  }
119
127
 
120
128
  componentWillUnmount() {
@@ -127,7 +135,11 @@ export class LatexLabel extends React.Component {
127
135
  // Fall back to the raw latex so a button is never blank.
128
136
  return markup ? (
129
137
  // eslint-disable-next-line react/no-danger
130
- <LabelHolder slanted={SLANTED_LABELS.includes(this.props.latex)} dangerouslySetInnerHTML={{ __html: markup }} />
138
+ <LabelHolder
139
+ ref={this.labelRef}
140
+ slanted={SLANTED_LABELS.includes(this.props.latex)}
141
+ dangerouslySetInnerHTML={{ __html: markup }}
142
+ />
131
143
  ) : (
132
144
  <LabelHolder slanted={SLANTED_LABELS.includes(this.props.latex)}>{this.props.latex}</LabelHolder>
133
145
  );
package/src/longdiv.js ADDED
@@ -0,0 +1,172 @@
1
+ import debug from 'debug';
2
+
3
+ const log = debug('pie-lib:math-input-mathlive:longdiv');
4
+
5
+ /**
6
+ * Long division (`\enclose{longdiv}{...}`, which `\longdiv` is mapped to).
7
+ *
8
+ * MathLive 0.110 draws it wrong in three ways, all in `EncloseAtom.render`:
9
+ *
10
+ * 1. It lowers the dividend by `padding + clearance` (0.45em) with
11
+ * `position: relative; top`, which moves it off the baseline, so the divisor
12
+ * looks too high.
13
+ * 2. The notation box's CSS width leaves out the room it made for the `)`,
14
+ * while the SVG's viewBox includes it. The SVG is stretched to 100% of the
15
+ * box, so the whole drawing is scaled down (to about 86%).
16
+ * 3. The bar is drawn `base.width` long from the `)`, but the dividend itself
17
+ * starts after the `)` and its gap, so the bar stops short of the end of
18
+ * the dividend. `base.width` also leaves out the spacing MathLive puts
19
+ * around `,` and `+`, so even the right length would come up short.
20
+ *
21
+ * This reads back the numbers MathLive wrote into inline styles and the SVG,
22
+ * and corrects them, so the rest of MathLive's drawing (the arc, the stroke
23
+ * weight) is kept. The bar's end is measured from the rendered dividend when
24
+ * the markup is laid out, and computed from MathLive's numbers otherwise.
25
+ *
26
+ * Every value set here is absolute, so running it again is harmless - it has
27
+ * to run after every render of a live field anyway.
28
+ */
29
+
30
+ /** A length MathLive wrote as `prop:<n>em` in an inline style, or 0. */
31
+ const inlineEm = (style, prop) => {
32
+ const match = new RegExp(`(?:^|;)\\s*${prop}:\\s*(-?[\\d.]+)em`).exec(style || '');
33
+
34
+ return match ? parseFloat(match[1]) : 0;
35
+ };
36
+
37
+ /**
38
+ * Direct child with a given tag. Not `querySelector(':scope > tag')`: jsdom's
39
+ * selector engine does not support `:scope` inside SVG elements.
40
+ */
41
+ const childTag = (el, tag) => el && Array.from(el.children || []).find((c) => c.tagName.toLowerCase() === tag);
42
+
43
+ /** Right edge of what is actually drawn inside an element, ignoring the caret. */
44
+ const drawnRight = (el) => {
45
+ let right = -Infinity;
46
+
47
+ el.querySelectorAll('*').forEach((child) => {
48
+ if (child.children.length || (child.classList && child.classList.contains('ML__caret'))) {
49
+ return;
50
+ }
51
+
52
+ const rect = child.getBoundingClientRect();
53
+
54
+ if (rect.width > 0) {
55
+ right = Math.max(right, rect.right);
56
+ }
57
+ });
58
+
59
+ return right;
60
+ };
61
+
62
+ /**
63
+ * Correct every long division under `root`.
64
+ *
65
+ * @param {ParentNode} root a mathfield's shadow root, or an element holding
66
+ * static MathLive markup
67
+ * @returns {number} how many were corrected
68
+ */
69
+ export const fixLongDivision = (root) => {
70
+ if (!root || typeof root.querySelectorAll !== 'function') {
71
+ return 0;
72
+ }
73
+
74
+ let fixed = 0;
75
+
76
+ root.querySelectorAll('.ML__notation').forEach((notation) => {
77
+ try {
78
+ const svg = childTag(notation, 'svg');
79
+ const line = childTag(svg, 'line');
80
+
81
+ // Long division is the only notation MathLive draws with an arc (<path>).
82
+ if (!line || !childTag(svg, 'path')) {
83
+ return;
84
+ }
85
+
86
+ const dividend = notation.nextElementSibling;
87
+ const style = dividend && dividend.getAttribute('style');
88
+ const width = inlineEm(style, 'width');
89
+ const gap = inlineEm(style, 'margin-left');
90
+ const drop = inlineEm(style, 'top');
91
+ const viewBox = (svg.getAttribute('viewBox') || '').trim().split(/\s+/).map(Number);
92
+ const start = parseFloat(line.getAttribute('x1'));
93
+
94
+ if (!width || viewBox.length !== 4 || Number.isNaN(start)) {
95
+ return;
96
+ }
97
+
98
+ // 2. size the box to the viewBox, so the drawing is not scaled down
99
+ notation.style.width = `calc(${viewBox[2]}em + 1px)`;
100
+
101
+ // 1. put the dividend back on the baseline. Growing the box upwards by the
102
+ // same amount keeps the drawing where it was relative to the dividend, and
103
+ // makes the line tall enough that the field does not clip the bar.
104
+ if (drop && notation.parentElement) {
105
+ dividend.style.top = '0';
106
+ notation.parentElement.style.paddingTop = `${drop}em`;
107
+ }
108
+
109
+ // 3. run the bar from the ")" to the end of the dividend
110
+ let end = start + gap + width;
111
+ const svgRect = svg.getBoundingClientRect();
112
+ const right = drawnRight(dividend);
113
+
114
+ if (svgRect.width > 0 && Number.isFinite(right)) {
115
+ end = (right - svgRect.left) * (viewBox[2] / svgRect.width);
116
+ }
117
+
118
+ line.setAttribute('x2', String(end));
119
+ fixed += 1;
120
+ } catch (e) {
121
+ log('could not correct a long division: %s', e && e.message);
122
+ }
123
+ });
124
+
125
+ return fixed;
126
+ };
127
+
128
+ /**
129
+ * Correct static markup now, and again once web fonts have loaded: the bar's
130
+ * end is measured, and glyph widths change when the math fonts arrive.
131
+ *
132
+ * @param {Element} element
133
+ */
134
+ export const fixLongDivisionWhenReady = (element) => {
135
+ fixLongDivision(element);
136
+
137
+ if (typeof document !== 'undefined' && document.fonts && document.fonts.ready) {
138
+ document.fonts.ready.then(() => fixLongDivision(element)).catch(() => {});
139
+ }
140
+ };
141
+
142
+ /**
143
+ * Keep a live mathfield's long divisions corrected. MathLive rebuilds its shadow
144
+ * DOM on every render (each keystroke, a resize, the fonts loading), which
145
+ * discards the corrections, so they are reapplied whenever it does.
146
+ *
147
+ * @param {HTMLElement} mathField a MathLive `<math-field>`
148
+ * @returns {function|undefined} teardown, to call on unmount
149
+ */
150
+ export const trackLongDivision = (mathField) => {
151
+ const root = mathField && mathField.shadowRoot;
152
+
153
+ if (!root) {
154
+ return undefined;
155
+ }
156
+
157
+ const run = () => fixLongDivision(root);
158
+
159
+ run();
160
+
161
+ if (typeof MutationObserver === 'undefined') {
162
+ return () => {};
163
+ }
164
+
165
+ // Only childList: the corrections themselves change attributes, so they do
166
+ // not retrigger this.
167
+ const observer = new MutationObserver(run);
168
+
169
+ observer.observe(root, { childList: true, subtree: true });
170
+
171
+ return () => observer.disconnect();
172
+ };
@@ -1,6 +1,7 @@
1
1
  import debug from 'debug';
2
2
  import { NEWLINE_EMBED, toMathLive, withVisibleEmptySlots } from './latex-bridge';
3
3
  import { ROOT_INDEX_CSS, PARALLEL_CSS } from './mf/common-styles';
4
+ import { fixLongDivisionWhenReady } from './longdiv';
4
5
 
5
6
  const log = debug('pie-lib:math-input-mathlive:instance');
6
7
 
@@ -545,6 +546,9 @@ export function applyStaticMath(element, latex, options = {}) {
545
546
  macros: { ...getMacros(), ...(options.macros || {}) },
546
547
  });
547
548
 
549
+ // MathLive misdraws long division (see longdiv.js).
550
+ fixLongDivisionWhenReady(element);
551
+
548
552
  return element;
549
553
  }
550
554
 
package/src/mf/input.jsx CHANGED
@@ -10,6 +10,8 @@ import {
10
10
  trackPlaceholderCaret,
11
11
  } from '../mathlive-instance';
12
12
  import { toMathLive, fromMathLive, keyToAction, KEYSTROKES } from '../latex-bridge';
13
+ import { trackWholeAtomSelection } from '../selection';
14
+ import { trackLongDivision } from '../longdiv';
13
15
 
14
16
  const log = debug('pie-lib:math-input-mathlive:input');
15
17
 
@@ -93,6 +95,12 @@ export class Input extends React.Component {
93
95
  // A selected `\placeholder{}` gets no MathLive caret; draw one.
94
96
  this.untrackCaret = trackPlaceholderCaret(this.mathField);
95
97
 
98
+ // Shift+Arrow would otherwise select half a fraction; snap to whole atoms.
99
+ this.untrackSelection = trackWholeAtomSelection(this.mathField);
100
+
101
+ // MathLive misdraws long division; correct it after every render.
102
+ this.untrackLongDivision = trackLongDivision(this.mathField);
103
+
96
104
  this.mathField.addEventListener('input', this.onInputEdit);
97
105
 
98
106
  // Belt and braces: drop anything a previous mount may have left behind.
@@ -136,6 +144,16 @@ export class Input extends React.Component {
136
144
  this.untrackCaret = undefined;
137
145
  }
138
146
 
147
+ if (this.untrackSelection) {
148
+ this.untrackSelection();
149
+ this.untrackSelection = undefined;
150
+ }
151
+
152
+ if (this.untrackLongDivision) {
153
+ this.untrackLongDivision();
154
+ this.untrackLongDivision = undefined;
155
+ }
156
+
139
157
  if (this.mathField) {
140
158
  this.mathField.removeEventListener('input', this.onInputEdit);
141
159
  this.mathField.remove();
package/src/mf/static.jsx CHANGED
@@ -10,6 +10,7 @@ import {
10
10
  applyShadowStyles,
11
11
  trackPlaceholderCaret,
12
12
  } from '../mathlive-instance';
13
+ import { trackLongDivision, fixLongDivisionWhenReady } from '../longdiv';
13
14
  import { toMathLive, fromMathLive, fieldIds } from '../latex-bridge';
14
15
  import { placeholderStyles, rootIndexStyles, parallelStyles } from './common-styles';
15
16
 
@@ -114,6 +115,9 @@ export default class Static extends React.Component {
114
115
  // blocks are typed into with the same keypad, so they grow placeholders too.
115
116
  this.untrackCaret = trackPlaceholderCaret(this.mathField);
116
117
 
118
+ // MathLive misdraws long division; correct it after every render.
119
+ this.untrackLongDivision = trackLongDivision(this.mathField);
120
+
117
121
  this.mathField.addEventListener('input', this.onPromptInput);
118
122
  this.mathField.addEventListener('focusin', this.onPromptFocus);
119
123
  this.mathField.addEventListener('keydown', this.onKeyDown);
@@ -124,7 +128,12 @@ export default class Static extends React.Component {
124
128
  this.createLiveRegion();
125
129
  }
126
130
 
127
- componentDidUpdate(prevProps) {
131
+ componentDidUpdate(prevProps, prevState) {
132
+ // Display mode: new markup was just written into the DOM.
133
+ if (!this.mathField && this.state.markup !== (prevState && prevState.markup)) {
134
+ fixLongDivisionWhenReady(this.holderRef.current);
135
+ }
136
+
128
137
  if (prevProps.latex === this.props.latex) {
129
138
  return;
130
139
  }
@@ -145,6 +154,11 @@ export default class Static extends React.Component {
145
154
  this.untrackCaret = undefined;
146
155
  }
147
156
 
157
+ if (this.untrackLongDivision) {
158
+ this.untrackLongDivision();
159
+ this.untrackLongDivision = undefined;
160
+ }
161
+
148
162
  if (this.mathField) {
149
163
  this.mathField.removeEventListener('input', this.onPromptInput);
150
164
  this.mathField.removeEventListener('focusin', this.onPromptFocus);