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

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 (55) hide show
  1. package/lib/__tests__/composites.test.js +255 -0
  2. package/lib/__tests__/drop-in-compat.test.js +133 -0
  3. package/lib/__tests__/embedded-fonts.test.js +121 -0
  4. package/lib/__tests__/index.test.js +118 -0
  5. package/lib/__tests__/input-imperative.test.js +155 -0
  6. package/lib/__tests__/keypad-label.test.js +231 -0
  7. package/lib/__tests__/keypad-render.test.js +206 -0
  8. package/lib/__tests__/latex-bridge.test.js +386 -0
  9. package/lib/__tests__/mathlive-instance.test.js +419 -0
  10. package/lib/__tests__/mount-race.test.js +218 -0
  11. package/lib/__tests__/static-prompts.test.js +405 -0
  12. package/lib/__tests__/tmp-markup.test.js +26 -0
  13. package/lib/keypad/index.js +36 -12
  14. package/lib/keypad/index.js.map +1 -1
  15. package/lib/latex-bridge.js +159 -4
  16. package/lib/latex-bridge.js.map +1 -1
  17. package/lib/mathlive-instance.js +21 -28
  18. package/lib/mathlive-instance.js.map +1 -1
  19. package/lib/mf/common-styles.js +17 -2
  20. package/lib/mf/common-styles.js.map +1 -1
  21. package/lib/mf/index.js +6 -0
  22. package/lib/mf/index.js.map +1 -1
  23. package/lib/mf/input.js +13 -0
  24. package/lib/mf/input.js.map +1 -1
  25. package/lib/mf/static.js +4 -2
  26. package/lib/mf/static.js.map +1 -1
  27. package/lib/selection.js +148 -0
  28. package/lib/selection.js.map +1 -0
  29. package/package.json +1 -1
  30. package/src/__tests__/composites.test.jsx +220 -0
  31. package/src/__tests__/drop-in-compat.test.jsx +112 -0
  32. package/src/__tests__/index.test.jsx +109 -0
  33. package/src/__tests__/input-imperative.test.jsx +144 -0
  34. package/src/__tests__/input-selection.test.jsx +64 -0
  35. package/src/__tests__/keypad-label.test.jsx +284 -0
  36. package/src/__tests__/keypad-render.test.jsx +123 -0
  37. package/src/__tests__/latex-bridge.test.js +389 -0
  38. package/src/__tests__/mathlive-instance.test.js +418 -0
  39. package/src/__tests__/mount-race.test.jsx +142 -0
  40. package/src/__tests__/selection.test.js +212 -0
  41. package/src/__tests__/static-prompts.test.jsx +364 -0
  42. package/src/keypad/index.jsx +18 -3
  43. package/src/latex-bridge.js +171 -4
  44. package/src/mathlive-instance.js +27 -29
  45. package/src/mf/common-styles.js +19 -0
  46. package/src/mf/index.js +2 -0
  47. package/src/mf/input.jsx +14 -0
  48. package/src/mf/static.jsx +4 -1
  49. package/src/selection.js +146 -0
  50. package/lib/fonts/embedded.js +0 -120
  51. package/lib/fonts/index.js +0 -81
  52. package/lib/fonts/index.js.map +0 -1
  53. package/scripts/generate-embedded-fonts.js +0 -94
  54. package/src/fonts/embedded.js +0 -116
  55. package/src/fonts/index.js +0 -71
@@ -69,6 +69,35 @@ export const DEFAULT_FIELD_ID = 'r1';
69
69
  */
70
70
  const EMPTY_SLOT = '\\htmlData{pie-empty=1}{\\rule{0.55em}{0.55em}}';
71
71
  const EMPTY_GROUP_REGEX = /\{\s*\}/g;
72
+ // The segment key (`\overline{}`). The keypad centres every label in its cell, and
73
+ // a thin bar is a much shorter label than the ray and line keys' stretchy arrows,
74
+ // so by default both its bar and its box sit off from theirs: the bar is lower
75
+ // than the arrows' shafts, and the box higher than theirs. Two zero-width struts
76
+ // fix that, found by rendering the three keys at 14-28px and measuring:
77
+ // - inside the bar, above the box: pushes the bar up to the height of the
78
+ // arrows' shafts (the bar-to-box distance then matches theirs);
79
+ // - before the bar, from the baseline: raises the label's top, which moves the
80
+ // whole label down so the box lands where theirs does.
81
+ const EMPTY_OVERLINE_REGEX = /\\overline\{\s*\}/g;
82
+ const EMPTY_OVERLINE_BAR_STRUT = '\\rule{0em}{0.87em}';
83
+ const EMPTY_OVERLINE_LABEL_STRUT = '\\rule{0em}{1.3em}';
84
+ // Empty delimiter pairs: `\abs{}` is converted to `\left|\right|`, and the
85
+ // parenthesis / bracket keys are `\left(\right)` / `\left[\right]`. None has a
86
+ // `{}` group for the rule above to catch, so they would render as bare fences.
87
+ const EMPTY_FENCE_REGEX = /\\left([([|])\s*\\right([)\]|])/g;
88
+ // The same muted fill as the other slots (`pie-empty`), but a tall rectangle
89
+ // rather than a square. It also sets the fences' height: `\left...\right` stretch
90
+ // to their content, so a taller slot gives taller parentheses.
91
+ //
92
+ // A `\rule` stands on the baseline, but the fences are centred on the math axis
93
+ // (0.25em above it), so an un-lifted box sat high between them. `[-0.25em]`
94
+ // lowers it by (axis - height / 2) = 0.25 - 1 / 2, centring it on the axis.
95
+ //
96
+ // Lowering it alone also shrinks the fences, which size themselves to their
97
+ // content (MathLive switches to short bars). The zero-width `\rule{0em}{1em}`
98
+ // strut keeps the content as tall as before, so the bars keep their height and
99
+ // only the box moves.
100
+ const EMPTY_FENCE_SLOT = '\\rule{0em}{1em}\\htmlData{pie-empty=1}{\\rule[-0.25em]{0.55em}{1em}}';
72
101
 
73
102
  /**
74
103
  * Make empty argument slots visible, for static rendering.
@@ -79,7 +108,11 @@ const EMPTY_GROUP_REGEX = /\{\s*\}/g;
79
108
  * @param {string} latex
80
109
  * @returns {string}
81
110
  */
82
- export const withVisibleEmptySlots = (latex) => (latex || '').replace(EMPTY_GROUP_REGEX, `{${EMPTY_SLOT}}`);
111
+ export const withVisibleEmptySlots = (latex) =>
112
+ (latex || '')
113
+ .replace(EMPTY_OVERLINE_REGEX, `${EMPTY_OVERLINE_LABEL_STRUT}\\overline{${EMPTY_OVERLINE_BAR_STRUT}${EMPTY_SLOT}}`)
114
+ .replace(EMPTY_GROUP_REGEX, `{${EMPTY_SLOT}}`)
115
+ .replace(EMPTY_FENCE_REGEX, (_m, open, close) => `\\left${open}${EMPTY_FENCE_SLOT}\\right${close}`);
83
116
 
84
117
  /**
85
118
  * Pie commands that take an argument, and their native MathLive equivalents.
@@ -236,6 +269,138 @@ export const toMathLive = (latex) => {
236
269
  return out;
237
270
  };
238
271
 
272
+ /**
273
+ * Keypad command names that MathQuill treated as ALIASES of a different command.
274
+ *
275
+ * MathQuill registered `\\divide` as another name for `\\div` (and
276
+ * `\\perpendicular` for `\\perp`), so pressing the key stored the real command.
277
+ * MathLive only knows these names through `PIE_MACROS`, and serialises a macro
278
+ * under its own name - so `\\divide` reached storage verbatim, where MathJax
279
+ * (@pie-lib/math-rendering) has no such command and renders it as red text.
280
+ *
281
+ * Every other keypad command already matches what MathQuill stored (`\\degree`,
282
+ * `\\nsim`, `\\ncong`, `\\nparallel`, `\\square`, `\\napprox`,
283
+ * `\\parallelogram`), and MathJax understands those.
284
+ */
285
+ const MATHQUILL_ALIASES = {
286
+ '\\divide': '\\div',
287
+ '\\perpendicular': '\\perp',
288
+ };
289
+
290
+ // Whole command names only: `\\divideontimes` is a real AMS symbol.
291
+ const MATHQUILL_ALIAS_REGEX = /\\(divide|perpendicular)(?![a-zA-Z])/g;
292
+
293
+ /**
294
+ * Primes. MathLive turns a prime that follows a base into a superscript atom and
295
+ * serialises it as `^{\\prime}`, or `^{\\doubleprime}` for the keypad's double
296
+ * prime key (`5''` comes back out as `5^{\\doubleprime}`). MathJax has no
297
+ * `\\doubleprime`, and two adjacent `^{\\prime}` groups - what typing `'` twice
298
+ * produces - are a "double exponent" error. MathQuill stored plain apostrophes,
299
+ * which both MathJax and MathLive read as primes, so store those.
300
+ *
301
+ * Only a superscript made of primes alone is rewritten: `x^{\\prime 2}` keeps
302
+ * its group, since it holds more than primes.
303
+ */
304
+ const PRIME_SUPERSCRIPT_REGEX = /\^\{((?:\s*\\(?:prime|doubleprime)(?![a-zA-Z]))+)\s*\}/g;
305
+ const BARE_DOUBLEPRIME_REGEX = /\\doubleprime(?![a-zA-Z])/g;
306
+
307
+ const primesToApostrophes = (primes) =>
308
+ // eslint-disable-next-line quotes
309
+ "'".repeat(
310
+ (primes.match(/\\prime(?![a-zA-Z])/g) || []).length + 2 * (primes.match(BARE_DOUBLEPRIME_REGEX) || []).length,
311
+ );
312
+
313
+ const BARE_PRIME_COMMAND_REGEX = /^\\(doubleprime|prime)(?![a-zA-Z])/;
314
+
315
+ /**
316
+ * Primes that are *not* in a superscript.
317
+ *
318
+ * MathLive emits bare `\prime` / `\doubleprime` for a `′` or `″` typed or pasted
319
+ * as a character (`5′6″` serialises as `5\prime6\doubleprime`). A bare `\prime`
320
+ * is a full-size glyph in MathJax, while `''` is a superscript, so storing
321
+ * `5\prime6''` drew the single prime visibly larger than each stroke of the
322
+ * double prime. Both become apostrophes, the superscript primes MathQuill
323
+ * stored, so they always match.
324
+ *
325
+ * Inside a script (`x^{\prime 2}`, `x^\prime`) a prime is already a
326
+ * superscript, and an apostrophe there would mean something else, so
327
+ * `\prime` is kept and `\doubleprime` (unknown to MathJax) becomes
328
+ * `\prime\prime`.
329
+ *
330
+ * @param {string} latex
331
+ * @returns {string}
332
+ */
333
+ const barePrimesToApostrophes = (latex) => {
334
+ if (latex.indexOf('prime') === -1) {
335
+ return latex;
336
+ }
337
+
338
+ // One entry per open brace: is that group a super/subscript?
339
+ const groups = [];
340
+ let out = '';
341
+ let i = 0;
342
+
343
+ while (i < latex.length) {
344
+ const ch = latex[i];
345
+
346
+ if (ch === '\\') {
347
+ const match = latex.slice(i).match(BARE_PRIME_COMMAND_REGEX);
348
+
349
+ if (match) {
350
+ const isDouble = match[1] === 'doubleprime';
351
+ // `x^\prime`: the command is itself the script.
352
+ const isScriptArgument = /[\^_]\s*$/.test(out);
353
+ const inScript = isScriptArgument || groups.indexOf(true) !== -1;
354
+
355
+ if (inScript) {
356
+ out += isDouble ? '\\prime\\prime' : '\\prime';
357
+ } else {
358
+ // eslint-disable-next-line quotes
359
+ out += isDouble ? "''" : "'";
360
+ }
361
+
362
+ i += match[0].length;
363
+ continue;
364
+ }
365
+
366
+ // Copy any other command, or an escaped character such as `\{`, whole.
367
+ const command = latex.slice(i).match(/^\\(?:[a-zA-Z]+|.)/);
368
+ const text = command ? command[0] : ch;
369
+
370
+ out += text;
371
+ i += text.length;
372
+ continue;
373
+ }
374
+
375
+ if (ch === '{') {
376
+ groups.push(/[\^_]\s*$/.test(out));
377
+ } else if (ch === '}') {
378
+ groups.pop();
379
+ }
380
+
381
+ out += ch;
382
+ i += 1;
383
+ }
384
+
385
+ return out;
386
+ };
387
+
388
+ /**
389
+ * Rewrite what MathLive serialises into the form MathQuill stored: alias
390
+ * command names, and primes.
391
+ *
392
+ * @param {string} latex
393
+ * @returns {string}
394
+ */
395
+ export const toCanonicalCommands = (latex) =>
396
+ typeof latex === 'string'
397
+ ? barePrimesToApostrophes(
398
+ latex
399
+ .replace(MATHQUILL_ALIAS_REGEX, (m) => MATHQUILL_ALIASES[m])
400
+ .replace(PRIME_SUPERSCRIPT_REGEX, (_m, primes) => primesToApostrophes(primes)),
401
+ )
402
+ : latex;
403
+
239
404
  /**
240
405
  * Convert latex out of MathLive back into the stored form, so items authored
241
406
  * with the MathLive editor remain readable by @pie-lib/math-rendering and by
@@ -273,7 +438,9 @@ export const fromMathLive = (latex) => {
273
438
  out = out.replace(BARE_PLACEHOLDER_REGEX, (_m, content) => content);
274
439
  out = out.replace(BARE_PLACEHOLDER_NO_ARG_REGEX, '');
275
440
 
276
- return out;
441
+ // Store the command MathQuill stored, not the alias - also heals latex that was
442
+ // saved with `\\divide` before the keypad resolved it.
443
+ return toCanonicalCommands(out);
277
444
  };
278
445
 
279
446
  /**
@@ -347,7 +514,7 @@ export const keyToAction = (key) => {
347
514
  // applied them in sequence; MathLive inserts one latex string, so expand
348
515
  // each part and concatenate.
349
516
  const parts = Array.isArray(key.command) ? key.command : [key.command];
350
- const value = parts.map((c) => COMMANDS_WITH_ARGS[c] || c).join('');
517
+ const value = parts.map((c) => COMMANDS_WITH_ARGS[c] || MATHQUILL_ALIASES[c] || c).join('');
351
518
 
352
519
  return { type: 'insert', value };
353
520
  }
@@ -361,4 +528,4 @@ export const keyToAction = (key) => {
361
528
  return undefined;
362
529
  };
363
530
 
364
- export { COMMANDS_WITH_ARGS, KEYSTROKES };
531
+ export { COMMANDS_WITH_ARGS, KEYSTROKES, MATHQUILL_ALIASES };
@@ -1,7 +1,6 @@
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
+ import { ROOT_INDEX_CSS, PARALLEL_CSS } from './mf/common-styles';
5
4
 
6
5
  const log = debug('pie-lib:math-input-mathlive:instance');
7
6
 
@@ -49,7 +48,7 @@ const absoluteFontsDirectory = (dir) => {
49
48
  };
50
49
 
51
50
  /**
52
- * Last-resort CDN for the KaTeX fonts.
51
+ * Version-pinned CDN fallback for the KaTeX fonts.
53
52
  *
54
53
  * Some symbols are composed from Private Use Area glyphs that exist only in
55
54
  * those fonts: `\\neq` is U+E020 (a negation slash) overlapped on `=`, and
@@ -57,10 +56,9 @@ const absoluteFontsDirectory = (dir) => {
57
56
  * an arbitrary glyph for U+E020, so those keys render as garbage rather than
58
57
  * simply falling back to a plainer face.
59
58
  *
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.
59
+ * The version comes from MathLive itself, so the fonts always match the engine.
60
+ * Hosts that would rather not depend on a third party should serve the fonts and
61
+ * call `configureFonts('/their/path')`, or pass `null` to opt out.
64
62
  */
65
63
  const FALLBACK_MATHLIVE_VERSION = '0.110.0';
66
64
 
@@ -74,17 +72,15 @@ const cdnFontsDirectory = () => {
74
72
  /**
75
73
  * Tell the package where the MathLive fonts are served from.
76
74
  *
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.
75
+ * Call before `loadMathLive()`.
81
76
  *
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
77
+ * - a path/URL: served by you, no third-party request
78
+ * - `null`: skip font loading; note `\\neq`, `\\nsim` and `\\ncong` then
79
+ * render incorrectly, being built from Private Use Area glyphs
80
+ * - not called: falls back to a version-pinned CDN
81
+ *
82
+ * The fonts ship inside the `mathlive` package (`node_modules/mathlive/fonts`)
83
+ * ready to copy into whatever the host app serves statically.
88
84
  *
89
85
  * @param {string|null} [dir]
90
86
  */
@@ -120,6 +116,9 @@ export const PIE_MACROS = {
120
116
  // Argument-less symbols. Atomic is correct here: backspace should remove the
121
117
  // whole glyph, and there is nothing to type into.
122
118
  parallelogram: '\\htmlData{pie-macro=parallelogram}{\\unicode{"25B1}}',
119
+ // Same glyph as the built-in `\\parallel` (U+2225), tagged so it can be sized -
120
+ // see `parallelStyles`.
121
+ parallel: '\\htmlData{pie-macro=parallel}{\\unicode{"2225}}',
123
122
  napprox: '\\not\\approx',
124
123
  nsim: '\\not\\sim',
125
124
  ncong: '\\not\\cong',
@@ -167,22 +166,18 @@ let loadWarned = false;
167
166
 
168
167
  /** Apply post-load configuration exactly once. */
169
168
  const configureLoaded = () => {
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()) {
169
+ // Always replace MathLive's relative `'./fonts/'` default: it throws
170
+ // "Invalid base URL" when bundled. Use the host's copy if one was configured,
171
+ // otherwise the version-pinned CDN.
172
+ if (fontsDirectory === undefined) {
175
173
  log(
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.',
174
+ 'no fontsDirectory configured - falling back to the CDN (%s). ' +
175
+ 'Serve the fonts yourself and call configureFonts("/path/to/fonts") to avoid the network request, ' +
176
+ 'or configureFonts(null) to opt out.',
178
177
  cdnFontsDirectory(),
179
178
  );
180
179
  }
181
180
 
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.
186
181
  mathlive.MathfieldElement.fontsDirectory =
187
182
  fontsDirectory === undefined ? cdnFontsDirectory() : absoluteFontsDirectory(fontsDirectory);
188
183
 
@@ -244,7 +239,10 @@ const PLACEHOLDER_CARET_CSS = [
244
239
  // written for static markup in the host page, which cannot cross the shadow
245
240
  // boundary.
246
241
  const SHADOW_CSS =
247
- '.ML__latex .ML__center:has(.ML__stretchy){margin-left:0 !important}' + ROOT_INDEX_CSS + PLACEHOLDER_CARET_CSS;
242
+ '.ML__latex .ML__center:has(.ML__stretchy){margin-left:0 !important}' +
243
+ ROOT_INDEX_CSS +
244
+ PARALLEL_CSS +
245
+ PLACEHOLDER_CARET_CSS;
248
246
 
249
247
  /**
250
248
  * Keep {@link PLACEHOLDER_CARET_CLASS} in step with the selection.
@@ -75,6 +75,24 @@ export const rootIndexStyles = {
75
75
  */
76
76
  export const ROOT_INDEX_CSS = `${ROOT_INDEX_SELECTOR}{font-size:${ROOT_INDEX_FONT_SIZE} !important}`;
77
77
 
78
+ /**
79
+ * Size of the parallel sign (`\parallel`, tagged `data-pie-macro="parallel"` by
80
+ * `PIE_MACROS`). The legacy editor enlarged it (`updateSpans` set 32px) because
81
+ * the bars read too small next to the letters of `AB ∥ CD`; MathLive draws it at
82
+ * the surrounding size, so it is scaled up here. Not applied to keypad labels.
83
+ */
84
+ const PARALLEL_SELECTOR = '[data-pie-macro="parallel"]';
85
+ const PARALLEL_FONT_SIZE = '1.4em';
86
+
87
+ export const parallelStyles = {
88
+ [`& ${PARALLEL_SELECTOR}`]: {
89
+ fontSize: PARALLEL_FONT_SIZE,
90
+ },
91
+ };
92
+
93
+ /** The same rule as a CSS string, for a live mathfield's shadow root. */
94
+ export const PARALLEL_CSS = `${PARALLEL_SELECTOR}{font-size:${PARALLEL_FONT_SIZE}}`;
95
+
78
96
  /**
79
97
  * Superscript/subscript sizing. MathLive follows TeX metrics for scripts, so the
80
98
  * MathQuill-era corrections are gone; the radical index is the one place where a
@@ -82,6 +100,7 @@ export const ROOT_INDEX_CSS = `${ROOT_INDEX_SELECTOR}{font-size:${ROOT_INDEX_FON
82
100
  */
83
101
  export const supsubStyles = {
84
102
  ...rootIndexStyles,
103
+ ...parallelStyles,
85
104
  };
86
105
 
87
106
  /** Empty placeholder boxes (answer blocks / open slots). */
package/src/mf/index.js CHANGED
@@ -7,6 +7,7 @@ import CommonMqStyles, {
7
7
  commonMqKeyboardStyles,
8
8
  longdivStyles,
9
9
  rootIndexStyles,
10
+ parallelStyles,
10
11
  supsubStyles,
11
12
  placeholderStyles,
12
13
  } from './common-styles';
@@ -23,6 +24,7 @@ export {
23
24
  commonMqKeyboardStyles,
24
25
  longdivStyles,
25
26
  rootIndexStyles,
27
+ parallelStyles,
26
28
  supsubStyles,
27
29
  placeholderStyles,
28
30
  };
package/src/mf/input.jsx CHANGED
@@ -10,6 +10,7 @@ import {
10
10
  trackPlaceholderCaret,
11
11
  } from '../mathlive-instance';
12
12
  import { toMathLive, fromMathLive, keyToAction, KEYSTROKES } from '../latex-bridge';
13
+ import { trackWholeAtomSelection } from '../selection';
13
14
 
14
15
  const log = debug('pie-lib:math-input-mathlive:input');
15
16
 
@@ -80,6 +81,11 @@ export class Input extends React.Component {
80
81
  // Without this the spacebar does nothing: MathLive's mathModeSpace
81
82
  // defaults to an empty string.
82
83
  this.mathField.mathModeSpace = MATH_MODE_SPACE;
84
+ // After the exponent key the cursor must stay in the exponent until it is moved
85
+ // MathLive's `smartSuperscript` (on by
86
+ // default) jumps out of an empty superscript after a single digit, which breaks
87
+ // typing x^23 or 10^6 and is inconsistent with non-digit exponents.
88
+ this.mathField.smartSuperscript = false;
83
89
 
84
90
  // The mathfield renders in shadow DOM, so page CSS cannot reach it - the
85
91
  // stretchy-accent fix has to be injected there directly.
@@ -88,6 +94,9 @@ export class Input extends React.Component {
88
94
  // A selected `\placeholder{}` gets no MathLive caret; draw one.
89
95
  this.untrackCaret = trackPlaceholderCaret(this.mathField);
90
96
 
97
+ // Shift+Arrow would otherwise select half a fraction; snap to whole atoms.
98
+ this.untrackSelection = trackWholeAtomSelection(this.mathField);
99
+
91
100
  this.mathField.addEventListener('input', this.onInputEdit);
92
101
 
93
102
  // Belt and braces: drop anything a previous mount may have left behind.
@@ -131,6 +140,11 @@ export class Input extends React.Component {
131
140
  this.untrackCaret = undefined;
132
141
  }
133
142
 
143
+ if (this.untrackSelection) {
144
+ this.untrackSelection();
145
+ this.untrackSelection = undefined;
146
+ }
147
+
134
148
  if (this.mathField) {
135
149
  this.mathField.removeEventListener('input', this.onInputEdit);
136
150
  this.mathField.remove();
package/src/mf/static.jsx CHANGED
@@ -11,7 +11,7 @@ import {
11
11
  trackPlaceholderCaret,
12
12
  } from '../mathlive-instance';
13
13
  import { toMathLive, fromMathLive, fieldIds } from '../latex-bridge';
14
- import { placeholderStyles, rootIndexStyles } from './common-styles';
14
+ import { placeholderStyles, rootIndexStyles, parallelStyles } from './common-styles';
15
15
 
16
16
  const log = debug('pie-lib:math-input-mathlive:static');
17
17
 
@@ -22,6 +22,7 @@ const Holder = styled('span')({
22
22
  // Readable radical index - display mode renders in the light DOM, so the
23
23
  // shadow-root copy of this rule does not reach it.
24
24
  ...rootIndexStyles,
25
+ ...parallelStyles,
25
26
  '& math-field': {
26
27
  display: 'inline-block',
27
28
  border: 'none',
@@ -101,6 +102,8 @@ export default class Static extends React.Component {
101
102
  // Without this the spacebar does nothing: MathLive's mathModeSpace
102
103
  // defaults to an empty string.
103
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;
104
107
  this.mathField.value = toMathLive(this.props.latex);
105
108
 
106
109
  // The mathfield renders in shadow DOM, so page CSS cannot reach it - the
@@ -0,0 +1,146 @@
1
+ import debug from 'debug';
2
+
3
+ const log = debug('pie-lib:math-input-mathlive:selection');
4
+
5
+ /**
6
+ * Widen a selection so it covers whole atoms of a single parent.
7
+ *
8
+ * MathLive extends a selection over *flat offsets*, so Shift+Arrow walks into
9
+ * a fraction instead of over it: from before `2x` in `5x^2+2x+\frac{\pi}{2}-4`,
10
+ * five presses select `2x+` plus the `\pi` of the numerator, and copying gives
11
+ * `2x+\pi`. MathQuill treated a fraction (root, superscript...) as one unit,
12
+ * which is what authors expect.
13
+ *
14
+ * The tree is recovered from MathLive's public `getElementInfo(offset)`, where
15
+ * offset `i` describes the atom ending at caret position `i`:
16
+ *
17
+ * - a composite atom (fraction, root, script) owns the offset right *after*
18
+ * its children; the children are one level deeper;
19
+ * - each branch (numerator, denominator, radicand) opens with an empty-latex
20
+ * "start" atom.
21
+ *
22
+ * The rule: find the shallowest depth the selection touches, counting the caret
23
+ * it starts from. If the range crosses into a sibling branch at that depth, the
24
+ * real common parent is one level up. Then push both ends out of anything
25
+ * deeper. A script atom (`^2`) is kept with its base, since MathLive stores
26
+ * `x^2` as `x` followed by a separate `^2` atom.
27
+ *
28
+ * Pure, so it can be tested without a browser.
29
+ *
30
+ * @param {number} start
31
+ * @param {number} end
32
+ * @param {function(number): {depth?: number, latex?: string}} info
33
+ * @param {number} lastOffset
34
+ * @returns {[number, number]} the widened range, start <= end
35
+ */
36
+ export const expandRange = (start, end, info, lastOffset) => {
37
+ const at = (i) => info(i) || {};
38
+ const depth = (i) => at(i).depth || 0;
39
+ const isBranchStart = (i) => depth(i) > 0 && at(i).latex === '';
40
+
41
+ if (end < start) {
42
+ [start, end] = [end, start];
43
+ }
44
+
45
+ if (end - start < 1) {
46
+ return [start, end];
47
+ }
48
+
49
+ let target = Infinity;
50
+
51
+ for (let i = start; i <= end; i++) {
52
+ target = Math.min(target, depth(i));
53
+ }
54
+
55
+ for (let i = start + 1; i <= end; i++) {
56
+ if (depth(i) === target && isBranchStart(i)) {
57
+ target -= 1;
58
+ break;
59
+ }
60
+ }
61
+
62
+ target = Math.max(target, 0);
63
+
64
+ while (start > 0 && depth(start) > target) {
65
+ start -= 1;
66
+ }
67
+
68
+ while (end < lastOffset && depth(end) > target) {
69
+ end += 1;
70
+ }
71
+
72
+ // Keep a superscript / subscript with its base.
73
+ let first = start + 1;
74
+
75
+ while (first <= end && depth(first) > target) {
76
+ first += 1;
77
+ }
78
+
79
+ if (start > 0 && first <= end && /^[\^_]/.test(at(first).latex || '') && depth(start) === target) {
80
+ start -= 1;
81
+ }
82
+
83
+ return [start, end];
84
+ };
85
+
86
+ /**
87
+ * Keep a mathfield's selection snapped to whole atoms (see {@link expandRange}).
88
+ *
89
+ * This fixes both what the user sees highlighted and what copy/cut put on the
90
+ * clipboard, since both come from the selection. The selection direction is
91
+ * preserved, so further Shift+Arrow presses keep extending the same end.
92
+ *
93
+ * @param {HTMLElement} mathField a MathLive `<math-field>`
94
+ * @returns {function|undefined} teardown, to call on unmount
95
+ */
96
+ export const trackWholeAtomSelection = (mathField) => {
97
+ if (!mathField || typeof mathField.addEventListener !== 'function') {
98
+ return undefined;
99
+ }
100
+
101
+ let applying = false;
102
+
103
+ const update = () => {
104
+ if (applying) {
105
+ return;
106
+ }
107
+
108
+ try {
109
+ if (mathField.selectionIsCollapsed || typeof mathField.getElementInfo !== 'function') {
110
+ return;
111
+ }
112
+
113
+ const selection = mathField.selection;
114
+ const range = selection && selection.ranges && selection.ranges[0];
115
+
116
+ if (!range) {
117
+ return;
118
+ }
119
+
120
+ const [start, end] = range;
121
+ const lo = Math.min(start, end);
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
+ );
129
+
130
+ if (nextStart === lo && nextEnd === hi) {
131
+ return;
132
+ }
133
+
134
+ applying = true;
135
+ mathField.selection = { ranges: [[nextStart, nextEnd]], direction: selection.direction };
136
+ } catch (e) {
137
+ log('could not normalise the selection: %s', e && e.message);
138
+ } finally {
139
+ applying = false;
140
+ }
141
+ };
142
+
143
+ mathField.addEventListener('selection-change', update);
144
+
145
+ return () => mathField.removeEventListener('selection-change', update);
146
+ };