@barocss/math-editor 0.4.0 → 0.6.1

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 (76) hide show
  1. package/API-SESSION.md +35 -1
  2. package/API-WEB-COMPONENT.md +2 -0
  3. package/CHANGELOG.md +109 -0
  4. package/CLIPBOARD.md +54 -0
  5. package/EDITING-SCENARIOS.md +209 -0
  6. package/GETTING-STARTED.md +54 -0
  7. package/IMPLEMENTATION.md +105 -0
  8. package/KEYBOARD.md +41 -0
  9. package/LATEX-GUIDE.md +52 -2
  10. package/LATEX-MODEL.md +12 -0
  11. package/LATEX-SCOPE.md +10 -1
  12. package/README.md +119 -23
  13. package/RELEASING.md +95 -10
  14. package/RENDERING-TESTS.md +79 -0
  15. package/ROADMAP.md +238 -6
  16. package/STYLING.md +28 -1
  17. package/TEXT-EDITORS.md +157 -0
  18. package/VALIDATION.md +433 -0
  19. package/dist/context-tools.d.ts +21 -0
  20. package/dist/context-tools.js +37 -0
  21. package/dist/dom/caret-geometry.js +1 -1
  22. package/dist/dom/context-keyboard.d.ts +2 -0
  23. package/dist/dom/context-keyboard.js +22 -0
  24. package/dist/dom/help.d.ts +5 -0
  25. package/dist/dom/help.js +104 -0
  26. package/dist/dom/menu-position.d.ts +3 -0
  27. package/dist/dom/menu-position.js +49 -4
  28. package/dist/dom/readable-layout.d.ts +3 -0
  29. package/dist/dom/readable-layout.js +52 -0
  30. package/dist/dom/toolbar-catalog.d.ts +1 -1
  31. package/dist/dom/toolbar.d.ts +2 -0
  32. package/dist/dom/toolbar.js +8 -1
  33. package/dist/dom.d.ts +13 -1
  34. package/dist/dom.js +323 -51
  35. package/dist/enter-policy.js +1 -1
  36. package/dist/latex.js +11 -0
  37. package/dist/lines.d.ts +2 -0
  38. package/dist/lines.js +15 -0
  39. package/dist/locales/en.js +33 -3
  40. package/dist/locales/en.json +33 -3
  41. package/dist/locales/ko.js +33 -3
  42. package/dist/locales/ko.json +33 -3
  43. package/dist/math-editor-toolbar.js +2 -1
  44. package/dist/math-editor.d.ts +4 -1
  45. package/dist/math-editor.js +506 -296
  46. package/dist/math-layout.d.ts +12 -0
  47. package/dist/math-layout.js +59 -0
  48. package/dist/math-spacing.d.ts +13 -0
  49. package/dist/math-spacing.js +87 -0
  50. package/dist/model.d.ts +6 -0
  51. package/dist/model.js +72 -5
  52. package/dist/range.d.ts +10 -2
  53. package/dist/range.js +31 -6
  54. package/dist/root-transform.d.ts +19 -0
  55. package/dist/root-transform.js +69 -0
  56. package/dist/selection-shortcuts.d.ts +13 -0
  57. package/dist/selection-shortcuts.js +35 -0
  58. package/dist/session.d.ts +1 -0
  59. package/dist/session.js +1 -1
  60. package/dist/suggestions.d.ts +9 -0
  61. package/dist/suggestions.js +53 -0
  62. package/dist/symbols.d.ts +1 -1
  63. package/dist/symbols.js +1 -0
  64. package/dist/vertical-navigation.d.ts +5 -0
  65. package/dist/vertical-navigation.js +33 -0
  66. package/dist/web-component.js +9 -1
  67. package/package.json +11 -3
  68. package/src/fonts/KaTeX_Math-Italic.woff2 +0 -0
  69. package/src/fonts/KaTeX_Size1-Regular.woff2 +0 -0
  70. package/src/fonts/README.md +18 -0
  71. package/src/shapes/README.md +21 -0
  72. package/src/shapes/parenthesis-bottom.svg +1 -0
  73. package/src/shapes/parenthesis-top.svg +1 -0
  74. package/src/shapes/parenthesis.svg +1 -0
  75. package/src/shapes/radical.svg +1 -0
  76. package/src/style.css +882 -61
package/dist/dom.js CHANGED
@@ -1,8 +1,16 @@
1
+ import { followReadableMathLayout } from './dom/readable-layout.js';
2
+ import { attachMathHelp, requestMathHelp } from './dom/help.js';
3
+ import { mathRunSpacing } from './math-spacing.js';
4
+ import { mathLayout } from './math-layout.js';
5
+ import { wrapSelectionShortcut } from './selection-shortcuts.js';
6
+ import { transformRoot } from './root-transform.js';
7
+ import { structureEditingContext, changeContextFence } from './context-tools.js';
8
+ import { focusContextAction } from './dom/context-keyboard.js';
1
9
  import { mountLatexPanel } from './dom/latex-panel.js';
2
10
  import { followMathTheme, syncMathTheme } from './dom/theme.js';
3
11
  import { matrixPointerRange } from './dom/matrix-selection.js';
4
- import { positionMathMenu } from './dom/menu-position.js';
5
- import { fences } from './fences.js';
12
+ import { positionMathMenu, followMathMenuPosition } from './dom/menu-position.js';
13
+ import { fences, fencePairs } from './fences.js';
6
14
  import { createEditorLabels } from './editor-labels.js';
7
15
  import { mathEnterAction } from './enter-policy.js';
8
16
  export { mountMathLatex, mountMathPreview, } from './outputs.js';
@@ -11,16 +19,16 @@ import { mountMathToolbar } from './dom/toolbar.js';
11
19
  export { mountMathToolbar } from './dom/toolbar.js';
12
20
  import { createMathSession, } from './session.js';
13
21
  import { translate, mathLocaleDirection } from './i18n.js';
14
- import { documentRows, isLiteralText, textNodes, setText, moveCaret, unwrapPrevious, gridDeletionTarget, removeGrid, isGrid, toLatex, } from './model.js';
22
+ import { documentRows, isLiteralText, textNodes, setText, moveCaret, unwrapPrevious, unwrapNext, unwrapEmptySlot, gridDeletionTarget, removeGrid, isGrid, toLatex, } from './model.js';
15
23
  import { activeGrid, activeMatrix, resizeMatrix } from './matrix.js';
16
24
  import { MATH_MATRIX_CLIPBOARD_TYPE, parseMatrixFragment, matrixFragment, matrixFromTSV, matrixRangeBetween, resolveMatrixRange, extendMatrixRange, focusMatrixCell, } from './matrix-range.js';
17
- import { joinPreviousLine } from './lines.js';
18
- import { moveVertical } from './vertical-navigation.js';
25
+ import { joinPreviousLine, joinNextLine } from './lines.js';
26
+ import { createVerticalNavigation } from './vertical-navigation.js';
19
27
  import { renderedCaretGeometry } from './dom/caret-geometry.js';
20
28
  import { extendsMathSelection, nativeSelectionAnchor, createSelectionMenuState, textSelectionBounds, } from './dom/selection.js';
21
- import { acceptSuggestion, findSuggestions, mathStructures } from './suggestions.js';
29
+ import { acceptSuggestion, findStateSuggestions, mathStructures, } from './suggestions.js';
22
30
  import { tokenizeMathText, tokenIndexAt } from './tokens.js';
23
- import { fragmentLatex, parseFragment, MATH_CLIPBOARD_TYPE, extendKeyboardRange, collapseKeyboardRange, resolveRange, copyRange, wrappingKinds, } from './range.js';
31
+ import { fragmentLatex, parseFragment, MATH_CLIPBOARD_TYPE, extendKeyboardRange, isTokenNavigationKey, collapseKeyboardRange, resolveRange, copyRange, wrappingKinds, } from './range.js';
24
32
  /** Native DOM renderer. No React or framework runtime is imported by this module. */
25
33
  export function mountMathEditor(host, initial = {}) {
26
34
  const doc = host.ownerDocument;
@@ -37,8 +45,8 @@ export function mountMathEditor(host, initial = {}) {
37
45
  const ownsSession = !initial.session;
38
46
  if (initial.session)
39
47
  session.configure({ locale: initial.locale, mode: initial.mode });
40
- const root = element('span', 'me-editor me-dom-editor'), tools = element('span'), surface = element('span', 'me-surface'), status = element('span', 'me-dom-status');
41
- root.append(tools, surface, status);
48
+ const root = element('span', 'me-editor me-dom-editor'), tools = element('span'), surface = element('span', 'me-surface'), status = element('span', 'me-dom-status'), contextFooter = element('span', 'me-context-footer');
49
+ root.append(tools, surface, contextFooter, status);
42
50
  host.append(root);
43
51
  surface.tabIndex = 0;
44
52
  surface.setAttribute('role', 'group');
@@ -70,6 +78,7 @@ export function mountMathEditor(host, initial = {}) {
70
78
  onClose: closeLatex,
71
79
  });
72
80
  };
81
+ const stopHelp = attachMathHelp(root, () => session.getSnapshot().locale);
73
82
  const t = (value) => translate(session.getSnapshot().locale, value);
74
83
  const currentInput = () => surface.querySelector('input');
75
84
  const cancelDrag = () => {
@@ -82,6 +91,7 @@ export function mountMathEditor(host, initial = {}) {
82
91
  const notify = (key) => {
83
92
  status.textContent = t(key);
84
93
  };
94
+ const verticalNavigation = createVerticalNavigation();
85
95
  const apply = (next) => {
86
96
  editing = true;
87
97
  selectedGrid = undefined;
@@ -96,13 +106,16 @@ export function mountMathEditor(host, initial = {}) {
96
106
  };
97
107
  const candidates = () => {
98
108
  const snapshot = session.getSnapshot(), state = snapshot.state, active = textNodes(state.document).find((n) => n.id === state.caret.id);
99
- const result = findSuggestions(active.text, state.caret.start, snapshot.locale);
100
- if (!editing ||
109
+ const result = findStateSuggestions(state, snapshot.locale);
110
+ if (options.suggestionMenu === false ||
111
+ !editing ||
101
112
  isLiteralText(state.document, state.caret.id) ||
102
113
  snapshot.range ||
103
114
  snapshot.matrixRange ||
104
115
  dismissed === `${active.id}:${active.text}:${state.caret.start}`)
105
116
  result.candidates = [];
117
+ else if (options.filterSuggestions)
118
+ result.candidates = [...options.filterSuggestions(result.candidates, result.query, state)];
106
119
  return result;
107
120
  };
108
121
  const choose = (index) => {
@@ -162,12 +175,18 @@ export function mountMathEditor(host, initial = {}) {
162
175
  const itemRect = option.getBoundingClientRect();
163
176
  const listRect = list.getBoundingClientRect();
164
177
  const scaleY = listRect.height / list.offsetHeight || 1;
165
- if (itemRect.top < listRect.top)
166
- list.scrollTop -= (listRect.top - itemRect.top) / scaleY;
167
- else if (itemRect.bottom > listRect.bottom)
168
- list.scrollTop += (itemRect.bottom - listRect.bottom) / scaleY;
178
+ list.scrollTop +=
179
+ (itemRect.top + itemRect.height / 2 - listRect.top) / scaleY -
180
+ list.clientTop -
181
+ list.clientHeight / 2;
169
182
  };
170
183
  const renderMenu = () => {
184
+ if (options.suggestionMenu === false) {
185
+ menu.hidden = true;
186
+ menu.replaceChildren();
187
+ currentInput()?.setAttribute('aria-expanded', 'false');
188
+ return;
189
+ }
171
190
  syncMathTheme(root, menu);
172
191
  const result = candidates(), input = currentInput();
173
192
  menu.replaceChildren();
@@ -196,8 +215,9 @@ export function mountMathEditor(host, initial = {}) {
196
215
  const list = element('div', 'me-suggestions');
197
216
  list.setAttribute('role', 'listbox');
198
217
  list.setAttribute('aria-label', t('selection.toolbar'));
199
- for (const item of mathStructures.filter((item) => wrappingKinds.includes(item.kind))) {
200
- const button = suggestionButton(item.glyph, t(item.label), t(item.detail));
218
+ for (const item of mathStructures.filter((item) => wrappingKinds.includes(item.kind) &&
219
+ (!options.selectionKinds || options.selectionKinds.includes(item.kind)))) {
220
+ const button = suggestionButton(item.glyph, t(item.kind === 'fraction' ? 'selection.asNumerator' : item.label), t(item.detail));
201
221
  button.setAttribute('aria-selected', String(list.children.length === candidateIndex));
202
222
  button.disabled = !canWrap;
203
223
  button.setAttribute('aria-label', translate(snapshot.locale, 'selection.wrap', { kind: t(item.label) }));
@@ -207,6 +227,18 @@ export function mountMathEditor(host, initial = {}) {
207
227
  perform({ type: 'structure', kind: item.kind });
208
228
  };
209
229
  list.append(button);
230
+ if (item.kind === 'fraction') {
231
+ const denominator = suggestionButton('□/▧', t('selection.asDenominator'), t(item.detail));
232
+ denominator.setAttribute('aria-selected', String(list.children.length === candidateIndex));
233
+ denominator.setAttribute('aria-label', t('selection.asDenominator'));
234
+ denominator.disabled = !canWrap;
235
+ denominator.onclick = () => {
236
+ if (!snapshot.range)
237
+ session.selectRange(selectedRange);
238
+ perform({ type: 'structure', kind: 'fraction', fractionSlot: 'denominator' });
239
+ };
240
+ list.append(denominator);
241
+ }
210
242
  }
211
243
  menu.append(list);
212
244
  const selected = surface.querySelectorAll('.me-dom-selected, .me-range-highlight');
@@ -262,6 +294,90 @@ export function mountMathEditor(host, initial = {}) {
262
294
  event.stopPropagation();
263
295
  (currentInput() ?? surface).focus();
264
296
  };
297
+ const renderContextFooter = () => {
298
+ contextFooter.replaceChildren();
299
+ const snapshot = session.getSnapshot();
300
+ const structureContext = editing && options.contextTools !== false && !snapshot.range && !snapshot.matrixRange
301
+ ? structureEditingContext(snapshot.state)
302
+ : undefined;
303
+ contextFooter.hidden = !structureContext;
304
+ if (!structureContext)
305
+ return;
306
+ contextFooter.setAttribute('role', 'group');
307
+ contextFooter.setAttribute('aria-label', t('context.label'));
308
+ const fence = structureContext.fence;
309
+ if (fence) {
310
+ const pair = fences(fence);
311
+ contextFooter.append(element('span', 'me-context-label', translate(snapshot.locale, 'context.current', {
312
+ kind: t('presentation.fence') + ' ' + pair.join(' … '),
313
+ })));
314
+ const actions = element('span', 'me-context-actions');
315
+ for (const [kind, pair] of Object.entries(fencePairs)) {
316
+ const label = t(mathStructures.find((item) => item.kind === kind).label);
317
+ const button = element('button', 'me-context-button', pair.join(' □ '));
318
+ button.type = 'button';
319
+ button.disabled = composing;
320
+ button.title = label;
321
+ button.setAttribute('aria-label', label);
322
+ button.setAttribute('aria-pressed', String(fence.type === kind));
323
+ button.onclick = () => {
324
+ if (!composing) {
325
+ apply(changeContextFence(session.getSnapshot().state, fence.id, kind));
326
+ currentInput()?.focus({ preventScroll: true });
327
+ }
328
+ };
329
+ actions.append(button);
330
+ }
331
+ contextFooter.append(actions, element('span', 'me-context-hint', t('context.fenceKeyboardHint')));
332
+ return;
333
+ }
334
+ const context = structureContext.root;
335
+ contextFooter.append(element('span', 'me-context-label', translate(snapshot.locale, 'context.current', { kind: t(`context.${context.kind}`) })));
336
+ const actions = element('span', 'me-context-actions');
337
+ if (context.indexCaret) {
338
+ const index = element('button', 'me-context-button', t('context.editIndex'));
339
+ index.type = 'button';
340
+ index.disabled = composing;
341
+ index.onclick = () => {
342
+ if (!composing)
343
+ session.select(context.indexCaret);
344
+ };
345
+ actions.append(index);
346
+ }
347
+ const convert = element('button', 'me-context-button', translate(snapshot.locale, 'suggestion.transform', {
348
+ kind: t(`context.${context.target}`),
349
+ }));
350
+ convert.type = 'button';
351
+ convert.disabled = composing || !context.canTransform;
352
+ convert.onclick = () => {
353
+ if (!composing)
354
+ apply(transformRoot(session.getSnapshot().state, context.id, context.target));
355
+ };
356
+ actions.append(convert);
357
+ contextFooter.append(actions, element('span', 'me-context-hint', t(context.canTransform ? 'context.keyboardHint' : 'context.squareIndexRequired')));
358
+ };
359
+ contextFooter.onmousedown = (event) => event.preventDefault();
360
+ contextFooter.onkeydown = (event) => {
361
+ event.stopPropagation();
362
+ if (composing || event.isComposing)
363
+ return;
364
+ if (focusContextAction(contextFooter, event.key === 'F6' ? '' : event.key)) {
365
+ event.preventDefault();
366
+ return;
367
+ }
368
+ if (event.key === 'Escape' || event.key === 'F6') {
369
+ event.preventDefault();
370
+ currentInput()?.focus({ preventScroll: true });
371
+ }
372
+ };
373
+ const focusContextFooter = (event) => {
374
+ if (event.key !== 'F6' || contextFooter.hidden || composing || event.isComposing)
375
+ return false;
376
+ if (!focusContextAction(contextFooter, 'F6'))
377
+ return false;
378
+ event.preventDefault();
379
+ return true;
380
+ };
265
381
  const render = () => {
266
382
  if (destroyed || composing || rendering)
267
383
  return;
@@ -288,8 +404,11 @@ export function mountMathEditor(host, initial = {}) {
288
404
  status.textContent = '';
289
405
  const selected = (r) => spans?.find((span) => span.row.id === r.id);
290
406
  const labels = createEditorLabels(snapshot.locale);
407
+ const layout = mathLayout(state.document, snapshot.mode);
291
408
  const renderRow = (r, name, inheritedSelection = false) => {
292
409
  const view = element('span', 'me-row');
410
+ const spacing = mathRunSpacing(r);
411
+ view.dataset.layoutStyle = layout.rows.get(r.id);
293
412
  const selection = selected(r);
294
413
  let offset = 0;
295
414
  for (const child of r.children) {
@@ -311,6 +430,14 @@ export function mountMathEditor(host, initial = {}) {
311
430
  run.dataset.runId = child.id;
312
431
  run.dataset.value = token.text;
313
432
  run.dataset.tokenKind = token.kind;
433
+ if (!isLiteralText(state.document, child.id)) {
434
+ const gap = spacing.get(`${child.id}:${token.start}`);
435
+ run.dataset.mathSpacing = 'true';
436
+ run.style.setProperty('--me-space-before', String(gap?.before ?? 0));
437
+ run.style.setProperty('--me-space-after', String(gap?.after ?? 0));
438
+ run.style.setProperty('--me-tight-before', String(gap?.tightBefore ?? 0));
439
+ run.style.setProperty('--me-tight-after', String(gap?.tightAfter ?? 0));
440
+ }
314
441
  run.dataset.tokenStart = String(token.start);
315
442
  run.dataset.tokenEnd = String(token.end);
316
443
  const mirror = element('span', 'me-measure', token.text || (boundary ? '' : '□'));
@@ -366,6 +493,7 @@ export function mountMathEditor(host, initial = {}) {
366
493
  cancelDrag();
367
494
  composing = true;
368
495
  toolbar?.setDisabled(true);
496
+ renderContextFooter();
369
497
  menu.hidden = true;
370
498
  });
371
499
  input.addEventListener('compositionend', () => {
@@ -374,7 +502,12 @@ export function mountMathEditor(host, initial = {}) {
374
502
  dismissed = '';
375
503
  apply(replaceToken());
376
504
  });
377
- input.onkeydown = keydown;
505
+ input.onkeydown = (event) => {
506
+ // Native arrow movement can precede select/keyup, especially during key repeat.
507
+ // Read the live caret before testing token and structure boundaries.
508
+ syncCaret();
509
+ keydown(event);
510
+ };
378
511
  run.append(input);
379
512
  }
380
513
  else {
@@ -424,6 +557,19 @@ export function mountMathEditor(host, initial = {}) {
424
557
  }
425
558
  const structure = element('span', `me-structure me-${child.type}`);
426
559
  structure.dataset.structure = child.type;
560
+ const structureSpacing = spacing.get(child.id);
561
+ if (structureSpacing) {
562
+ structure.dataset.mathSpacing = 'true';
563
+ structure.style.setProperty('--me-space-before', String(structureSpacing.before));
564
+ structure.style.setProperty('--me-space-after', String(structureSpacing.after));
565
+ structure.style.setProperty('--me-tight-before', String(structureSpacing.tightBefore ?? 0));
566
+ structure.style.setProperty('--me-tight-after', String(structureSpacing.tightAfter ?? 0));
567
+ }
568
+ const operatorLayout = layout.operators.get(child.id);
569
+ if (operatorLayout) {
570
+ structure.dataset.operatorStyle = operatorLayout.style;
571
+ structure.dataset.layoutLimits = operatorLayout.placement;
572
+ }
427
573
  if (child.mathStyle)
428
574
  structure.dataset.mathStyle = child.mathStyle;
429
575
  if (['limit', 'limsup', 'liminf'].includes(child.type)) {
@@ -507,6 +653,7 @@ export function mountMathEditor(host, initial = {}) {
507
653
  const start = Number(input.dataset.tokenStart);
508
654
  input.setSelectionRange(state.caret.start - start, state.caret.end - start);
509
655
  }
656
+ renderContextFooter();
510
657
  rendering = false;
511
658
  drawMenu();
512
659
  };
@@ -548,6 +695,38 @@ export function mountMathEditor(host, initial = {}) {
548
695
  }
549
696
  return false;
550
697
  }
698
+ function tokenNavigation(event) {
699
+ if (composing || !isTokenNavigationKey(event, win.navigator.platform))
700
+ return false;
701
+ const snapshot = session.getSnapshot();
702
+ if (snapshot.matrixRange)
703
+ return false;
704
+ event.preventDefault();
705
+ const state = snapshot.state;
706
+ const selection = snapshot.range ??
707
+ (state.caret.start !== state.caret.end
708
+ ? {
709
+ anchor: { id: state.caret.id, offset: state.caret.start },
710
+ focus: { id: state.caret.id, offset: state.caret.end },
711
+ }
712
+ : undefined);
713
+ if (event.shiftKey) {
714
+ session.selectRange(extendKeyboardRange(state, selection, event.key, 'token'));
715
+ surface.focus();
716
+ }
717
+ else {
718
+ const point = collapseKeyboardRange(state.document, selection, event.key) ??
719
+ extendKeyboardRange(state, undefined, event.key, 'token').focus;
720
+ editing = true;
721
+ session.select({
722
+ id: point.id,
723
+ start: point.offset,
724
+ end: point.offset,
725
+ affinity: event.key === 'ArrowLeft' ? 'forward' : 'backward',
726
+ });
727
+ }
728
+ return true;
729
+ }
551
730
  function keyboardSelect(event) {
552
731
  if (composing ||
553
732
  event.isComposing ||
@@ -563,6 +742,40 @@ export function mountMathEditor(host, initial = {}) {
563
742
  surface.focus();
564
743
  return true;
565
744
  }
745
+ function directSelectionKey(event) {
746
+ if (composing || event.isComposing)
747
+ return false;
748
+ const snapshot = session.getSnapshot();
749
+ const next = wrapSelectionShortcut(snapshot.state, snapshot.range, event);
750
+ if (!next)
751
+ return false;
752
+ event.preventDefault();
753
+ if (next !== snapshot.state)
754
+ apply(next);
755
+ return true;
756
+ }
757
+ function selectionMenuKey(event) {
758
+ if (menu.hidden ||
759
+ menu.dataset.kind !== 'selection' ||
760
+ event.shiftKey ||
761
+ event.ctrlKey ||
762
+ event.metaKey ||
763
+ !['ArrowUp', 'ArrowDown', 'Enter'].includes(event.key))
764
+ return false;
765
+ const items = [...menu.querySelectorAll('[role="option"]')];
766
+ if (!items.length)
767
+ return false;
768
+ // The visible menu owns vertical arrows before a model range can collapse.
769
+ event.preventDefault();
770
+ if (event.key === 'Enter')
771
+ items[candidateIndex % items.length].click();
772
+ else {
773
+ candidateIndex =
774
+ (candidateIndex + (event.key === 'ArrowDown' ? 1 : items.length - 1)) % items.length;
775
+ drawMenu();
776
+ }
777
+ return true;
778
+ }
566
779
  function keyboardCollapse(event) {
567
780
  if (event.shiftKey || event.altKey || event.ctrlKey || event.metaKey)
568
781
  return false;
@@ -576,15 +789,43 @@ export function mountMathEditor(host, initial = {}) {
576
789
  return true;
577
790
  }
578
791
  function keydown(event) {
792
+ if (!['ArrowUp', 'ArrowDown', 'Escape'].includes(event.key) ||
793
+ event.shiftKey ||
794
+ event.altKey ||
795
+ event.ctrlKey ||
796
+ event.metaKey)
797
+ verticalNavigation.reset();
579
798
  event.stopPropagation();
580
799
  if (event.isComposing || composing)
581
800
  return;
801
+ if (focusContextFooter(event))
802
+ return;
803
+ if (event.altKey &&
804
+ !event.shiftKey &&
805
+ !event.ctrlKey &&
806
+ !event.metaKey &&
807
+ event.key === 'ArrowDown') {
808
+ event.preventDefault();
809
+ dismissed = '';
810
+ const result = candidates();
811
+ candidateIndex = Math.max(0, result.candidates.findIndex((item) => item.transformFenceId || item.transformRootId));
812
+ const snapshot = session.getSnapshot();
813
+ const active = textNodes(snapshot.state.document).find((node) => node.id === snapshot.state.caret.id);
814
+ operandMenuKey = `${active.id}:${active.text}:${snapshot.state.caret.start}`;
815
+ drawMenu();
816
+ return;
817
+ }
582
818
  if (event.altKey && event.shiftKey && event.code === 'KeyV') {
583
819
  event.preventDefault();
584
820
  openLatex();
585
821
  return;
586
822
  }
587
- if (matrixSelectionKey(event) || keyboardSelect(event) || keyboardCollapse(event))
823
+ if (matrixSelectionKey(event) ||
824
+ tokenNavigation(event) ||
825
+ keyboardSelect(event) ||
826
+ directSelectionKey(event) ||
827
+ selectionMenuKey(event) ||
828
+ keyboardCollapse(event))
588
829
  return;
589
830
  const snapshot = session.getSnapshot(), state = snapshot.state, c = state.caret, active = textNodes(state.document).find((n) => n.id === c.id);
590
831
  if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'z') {
@@ -598,7 +839,10 @@ export function mountMathEditor(host, initial = {}) {
598
839
  if (result.candidates.length && ['ArrowDown', 'ArrowUp'].includes(event.key) && !event.altKey) {
599
840
  event.preventDefault();
600
841
  const key = `${active.id}:${active.text}:${c.start}`;
601
- const initialOperandChoice = result.candidates[0]?.wrapOperand && operandMenuKey !== key;
842
+ const initialOperandChoice = (result.candidates[0]?.wrapOperand ||
843
+ result.candidates[0]?.transformRootId ||
844
+ result.candidates[0]?.transformFenceId) &&
845
+ operandMenuKey !== key;
602
846
  operandMenuKey = key;
603
847
  candidateIndex = initialOperandChoice
604
848
  ? event.key === 'ArrowDown'
@@ -629,6 +873,17 @@ export function mountMathEditor(host, initial = {}) {
629
873
  }
630
874
  return;
631
875
  }
876
+ const acceptsSuggestion = event.key === 'Enter' &&
877
+ !event.shiftKey &&
878
+ result.candidates.length > 0 &&
879
+ ((!result.candidates[0].wrapOperand &&
880
+ !result.candidates[0].transformRootId &&
881
+ !result.candidates[0].transformFenceId) ||
882
+ operandMenuKey === `${active.id}:${active.text}:${c.start}`);
883
+ if (options.onEditKeyDown?.(event, snapshot, acceptsSuggestion)) {
884
+ event.preventDefault();
885
+ return;
886
+ }
632
887
  if (event.key === 'Enter') {
633
888
  event.preventDefault();
634
889
  const action = mathEnterAction({
@@ -636,7 +891,9 @@ export function mountMathEditor(host, initial = {}) {
636
891
  behavior: options.enterBehavior,
637
892
  shiftKey: event.shiftKey,
638
893
  hasSuggestion: result.candidates.length > 0 &&
639
- (!result.candidates[0].wrapOperand ||
894
+ ((!result.candidates[0].wrapOperand &&
895
+ !result.candidates[0].transformRootId &&
896
+ !result.candidates[0].transformFenceId) ||
640
897
  operandMenuKey === `${active.id}:${active.text}:${c.start}`),
641
898
  gridKind: activeGrid(state)?.matrix.type,
642
899
  });
@@ -657,7 +914,7 @@ export function mountMathEditor(host, initial = {}) {
657
914
  if (activeGrid(state) &&
658
915
  event.altKey &&
659
916
  event.shiftKey &&
660
- ['ArrowUp', 'ArrowLeft'].includes(event.key)) {
917
+ ['ArrowUp', 'Backspace'].includes(event.key)) {
661
918
  event.preventDefault();
662
919
  apply(resizeMatrix(state, event.key === 'ArrowUp' ? 'row' : 'column', 'delete'));
663
920
  return;
@@ -696,7 +953,7 @@ export function mountMathEditor(host, initial = {}) {
696
953
  }
697
954
  if (!event.shiftKey && ['ArrowUp', 'ArrowDown'].includes(event.key)) {
698
955
  const direction = event.key === 'ArrowUp' ? -1 : 1;
699
- const next = moveVertical(state, direction, renderedCaretGeometry(surface));
956
+ const next = verticalNavigation.move(state, direction, renderedCaretGeometry(surface));
700
957
  if (next !== state) {
701
958
  event.preventDefault();
702
959
  session.select(next.caret);
@@ -704,6 +961,12 @@ export function mountMathEditor(host, initial = {}) {
704
961
  return;
705
962
  }
706
963
  if (c.start === c.end && ['Backspace', 'Delete'].includes(event.key)) {
964
+ const unwrapped = unwrapEmptySlot(state);
965
+ if (unwrapped !== state) {
966
+ event.preventDefault();
967
+ apply(unwrapped);
968
+ return;
969
+ }
707
970
  const input = currentInput();
708
971
  const left = event.key === 'Backspace';
709
972
  if ((left && c.start > 0 && c.start === Number(input?.dataset.tokenStart)) ||
@@ -728,6 +991,14 @@ export function mountMathEditor(host, initial = {}) {
728
991
  }
729
992
  return;
730
993
  }
994
+ if (event.key === 'Delete' && c.end === active.text.length) {
995
+ const joined = joinNextLine(state);
996
+ const next = joined !== state ? joined : unwrapNext(state);
997
+ if (next !== state) {
998
+ event.preventDefault();
999
+ apply(next);
1000
+ }
1001
+ }
731
1002
  if (event.key === 'Backspace' && c.start === 0) {
732
1003
  const joined = joinPreviousLine(state), next = joined !== state ? joined : unwrapPrevious(state);
733
1004
  if (next !== state) {
@@ -777,6 +1048,7 @@ export function mountMathEditor(host, initial = {}) {
777
1048
  };
778
1049
  };
779
1050
  surface.onpointerdown = (event) => {
1051
+ verticalNavigation.reset();
780
1052
  if (event.button || composing || drag || event.target.closest('.me-line-number'))
781
1053
  return;
782
1054
  const point = pointAt(event.clientX, event.clientY);
@@ -882,37 +1154,28 @@ export function mountMathEditor(host, initial = {}) {
882
1154
  doc.addEventListener('pointercancel', cancelPointer, true);
883
1155
  win.addEventListener('blur', cancelDrag);
884
1156
  surface.onkeydown = (event) => {
1157
+ if (event.target !== currentInput())
1158
+ verticalNavigation.reset();
885
1159
  if (event.target === currentInput())
886
1160
  return;
887
1161
  event.stopPropagation();
888
1162
  if (composing || event.isComposing)
889
1163
  return;
1164
+ if (focusContextFooter(event))
1165
+ return;
890
1166
  if (event.altKey && event.shiftKey && event.code === 'KeyV') {
891
1167
  event.preventDefault();
892
1168
  openLatex();
893
1169
  return;
894
1170
  }
895
- if (matrixSelectionKey(event) || keyboardSelect(event) || keyboardCollapse(event))
1171
+ if (matrixSelectionKey(event) ||
1172
+ tokenNavigation(event) ||
1173
+ keyboardSelect(event) ||
1174
+ directSelectionKey(event) ||
1175
+ selectionMenuKey(event) ||
1176
+ keyboardCollapse(event))
896
1177
  return;
897
1178
  const snapshot = session.getSnapshot();
898
- if (snapshot.range &&
899
- !event.ctrlKey &&
900
- !event.metaKey &&
901
- !event.shiftKey &&
902
- (event.key === 'Enter' || (event.altKey && ['ArrowDown', 'ArrowUp'].includes(event.key)))) {
903
- const items = [...menu.querySelectorAll('[role="option"]')];
904
- if (items.length) {
905
- event.preventDefault();
906
- if (event.key === 'Enter')
907
- items[candidateIndex % items.length].click();
908
- else {
909
- candidateIndex =
910
- (candidateIndex + (event.key === 'ArrowDown' ? 1 : items.length - 1)) % items.length;
911
- drawMenu();
912
- }
913
- return;
914
- }
915
- }
916
1179
  if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'a') {
917
1180
  event.preventDefault();
918
1181
  const nodes = textNodes(snapshot.state.document);
@@ -974,6 +1237,12 @@ export function mountMathEditor(host, initial = {}) {
974
1237
  if (composing)
975
1238
  return;
976
1239
  const data = event.clipboardData, fragment = parseFragment(data?.getData(MATH_CLIPBOARD_TYPE) ?? ''), text = data?.getData('text/plain') ?? '';
1240
+ if (data?.getData(MATH_CLIPBOARD_TYPE) && !fragment) {
1241
+ event.preventDefault();
1242
+ event.stopPropagation();
1243
+ notify('clipboard.invalid');
1244
+ return;
1245
+ }
977
1246
  const matrixSource = data?.getData(MATH_MATRIX_CLIPBOARD_TYPE) ?? '';
978
1247
  const matrix = matrixSource ? parseMatrixFragment(matrixSource) : matrixFromTSV(text);
979
1248
  const snapshot = session.getSnapshot();
@@ -1034,6 +1303,8 @@ export function mountMathEditor(host, initial = {}) {
1034
1303
  toolbar?.setDisabled(composing);
1035
1304
  };
1036
1305
  const unsubscribe = session.subscribe((snapshot, changed) => {
1306
+ if (changed)
1307
+ verticalNavigation.reset();
1037
1308
  if (changed) {
1038
1309
  cancelDrag();
1039
1310
  selectionMenu.reset();
@@ -1042,19 +1313,19 @@ export function mountMathEditor(host, initial = {}) {
1042
1313
  if (changed)
1043
1314
  options.onChange?.(snapshot.state.document, snapshot.latex);
1044
1315
  });
1045
- const reposition = (event) => {
1046
- // Scrolling the candidate list must not rebuild it and reset its scroll position.
1047
- if (event.target && 'nodeType' in event.target && menu.contains(event.target))
1048
- return;
1316
+ const stopReadableLayout = followReadableMathLayout(surface);
1317
+ const stopPosition = followMathMenuPosition(root, () => {
1049
1318
  if (!menu.hidden)
1050
1319
  drawMenu();
1051
- };
1052
- win.addEventListener('resize', reposition);
1053
- win.addEventListener('scroll', reposition, true);
1320
+ }, menu);
1054
1321
  rebuildToolbar();
1055
1322
  render();
1056
1323
  return {
1057
1324
  session,
1325
+ showHelp() {
1326
+ if (!destroyed)
1327
+ requestMathHelp(root);
1328
+ },
1058
1329
  focus() {
1059
1330
  editing = true;
1060
1331
  render();
@@ -1073,11 +1344,12 @@ export function mountMathEditor(host, initial = {}) {
1073
1344
  destroyed = true;
1074
1345
  cancelDrag();
1075
1346
  unsubscribe();
1347
+ stopHelp();
1076
1348
  latexPanel?.destroy();
1077
1349
  toolbar?.destroy();
1078
1350
  root.removeEventListener('focusout', blur);
1079
- win.removeEventListener('resize', reposition);
1080
- win.removeEventListener('scroll', reposition, true);
1351
+ stopPosition();
1352
+ stopReadableLayout();
1081
1353
  doc.removeEventListener('pointermove', movePointer, true);
1082
1354
  doc.removeEventListener('pointerup', endPointer, true);
1083
1355
  doc.removeEventListener('pointercancel', cancelPointer, true);
@@ -4,7 +4,7 @@ export function mathEnterAction(context) {
4
4
  return 'suggestion';
5
5
  if (context.gridKind && context.shiftKey)
6
6
  return 'grid-row';
7
- if (context.mode === 'inline' || context.behavior === 'commit')
7
+ if (context.mode === 'inline' || (context.behavior === 'commit' && !context.shiftKey))
8
8
  return 'commit';
9
9
  if (context.gridKind)
10
10
  return context.gridKind === 'matrix' ? 'none' : 'grid-row';
package/dist/latex.js CHANGED
@@ -391,6 +391,17 @@ class LatexParser {
391
391
  return row(command);
392
392
  if (command === 'backslash')
393
393
  return row('\\');
394
+ if (command === 'char') {
395
+ // Canonical math-mode literal caret/tilde output. This is intentionally
396
+ // bounded; arbitrary TeX character codes are not part of the model API.
397
+ this.skip();
398
+ const match = this.source.slice(this.at, this.end).match(/^"([0-9a-f]+)/i);
399
+ const code = match ? Number.parseInt(match[1], 16) : NaN;
400
+ if (code !== 0x5e && code !== 0x7e)
401
+ this.fail('unsupported-command', 'Only literal caret and tilde character codes are supported.', start);
402
+ this.at += match[0].length;
403
+ return row(String.fromCodePoint(code));
404
+ }
394
405
  if (command === 'textasciicircum' || command === 'textasciitilde') {
395
406
  this.skip();
396
407
  if (!this.starts('{}'))
package/dist/lines.d.ts CHANGED
@@ -3,4 +3,6 @@ import { type MathState } from './model.js';
3
3
  export declare function splitLine(state: MathState): MathState;
4
4
  /** Backspace at the start of a top-level line merges it with the previous line. */
5
5
  export declare function joinPreviousLine(state: MathState): MathState;
6
+ /** Delete at the end of a top-level line joins the following line without moving the caret. */
7
+ export declare function joinNextLine(state: MathState): MathState;
6
8
  export declare function moveLineVertical(state: MathState, direction: -1 | 1): MathState;