@pie-lib/math-input-mathlive 1.1.5-beta.11 → 1.1.5-beta.13

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.
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
 
@@ -235,6 +236,22 @@ const PLACEHOLDER_CARET_CSS = [
235
236
  'animation:ML__caret-blink 1.05s step-end forwards infinite}',
236
237
  ].join('');
237
238
 
239
+ // MathLive sets the bar of `\overline{}` right on top of the content, so over an
240
+ // empty slot it touches the slot's box, while the ray and line arrows (stretchy
241
+ // accents) float clear of it. The bar is raised 0.33em, and the strut that sets
242
+ // the field's top edge is raised with it, or the bar would be clipped.
243
+ const OVERLINE_GAP_CSS =
244
+ '.ML__latex .overline>.ML__vlist-t>.ML__vlist-r>.ML__vlist>span:has(>.overline-line){top:-4.15em !important}' +
245
+ '.ML__latex:has(.overline)>.ML__strut{height:1.23em !important}';
246
+
247
+ // MathLive spaces a fraction's bar for text glyphs, whose ink sits tight against
248
+ // the baseline. An empty slot's box (`▢`) hangs lower than that, so the bar ends
249
+ // up much nearer the denominator's box than the numerator's (measured ~0.28em vs
250
+ // ~0.11em). Dropping the bar 0.08em evens the two gaps. Layout is untouched, so
251
+ // the numerator and denominator keep their positions; filled fractions (`1/2`)
252
+ // only see the bar move a hair.
253
+ const FRAC_BAR_CENTER_CSS = '.ML__latex .ML__frac-line{position:relative;top:0.08em}';
254
+
238
255
  // A live mathfield needs the readable radical index too: `rootIndexStyles` is
239
256
  // written for static markup in the host page, which cannot cross the shadow
240
257
  // boundary.
@@ -242,6 +259,8 @@ const SHADOW_CSS =
242
259
  '.ML__latex .ML__center:has(.ML__stretchy){margin-left:0 !important}' +
243
260
  ROOT_INDEX_CSS +
244
261
  PARALLEL_CSS +
262
+ OVERLINE_GAP_CSS +
263
+ FRAC_BAR_CENTER_CSS +
245
264
  PLACEHOLDER_CARET_CSS;
246
265
 
247
266
  /**
@@ -545,6 +564,9 @@ export function applyStaticMath(element, latex, options = {}) {
545
564
  macros: { ...getMacros(), ...(options.macros || {}) },
546
565
  });
547
566
 
567
+ // MathLive misdraws long division (see longdiv.js).
568
+ fixLongDivisionWhenReady(element);
569
+
548
570
  return element;
549
571
  }
550
572
 
@@ -115,6 +115,25 @@ export const placeholderStyles = {
115
115
  '& [data-pie-empty]': {
116
116
  opacity: 0.5,
117
117
  },
118
+ // A fraction with an empty slot: MathLive spaces the bar for text, but the
119
+ // slot's box is a taller `\rule`, so the bar hugs the numerator's box (~0.2em
120
+ // above vs ~0.4em below). Dropping the bar 0.16em (0.13em when the numerator is not an empty slot or follows an `x`) centres it between the two
121
+ // boxes. Only the bar moves, not the slots.
122
+ '& .ML__mfrac:has([data-pie-empty]) .ML__frac-line': {
123
+ position: 'relative',
124
+ top: '0.16em',
125
+ },
126
+ // A real numerator (`x`) is lighter than an empty box and its ink ends at the
127
+ // baseline, so the full drop overshoots: it needs a smaller drop. The numerator is
128
+ // the second `.ML__center` (MathLive emits the denominator first).
129
+ '& .ML__mfrac:has([data-pie-empty]):not(:has(.ML__center ~ .ML__center [data-pie-empty])) .ML__frac-line': {
130
+ top: '0.13em',
131
+ },
132
+ // Same for a mixed number (`x` then the fraction): the leading `x` is a sibling
133
+ // of the fraction, which a lone `\frac{}{}` does not have.
134
+ '& .ML__mfrac:has([data-pie-empty]):not(:only-child) .ML__frac-line': {
135
+ top: '0.13em',
136
+ },
118
137
  // Stretchy accents (\overarc, \overleftrightarrow, \overrightarrow) render an
119
138
  // <svg width="100%" preserveAspectRatio="none"> inside a `.ML__stretchy`. That
120
139
  // span is absolutely positioned, so its immediate wrapper computes to 0px and
package/src/mf/input.jsx CHANGED
@@ -11,6 +11,7 @@ import {
11
11
  } from '../mathlive-instance';
12
12
  import { toMathLive, fromMathLive, keyToAction, KEYSTROKES } from '../latex-bridge';
13
13
  import { trackWholeAtomSelection } from '../selection';
14
+ import { trackLongDivision } from '../longdiv';
14
15
 
15
16
  const log = debug('pie-lib:math-input-mathlive:input');
16
17
 
@@ -97,6 +98,9 @@ export class Input extends React.Component {
97
98
  // Shift+Arrow would otherwise select half a fraction; snap to whole atoms.
98
99
  this.untrackSelection = trackWholeAtomSelection(this.mathField);
99
100
 
101
+ // MathLive misdraws long division; correct it after every render.
102
+ this.untrackLongDivision = trackLongDivision(this.mathField);
103
+
100
104
  this.mathField.addEventListener('input', this.onInputEdit);
101
105
 
102
106
  // Belt and braces: drop anything a previous mount may have left behind.
@@ -145,6 +149,11 @@ export class Input extends React.Component {
145
149
  this.untrackSelection = undefined;
146
150
  }
147
151
 
152
+ if (this.untrackLongDivision) {
153
+ this.untrackLongDivision();
154
+ this.untrackLongDivision = undefined;
155
+ }
156
+
148
157
  if (this.mathField) {
149
158
  this.mathField.removeEventListener('input', this.onInputEdit);
150
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);
package/src/selection.js CHANGED
@@ -120,12 +120,7 @@ export const trackWholeAtomSelection = (mathField) => {
120
120
  const [start, end] = range;
121
121
  const lo = Math.min(start, end);
122
122
  const hi = Math.max(start, end);
123
- const [nextStart, nextEnd] = expandRange(
124
- lo,
125
- hi,
126
- (i) => mathField.getElementInfo(i),
127
- mathField.lastOffset,
128
- );
123
+ const [nextStart, nextEnd] = expandRange(lo, hi, (i) => mathField.getElementInfo(i), mathField.lastOffset);
129
124
 
130
125
  if (nextStart === lo && nextEnd === hi) {
131
126
  return;