@barocss/math-editor 0.2.1 → 0.5.0

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 (202) hide show
  1. package/ADAPTERS.md +25 -14
  2. package/API-JAVASCRIPT.md +1 -1
  3. package/API-REACT.md +5 -1
  4. package/API-SESSION.md +178 -6
  5. package/API-SOLID.md +1 -1
  6. package/API-SVELTE.md +1 -1
  7. package/API-VUE.md +1 -1
  8. package/API-WEB-COMPONENT.md +3 -1
  9. package/CHANGELOG.md +106 -0
  10. package/EDITING-SCENARIOS.md +174 -0
  11. package/EMBEDDING.md +4 -0
  12. package/IMPLEMENTATION.md +122 -2
  13. package/JSON-MODEL.md +1 -0
  14. package/LATEX-GUIDE.md +30 -1
  15. package/LATEX-MODEL.md +12 -0
  16. package/LATEX-SCOPE.md +12 -3
  17. package/LICENSE +1 -3
  18. package/README.md +201 -39
  19. package/RELEASING.md +81 -10
  20. package/RENDERING-TESTS.md +79 -0
  21. package/ROADMAP.md +220 -11
  22. package/STYLING.md +157 -0
  23. package/SUPPORT.md +24 -12
  24. package/VALIDATION.md +444 -4
  25. package/dist/context-tools.d.ts +21 -0
  26. package/dist/context-tools.js +37 -0
  27. package/dist/core.d.ts +5 -1
  28. package/dist/core.js +5 -1
  29. package/dist/document-codec.d.ts +0 -1
  30. package/dist/document-codec.js +0 -1
  31. package/dist/dom/caret-geometry.d.ts +3 -0
  32. package/dist/dom/caret-geometry.js +41 -0
  33. package/dist/dom/context-keyboard.d.ts +2 -0
  34. package/dist/dom/context-keyboard.js +22 -0
  35. package/dist/dom/elements.d.ts +1 -2
  36. package/dist/dom/elements.js +2 -3
  37. package/dist/dom/latex-panel.d.ts +10 -0
  38. package/dist/dom/latex-panel.js +59 -0
  39. package/dist/dom/matrix-selection.d.ts +11 -0
  40. package/dist/dom/matrix-selection.js +30 -0
  41. package/dist/dom/menu-position.d.ts +17 -2
  42. package/dist/dom/menu-position.js +129 -16
  43. package/dist/dom/presentation-controls.d.ts +4 -0
  44. package/dist/dom/presentation-controls.js +56 -0
  45. package/dist/dom/quick-panel.d.ts +12 -0
  46. package/dist/dom/quick-panel.js +102 -0
  47. package/dist/dom/selection.d.ts +29 -0
  48. package/dist/dom/selection.js +53 -0
  49. package/dist/dom/symbol-panel.d.ts +9 -0
  50. package/dist/dom/symbol-panel.js +146 -0
  51. package/dist/dom/theme.d.ts +6 -0
  52. package/dist/dom/theme.js +68 -0
  53. package/dist/dom/toolbar-catalog.d.ts +10 -0
  54. package/dist/dom/toolbar-catalog.js +35 -0
  55. package/dist/dom/toolbar.d.ts +13 -2
  56. package/dist/dom/toolbar.js +295 -28
  57. package/dist/dom.d.ts +9 -1
  58. package/dist/dom.js +682 -151
  59. package/dist/editor-labels.d.ts +0 -1
  60. package/dist/editor-labels.js +0 -1
  61. package/dist/enter-policy.d.ts +0 -1
  62. package/dist/enter-policy.js +0 -1
  63. package/dist/fences.d.ts +0 -1
  64. package/dist/fences.js +0 -1
  65. package/dist/i18n.d.ts +0 -1
  66. package/dist/i18n.js +0 -1
  67. package/dist/index.d.ts +4 -1
  68. package/dist/index.js +4 -1
  69. package/dist/latex-insertion.d.ts +12 -0
  70. package/dist/latex-insertion.js +26 -0
  71. package/dist/latex-paste-panel.d.ts +3 -0
  72. package/dist/latex-paste-panel.js +18 -0
  73. package/dist/latex.d.ts +0 -1
  74. package/dist/latex.js +11 -1
  75. package/dist/lines.d.ts +0 -1
  76. package/dist/lines.js +0 -1
  77. package/dist/locales/en.js +39 -1
  78. package/dist/locales/en.json +39 -1
  79. package/dist/locales/ko.js +39 -1
  80. package/dist/locales/ko.json +39 -1
  81. package/dist/math-editor-toolbar.d.ts +4 -2
  82. package/dist/math-editor-toolbar.js +3 -4
  83. package/dist/math-editor.d.ts +7 -2
  84. package/dist/math-editor.js +661 -308
  85. package/dist/math-layout.d.ts +12 -0
  86. package/dist/math-layout.js +59 -0
  87. package/dist/math-spacing.d.ts +13 -0
  88. package/dist/math-spacing.js +87 -0
  89. package/dist/matrix-range.d.ts +40 -0
  90. package/dist/matrix-range.js +242 -0
  91. package/dist/matrix.d.ts +0 -1
  92. package/dist/matrix.js +0 -1
  93. package/dist/model.d.ts +4 -1
  94. package/dist/model.js +49 -5
  95. package/dist/outputs.d.ts +0 -1
  96. package/dist/outputs.js +0 -1
  97. package/dist/preferences.d.ts +24 -0
  98. package/dist/preferences.js +66 -0
  99. package/dist/presentation-controls.d.ts +10 -0
  100. package/dist/presentation-controls.js +10 -0
  101. package/dist/presentation.d.ts +13 -0
  102. package/dist/presentation.js +86 -0
  103. package/dist/quick-panel.d.ts +2 -0
  104. package/dist/quick-panel.js +17 -0
  105. package/dist/range.d.ts +2 -1
  106. package/dist/range.js +11 -1
  107. package/dist/react.d.ts +0 -1
  108. package/dist/react.js +0 -1
  109. package/dist/root-transform.d.ts +19 -0
  110. package/dist/root-transform.js +69 -0
  111. package/dist/session.d.ts +31 -2
  112. package/dist/session.js +108 -7
  113. package/dist/solid.d.ts +0 -1
  114. package/dist/solid.js +0 -1
  115. package/dist/suggestions.d.ts +13 -1
  116. package/dist/suggestions.js +153 -12
  117. package/dist/svelte.d.ts +0 -1
  118. package/dist/svelte.js +0 -1
  119. package/dist/symbol-browser.d.ts +0 -1
  120. package/dist/symbol-browser.js +0 -1
  121. package/dist/symbols.d.ts +0 -1
  122. package/dist/symbols.js +0 -1
  123. package/dist/templates.d.ts +0 -1
  124. package/dist/templates.js +0 -1
  125. package/dist/tokens.d.ts +0 -1
  126. package/dist/tokens.js +0 -1
  127. package/dist/vertical-navigation.d.ts +15 -0
  128. package/dist/vertical-navigation.js +145 -0
  129. package/dist/vue.d.ts +0 -1
  130. package/dist/vue.js +0 -1
  131. package/dist/web-component.d.ts +0 -1
  132. package/dist/web-component.js +9 -2
  133. package/package.json +11 -3
  134. package/src/fonts/KaTeX_Math-Italic.woff2 +0 -0
  135. package/src/fonts/KaTeX_Size1-Regular.woff2 +0 -0
  136. package/src/fonts/README.md +18 -0
  137. package/src/shapes/README.md +21 -0
  138. package/src/shapes/parenthesis-bottom.svg +1 -0
  139. package/src/shapes/parenthesis-top.svg +1 -0
  140. package/src/shapes/parenthesis.svg +1 -0
  141. package/src/shapes/radical.svg +1 -0
  142. package/src/style.css +949 -173
  143. package/dist/core.d.ts.map +0 -1
  144. package/dist/core.js.map +0 -1
  145. package/dist/document-codec.d.ts.map +0 -1
  146. package/dist/document-codec.js.map +0 -1
  147. package/dist/dom/elements.d.ts.map +0 -1
  148. package/dist/dom/elements.js.map +0 -1
  149. package/dist/dom/menu-position.d.ts.map +0 -1
  150. package/dist/dom/menu-position.js.map +0 -1
  151. package/dist/dom/toolbar.d.ts.map +0 -1
  152. package/dist/dom/toolbar.js.map +0 -1
  153. package/dist/dom.d.ts.map +0 -1
  154. package/dist/dom.js.map +0 -1
  155. package/dist/editor-labels.d.ts.map +0 -1
  156. package/dist/editor-labels.js.map +0 -1
  157. package/dist/enter-policy.d.ts.map +0 -1
  158. package/dist/enter-policy.js.map +0 -1
  159. package/dist/fences.d.ts.map +0 -1
  160. package/dist/fences.js.map +0 -1
  161. package/dist/i18n.d.ts.map +0 -1
  162. package/dist/i18n.js.map +0 -1
  163. package/dist/index.d.ts.map +0 -1
  164. package/dist/index.js.map +0 -1
  165. package/dist/latex.d.ts.map +0 -1
  166. package/dist/latex.js.map +0 -1
  167. package/dist/lines.d.ts.map +0 -1
  168. package/dist/lines.js.map +0 -1
  169. package/dist/math-editor-toolbar.d.ts.map +0 -1
  170. package/dist/math-editor-toolbar.js.map +0 -1
  171. package/dist/math-editor.d.ts.map +0 -1
  172. package/dist/math-editor.js.map +0 -1
  173. package/dist/matrix.d.ts.map +0 -1
  174. package/dist/matrix.js.map +0 -1
  175. package/dist/model.d.ts.map +0 -1
  176. package/dist/model.js.map +0 -1
  177. package/dist/outputs.d.ts.map +0 -1
  178. package/dist/outputs.js.map +0 -1
  179. package/dist/range.d.ts.map +0 -1
  180. package/dist/range.js.map +0 -1
  181. package/dist/react.d.ts.map +0 -1
  182. package/dist/react.js.map +0 -1
  183. package/dist/session.d.ts.map +0 -1
  184. package/dist/session.js.map +0 -1
  185. package/dist/solid.d.ts.map +0 -1
  186. package/dist/solid.js.map +0 -1
  187. package/dist/suggestions.d.ts.map +0 -1
  188. package/dist/suggestions.js.map +0 -1
  189. package/dist/svelte.d.ts.map +0 -1
  190. package/dist/svelte.js.map +0 -1
  191. package/dist/symbol-browser.d.ts.map +0 -1
  192. package/dist/symbol-browser.js.map +0 -1
  193. package/dist/symbols.d.ts.map +0 -1
  194. package/dist/symbols.js.map +0 -1
  195. package/dist/templates.d.ts.map +0 -1
  196. package/dist/templates.js.map +0 -1
  197. package/dist/tokens.d.ts.map +0 -1
  198. package/dist/tokens.js.map +0 -1
  199. package/dist/vue.d.ts.map +0 -1
  200. package/dist/vue.js.map +0 -1
  201. package/dist/web-component.d.ts.map +0 -1
  202. package/dist/web-component.js.map +0 -1
package/dist/dom.js CHANGED
@@ -1,45 +1,89 @@
1
- import { positionMathMenu } from './dom/menu-position.js';
2
- import { fences } from './fences.js';
1
+ import { mathRunSpacing } from './math-spacing.js';
2
+ import { mathLayout } from './math-layout.js';
3
+ import { transformRoot } from './root-transform.js';
4
+ import { structureEditingContext, changeContextFence } from './context-tools.js';
5
+ import { focusContextAction } from './dom/context-keyboard.js';
6
+ import { mountLatexPanel } from './dom/latex-panel.js';
7
+ import { followMathTheme, syncMathTheme } from './dom/theme.js';
8
+ import { matrixPointerRange } from './dom/matrix-selection.js';
9
+ import { positionMathMenu, followMathMenuPosition } from './dom/menu-position.js';
10
+ import { fences, fencePairs } from './fences.js';
3
11
  import { createEditorLabels } from './editor-labels.js';
4
12
  import { mathEnterAction } from './enter-policy.js';
5
13
  export { mountMathLatex, mountMathPreview, } from './outputs.js';
6
- import { element } from './dom/elements.js';
14
+ import { elementFactory } from './dom/elements.js';
7
15
  import { mountMathToolbar } from './dom/toolbar.js';
8
16
  export { mountMathToolbar } from './dom/toolbar.js';
9
17
  import { createMathSession, } from './session.js';
10
18
  import { translate, mathLocaleDirection } from './i18n.js';
11
- import { documentRows, isLiteralText, textNodes, setText, moveCaret, unwrapPrevious, gridDeletionTarget, removeGrid, isGrid, toLatex, } from './model.js';
12
- import { activeGrid, moveMatrixVertical, resizeMatrix } from './matrix.js';
13
- import { joinPreviousLine, moveLineVertical } from './lines.js';
14
- import { acceptSuggestion, findSuggestions, mathStructures } from './suggestions.js';
15
- import { tokenizeMathText } from './tokens.js';
16
- import { fragmentLatex, parseFragment, MATH_CLIPBOARD_TYPE, extendKeyboardRange, resolveRange, copyRange, wrappingKinds, } from './range.js';
19
+ import { documentRows, isLiteralText, textNodes, setText, moveCaret, unwrapPrevious, unwrapEmptySlot, gridDeletionTarget, removeGrid, isGrid, toLatex, } from './model.js';
20
+ import { activeGrid, activeMatrix, resizeMatrix } from './matrix.js';
21
+ import { MATH_MATRIX_CLIPBOARD_TYPE, parseMatrixFragment, matrixFragment, matrixFromTSV, matrixRangeBetween, resolveMatrixRange, extendMatrixRange, focusMatrixCell, } from './matrix-range.js';
22
+ import { joinPreviousLine } from './lines.js';
23
+ import { moveVertical } from './vertical-navigation.js';
24
+ import { renderedCaretGeometry } from './dom/caret-geometry.js';
25
+ import { extendsMathSelection, nativeSelectionAnchor, createSelectionMenuState, textSelectionBounds, } from './dom/selection.js';
26
+ import { acceptSuggestion, findStateSuggestions, mathStructures } from './suggestions.js';
27
+ import { tokenizeMathText, tokenIndexAt } from './tokens.js';
28
+ import { fragmentLatex, parseFragment, MATH_CLIPBOARD_TYPE, extendKeyboardRange, collapseKeyboardRange, resolveRange, copyRange, wrappingKinds, } from './range.js';
17
29
  /** Native DOM renderer. No React or framework runtime is imported by this module. */
18
30
  export function mountMathEditor(host, initial = {}) {
31
+ const doc = host.ownerDocument;
32
+ const win = doc.defaultView;
33
+ const element = elementFactory(doc);
19
34
  let options = { ...initial };
20
35
  const session = initial.session ??
21
36
  createMathSession({
22
37
  document: initial.defaultValue,
38
+ preferences: initial.preferences,
23
39
  locale: initial.locale,
24
40
  mode: initial.mode,
25
41
  });
26
42
  const ownsSession = !initial.session;
27
43
  if (initial.session)
28
44
  session.configure({ locale: initial.locale, mode: initial.mode });
29
- const root = element('span', 'me-editor me-dom-editor'), tools = element('span'), surface = element('span', 'me-surface'), status = element('span', 'me-dom-status');
30
- root.append(tools, surface, status);
45
+ 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');
46
+ root.append(tools, surface, contextFooter, status);
31
47
  host.append(root);
32
48
  surface.tabIndex = 0;
33
49
  surface.setAttribute('role', 'group');
34
50
  status.setAttribute('role', 'status');
35
51
  const menu = element('div', 'me-suggestion-panel me-dom-menu');
36
52
  menu.hidden = true;
37
- (initial.menuHost ?? host.closest('dialog, [role="dialog"]') ?? document.body).append(menu);
53
+ (initial.menuHost ?? host.closest('dialog, [role="dialog"]') ?? doc.body).append(menu);
54
+ const stopTheme = followMathTheme(root, menu);
38
55
  let editing = false, rendering = false, composing = false, destroyed = false, dismissed = '', operandMenuKey = '', candidateIndex = 0, selectedGrid;
39
56
  let drag;
57
+ let dragMatrix;
58
+ const selectionMenu = createSelectionMenuState();
40
59
  let toolbar;
60
+ let latexPanel;
61
+ const closeLatex = () => {
62
+ latexPanel?.destroy();
63
+ latexPanel = undefined;
64
+ editing = true;
65
+ render();
66
+ };
67
+ const openLatex = () => {
68
+ if (composing || destroyed || latexPanel)
69
+ return;
70
+ toolbar?.closePanels();
71
+ menu.hidden = true;
72
+ latexPanel = mountLatexPanel(root, {
73
+ locale: session.getSnapshot().locale,
74
+ onInsert: (source) => session.pasteLatex(source),
75
+ onClose: closeLatex,
76
+ });
77
+ };
41
78
  const t = (value) => translate(session.getSnapshot().locale, value);
42
79
  const currentInput = () => surface.querySelector('input');
80
+ const cancelDrag = () => {
81
+ const previous = drag;
82
+ drag = undefined;
83
+ dragMatrix = undefined;
84
+ if (previous && surface.hasPointerCapture(previous.pointerId))
85
+ surface.releasePointerCapture(previous.pointerId);
86
+ };
43
87
  const notify = (key) => {
44
88
  status.textContent = t(key);
45
89
  };
@@ -57,10 +101,11 @@ export function mountMathEditor(host, initial = {}) {
57
101
  };
58
102
  const candidates = () => {
59
103
  const snapshot = session.getSnapshot(), state = snapshot.state, active = textNodes(state.document).find((n) => n.id === state.caret.id);
60
- const result = findSuggestions(active.text, state.caret.start, snapshot.locale);
104
+ const result = findStateSuggestions(state, snapshot.locale);
61
105
  if (!editing ||
62
106
  isLiteralText(state.document, state.caret.id) ||
63
107
  snapshot.range ||
108
+ snapshot.matrixRange ||
64
109
  dismissed === `${active.id}:${active.text}:${state.caret.start}`)
65
110
  result.candidates = [];
66
111
  return result;
@@ -73,38 +118,94 @@ export function mountMathEditor(host, initial = {}) {
73
118
  dismissed = '';
74
119
  candidateIndex = 0;
75
120
  apply(acceptSuggestion(session.getSnapshot().state, result.query, item));
121
+ session.preferences.record(item.id);
76
122
  }
77
123
  };
78
- const drawMenu = () => {
124
+ const suggestionButton = (glyph, label, detail) => {
125
+ const button = element('button');
126
+ button.type = 'button';
127
+ button.setAttribute('role', 'option');
128
+ // Keep the concise option name while showing the same detail as the rich UI.
129
+ button.setAttribute('aria-label', `${glyph} ${label}`);
130
+ const copy = element('span', 'me-candidate-copy');
131
+ copy.append(element('strong', '', label), element('small', '', detail));
132
+ button.append(element('span', 'me-candidate-glyph', glyph), copy);
133
+ button.onmousedown = (event) => event.preventDefault();
134
+ button.onmousemove = (event) => {
135
+ // Rebuilding or scrolling the list can put a new row under a stationary
136
+ // pointer. Only real pointer movement may replace the keyboard choice.
137
+ if (composing || button.disabled || (!event.movementX && !event.movementY))
138
+ return;
139
+ const buttons = Array.from(button.parentElement.children);
140
+ const index = buttons.indexOf(button);
141
+ if (candidateIndex === index && button.getAttribute('aria-selected') === 'true')
142
+ return;
143
+ candidateIndex = index;
144
+ // Update the highlight in place: rebuilding would reset a wheel-scrolled list.
145
+ for (const option of buttons)
146
+ option.setAttribute('aria-selected', String(option === button));
147
+ // Host observers also see these attribute updates and must retain the input caret.
148
+ options.onRender?.();
149
+ };
150
+ return button;
151
+ };
152
+ const positionMenu = (anchor) => {
153
+ delete menu.dataset.compact;
154
+ positionMathMenu(menu, anchor, 340, options.menuAvoidElements?.());
155
+ // In a short dialog, leave the available space for selectable rows.
156
+ if (menu.getBoundingClientRect().height < 180) {
157
+ menu.dataset.compact = 'true';
158
+ positionMathMenu(menu, anchor, 340, options.menuAvoidElements?.());
159
+ }
160
+ };
161
+ const scrollMenuSelection = () => {
162
+ const option = menu.querySelector('[aria-selected="true"]');
163
+ const list = option?.parentElement;
164
+ if (!option || !list)
165
+ return;
166
+ // Move only the options list, never the surrounding page or host editor.
167
+ const itemRect = option.getBoundingClientRect();
168
+ const listRect = list.getBoundingClientRect();
169
+ const scaleY = listRect.height / list.offsetHeight || 1;
170
+ if (itemRect.top < listRect.top)
171
+ list.scrollTop -= (listRect.top - itemRect.top) / scaleY;
172
+ else if (itemRect.bottom > listRect.bottom)
173
+ list.scrollTop += (itemRect.bottom - listRect.bottom) / scaleY;
174
+ };
175
+ const renderMenu = () => {
176
+ syncMathTheme(root, menu);
79
177
  const result = candidates(), input = currentInput();
80
178
  menu.replaceChildren();
81
179
  const snapshot = session.getSnapshot();
82
180
  const caret = snapshot.state.caret;
83
181
  const selectedRange = snapshot.range ??
84
- (caret.start !== caret.end
182
+ (input && caret.start !== caret.end
85
183
  ? {
86
184
  anchor: { id: caret.id, offset: caret.start },
87
185
  focus: { id: caret.id, offset: caret.end },
88
186
  }
89
187
  : undefined);
90
188
  const fragment = selectedRange && copyRange(snapshot.state.document, selectedRange);
189
+ const showSelectionMenu = selectionMenu.sync(fragment ? selectedRange : undefined);
91
190
  if (fragment) {
92
- menu.hidden = false;
191
+ menu.dataset.kind = 'selection';
192
+ menu.hidden = !showSelectionMenu;
193
+ input?.setAttribute('aria-expanded', String(showSelectionMenu));
194
+ if (!showSelectionMenu)
195
+ return;
93
196
  menu.lang = snapshot.locale;
94
197
  menu.dir = mathLocaleDirection(snapshot.locale);
198
+ const canWrap = fragment.rows.length === 1 &&
199
+ !isLiteralText(snapshot.state.document, selectedRange.anchor.id);
200
+ menu.append(element('div', 'me-suggestion-heading', t(canWrap ? 'selection.highlighted' : 'selection.singleRowRequired')));
95
201
  const list = element('div', 'me-suggestions');
96
202
  list.setAttribute('role', 'listbox');
97
203
  list.setAttribute('aria-label', t('selection.toolbar'));
98
204
  for (const item of mathStructures.filter((item) => wrappingKinds.includes(item.kind))) {
99
- const button = element('button', '', `${item.glyph} ${t(item.label)}`);
100
- button.type = 'button';
101
- button.setAttribute('role', 'option');
205
+ const button = suggestionButton(item.glyph, t(item.label), t(item.detail));
102
206
  button.setAttribute('aria-selected', String(list.children.length === candidateIndex));
103
- button.disabled =
104
- fragment.rows.length !== 1 ||
105
- isLiteralText(snapshot.state.document, selectedRange.anchor.id);
207
+ button.disabled = !canWrap;
106
208
  button.setAttribute('aria-label', translate(snapshot.locale, 'selection.wrap', { kind: t(item.label) }));
107
- button.onmousedown = (event) => event.preventDefault();
108
209
  button.onclick = () => {
109
210
  if (!snapshot.range)
110
211
  session.selectRange(selectedRange);
@@ -113,39 +214,149 @@ export function mountMathEditor(host, initial = {}) {
113
214
  list.append(button);
114
215
  }
115
216
  menu.append(list);
116
- const selected = surface.querySelectorAll('.me-dom-selected');
217
+ const selected = surface.querySelectorAll('.me-dom-selected, .me-range-highlight');
117
218
  const rect = (selected[selected.length - 1] ?? input ?? surface).getBoundingClientRect();
118
- positionMathMenu(menu, rect, 340);
219
+ positionMenu(rect);
119
220
  return;
120
221
  }
222
+ menu.dataset.kind = 'suggestions';
121
223
  menu.hidden = !input || !result.candidates.length;
122
224
  input?.setAttribute('aria-expanded', String(!menu.hidden));
123
225
  if (menu.hidden)
124
226
  return;
125
227
  menu.lang = session.getSnapshot().locale;
126
228
  menu.dir = mathLocaleDirection(menu.lang);
229
+ const heading = element('div', 'me-suggestion-heading');
230
+ heading.append(element('span', 'me-suggestion-query', translate(snapshot.locale, 'suggestion.create', { query: result.query })), element('span', '', t(composing ? 'suggestion.composing' : 'suggestion.keyboardHint')));
127
231
  const list = element('div', 'me-suggestions');
128
232
  list.setAttribute('role', 'listbox');
129
233
  list.setAttribute('aria-label', t('suggestion.label'));
130
234
  candidateIndex = Math.min(candidateIndex, result.candidates.length - 1);
131
235
  result.candidates.forEach((item, index) => {
132
- const button = element('button', '', `${item.glyph} ${item.label}`);
133
- button.type = 'button';
134
- button.setAttribute('role', 'option');
135
- button.setAttribute('aria-selected', String(index === candidateIndex));
236
+ const button = suggestionButton(item.glyph, item.label, item.detail);
237
+ button.setAttribute('aria-selected', String(!composing && index === candidateIndex));
136
238
  button.disabled = composing;
137
- button.onmousedown = (event) => event.preventDefault();
138
239
  button.onclick = () => choose(index);
139
240
  list.append(button);
140
241
  });
141
- menu.append(list);
242
+ menu.append(heading, list, element('div', 'me-suggestion-footer', t(composing ? 'suggestion.imeHint' : 'suggestion.keepSource')));
142
243
  const rect = input.getBoundingClientRect();
143
- positionMathMenu(menu, rect, 230);
244
+ positionMenu(rect);
144
245
  input.setAttribute('aria-expanded', 'true');
145
246
  };
247
+ const drawMenu = () => {
248
+ renderMenu();
249
+ // Keyup and selection events can redraw the list after keydown has scrolled it.
250
+ if (!menu.hidden)
251
+ scrollMenuSelection();
252
+ // Focus-only renders and native selection updates need the same notification
253
+ // as model edits, before a host's mutation observer restores its own selection.
254
+ if (!destroyed && !composing && !rendering)
255
+ options.onRender?.();
256
+ };
257
+ const dismissSelectionMenu = () => {
258
+ if (menu.hidden || menu.dataset.kind !== 'selection' || !selectionMenu.dismiss())
259
+ return false;
260
+ drawMenu();
261
+ return true;
262
+ };
263
+ menu.onkeydown = (event) => {
264
+ if (event.key !== 'Escape' || composing || event.isComposing || !dismissSelectionMenu())
265
+ return;
266
+ event.preventDefault();
267
+ event.stopPropagation();
268
+ (currentInput() ?? surface).focus();
269
+ };
270
+ const renderContextFooter = () => {
271
+ contextFooter.replaceChildren();
272
+ const snapshot = session.getSnapshot();
273
+ const structureContext = editing && options.contextTools !== false && !snapshot.range && !snapshot.matrixRange
274
+ ? structureEditingContext(snapshot.state)
275
+ : undefined;
276
+ contextFooter.hidden = !structureContext;
277
+ if (!structureContext)
278
+ return;
279
+ contextFooter.setAttribute('role', 'group');
280
+ contextFooter.setAttribute('aria-label', t('context.label'));
281
+ const fence = structureContext.fence;
282
+ if (fence) {
283
+ const pair = fences(fence);
284
+ contextFooter.append(element('span', 'me-context-label', translate(snapshot.locale, 'context.current', {
285
+ kind: t('presentation.fence') + ' ' + pair.join(' … '),
286
+ })));
287
+ const actions = element('span', 'me-context-actions');
288
+ for (const [kind, pair] of Object.entries(fencePairs)) {
289
+ const label = t(mathStructures.find((item) => item.kind === kind).label);
290
+ const button = element('button', 'me-context-button', pair.join(' □ '));
291
+ button.type = 'button';
292
+ button.disabled = composing;
293
+ button.title = label;
294
+ button.setAttribute('aria-label', label);
295
+ button.setAttribute('aria-pressed', String(fence.type === kind));
296
+ button.onclick = () => {
297
+ if (!composing) {
298
+ apply(changeContextFence(session.getSnapshot().state, fence.id, kind));
299
+ currentInput()?.focus({ preventScroll: true });
300
+ }
301
+ };
302
+ actions.append(button);
303
+ }
304
+ contextFooter.append(actions, element('span', 'me-context-hint', t('context.fenceKeyboardHint')));
305
+ return;
306
+ }
307
+ const context = structureContext.root;
308
+ contextFooter.append(element('span', 'me-context-label', translate(snapshot.locale, 'context.current', { kind: t(`context.${context.kind}`) })));
309
+ const actions = element('span', 'me-context-actions');
310
+ if (context.indexCaret) {
311
+ const index = element('button', 'me-context-button', t('context.editIndex'));
312
+ index.type = 'button';
313
+ index.disabled = composing;
314
+ index.onclick = () => {
315
+ if (!composing)
316
+ session.select(context.indexCaret);
317
+ };
318
+ actions.append(index);
319
+ }
320
+ const convert = element('button', 'me-context-button', translate(snapshot.locale, 'suggestion.transform', {
321
+ kind: t(`context.${context.target}`),
322
+ }));
323
+ convert.type = 'button';
324
+ convert.disabled = composing || !context.canTransform;
325
+ convert.onclick = () => {
326
+ if (!composing)
327
+ apply(transformRoot(session.getSnapshot().state, context.id, context.target));
328
+ };
329
+ actions.append(convert);
330
+ contextFooter.append(actions, element('span', 'me-context-hint', t(context.canTransform ? 'context.keyboardHint' : 'context.squareIndexRequired')));
331
+ };
332
+ contextFooter.onmousedown = (event) => event.preventDefault();
333
+ contextFooter.onkeydown = (event) => {
334
+ event.stopPropagation();
335
+ if (composing || event.isComposing)
336
+ return;
337
+ if (focusContextAction(contextFooter, event.key === 'F6' ? '' : event.key)) {
338
+ event.preventDefault();
339
+ return;
340
+ }
341
+ if (event.key === 'Escape' || event.key === 'F6') {
342
+ event.preventDefault();
343
+ currentInput()?.focus({ preventScroll: true });
344
+ }
345
+ };
346
+ const focusContextFooter = (event) => {
347
+ if (event.key !== 'F6' || contextFooter.hidden || composing || event.isComposing)
348
+ return false;
349
+ if (!focusContextAction(contextFooter, 'F6'))
350
+ return false;
351
+ event.preventDefault();
352
+ return true;
353
+ };
146
354
  const render = () => {
147
355
  if (destroyed || composing || rendering)
148
356
  return;
357
+ // External caret/configuration changes replace the input and end its pending native drag.
358
+ if (drag?.input && !drag.moved)
359
+ cancelDrag();
149
360
  rendering = true;
150
361
  const snapshot = session.getSnapshot(), { state } = snapshot;
151
362
  root.dataset.mode = snapshot.mode;
@@ -156,90 +367,139 @@ export function mountMathEditor(host, initial = {}) {
156
367
  surface.setAttribute('aria-label', t('selection.surface'));
157
368
  surface.replaceChildren();
158
369
  const spans = snapshot.range && resolveRange(state.document, snapshot.range);
370
+ const matrixSelection = snapshot.matrixRange && resolveMatrixRange(state.document, snapshot.matrixRange);
371
+ if (matrixSelection)
372
+ status.textContent = translate(snapshot.locale, 'matrix.rangeHint', {
373
+ rows: matrixSelection.rows,
374
+ columns: matrixSelection.columns,
375
+ });
376
+ else
377
+ status.textContent = '';
159
378
  const selected = (r) => spans?.find((span) => span.row.id === r.id);
160
379
  const labels = createEditorLabels(snapshot.locale);
380
+ const layout = mathLayout(state.document, snapshot.mode);
161
381
  const renderRow = (r, name, inheritedSelection = false) => {
162
382
  const view = element('span', 'me-row');
383
+ const spacing = mathRunSpacing(r);
384
+ view.dataset.layoutStyle = layout.rows.get(r.id);
163
385
  const selection = selected(r);
164
386
  let offset = 0;
165
387
  for (const child of r.children) {
166
388
  const length = child.type === 'text' ? child.text.length : 1;
389
+ const textSelection = child.type === 'text'
390
+ ? textSelectionBounds(length, offset, selection, inheritedSelection)
391
+ : undefined;
167
392
  const highlighted = inheritedSelection ||
168
393
  (!!selection && offset < selection.end && offset + length > selection.start);
169
394
  offset += length;
170
395
  if (child.type === 'text') {
171
- const boundary = !child.text && r.children.length > 1;
172
- const run = element('span', `me-run${child.text ? '' : ' me-empty'}${boundary ? ' me-empty-boundary' : ''}`);
173
- run.dataset.runId = child.id;
174
- run.dataset.value = child.text;
175
- const mirror = element('span', 'me-measure', child.text || (boundary ? '' : '□'));
176
- mirror.setAttribute('aria-hidden', 'true');
177
- run.append(mirror);
178
- const active = editing && !snapshot.range && state.caret.id === child.id;
179
- if (active) {
180
- const input = element('input', 'me-input');
181
- input.value = child.text;
182
- input.placeholder = '□';
183
- input.setAttribute('aria-label', name);
184
- input.setAttribute('role', 'combobox');
185
- input.setAttribute('aria-expanded', 'false');
186
- input.autocomplete = 'off';
187
- input.spellcheck = false;
188
- const syncCaret = () => {
189
- if (input.isConnected && !rendering && !composing) {
190
- rendering = true;
191
- session.select({
192
- id: child.id,
193
- start: input.selectionStart ?? 0,
194
- end: input.selectionEnd ?? 0,
195
- });
196
- rendering = false;
197
- drawMenu();
198
- }
199
- };
200
- input.onselect = syncCaret;
201
- input.onkeyup = syncCaret;
202
- input.oninput = () => {
203
- if (composing) {
204
- mirror.textContent = input.value || '□';
205
- return;
396
+ const parts = isLiteralText(state.document, child.id)
397
+ ? [{ text: child.text, start: 0, end: child.text.length, kind: 'draft' }]
398
+ : tokenizeMathText(child.text);
399
+ const activeIndex = tokenIndexAt(parts, state.caret.start, state.caret.affinity);
400
+ for (const [index, token] of parts.entries()) {
401
+ const boundary = !token.text && r.children.length > 1;
402
+ const run = element('span', `me-run${token.text ? '' : ' me-empty'}${boundary ? ' me-empty-boundary' : ''}`);
403
+ run.dataset.runId = child.id;
404
+ run.dataset.value = token.text;
405
+ run.dataset.tokenKind = token.kind;
406
+ if (!isLiteralText(state.document, child.id)) {
407
+ const gap = spacing.get(`${child.id}:${token.start}`);
408
+ run.dataset.mathSpacing = 'true';
409
+ run.style.setProperty('--me-space-before', String(gap?.before ?? 0));
410
+ run.style.setProperty('--me-space-after', String(gap?.after ?? 0));
411
+ run.style.setProperty('--me-tight-before', String(gap?.tightBefore ?? 0));
412
+ run.style.setProperty('--me-tight-after', String(gap?.tightAfter ?? 0));
413
+ }
414
+ run.dataset.tokenStart = String(token.start);
415
+ run.dataset.tokenEnd = String(token.end);
416
+ const mirror = element('span', 'me-measure', token.text || (boundary ? '' : '□'));
417
+ mirror.setAttribute('aria-hidden', 'true');
418
+ run.append(mirror);
419
+ const active = editing &&
420
+ !snapshot.range &&
421
+ !snapshot.matrixRange &&
422
+ state.caret.id === child.id &&
423
+ index === activeIndex;
424
+ if (active) {
425
+ const input = element('input', 'me-input');
426
+ input.value = token.text;
427
+ input.placeholder = boundary ? '' : '□';
428
+ input.dataset.tokenStart = String(token.start);
429
+ input.dataset.tokenEnd = String(token.end);
430
+ input.setAttribute('aria-label', name);
431
+ input.setAttribute('role', 'combobox');
432
+ input.setAttribute('aria-expanded', 'false');
433
+ input.autocomplete = 'off';
434
+ input.spellcheck = false;
435
+ const syncCaret = () => {
436
+ if (input.isConnected && !rendering && !composing) {
437
+ rendering = true;
438
+ session.select({
439
+ id: child.id,
440
+ start: token.start + (input.selectionStart ?? 0),
441
+ end: token.start + (input.selectionEnd ?? 0),
442
+ affinity: input.selectionStart === 0 ? 'forward' : 'backward',
443
+ });
444
+ rendering = false;
445
+ drawMenu();
446
+ }
447
+ };
448
+ const replaceToken = () => {
449
+ const current = session.getSnapshot().state;
450
+ const text = textNodes(current.document).find((node) => node.id === child.id).text;
451
+ return setText(current, text.slice(0, token.start) + input.value + text.slice(token.end), token.start + (input.selectionStart ?? 0), token.start + (input.selectionEnd ?? 0));
452
+ };
453
+ input.onselect = syncCaret;
454
+ input.onkeyup = syncCaret;
455
+ input.oninput = () => {
456
+ if (composing) {
457
+ // Do not split or replace this input before the browser commits its preedit.
458
+ mirror.textContent = input.value || '□';
459
+ return;
460
+ }
461
+ dismissed = '';
462
+ candidateIndex = 0;
463
+ apply(replaceToken());
464
+ };
465
+ input.addEventListener('compositionstart', () => {
466
+ cancelDrag();
467
+ composing = true;
468
+ toolbar?.setDisabled(true);
469
+ renderContextFooter();
470
+ menu.hidden = true;
471
+ });
472
+ input.addEventListener('compositionend', () => {
473
+ composing = false;
474
+ toolbar?.setDisabled(false);
475
+ dismissed = '';
476
+ apply(replaceToken());
477
+ });
478
+ input.onkeydown = keydown;
479
+ run.append(input);
480
+ }
481
+ else {
482
+ const preview = element('span', 'me-preview-token');
483
+ preview.setAttribute('aria-label', name);
484
+ preview.setAttribute('role', 'button');
485
+ preview.tabIndex = -1;
486
+ const selected = textSelectionBounds(token.text.length, token.start, textSelection);
487
+ if (selected) {
488
+ preview.append(doc.createTextNode(token.text.slice(0, selected.start)), element('mark', 'me-range-highlight', token.text.slice(selected.start, selected.end)), doc.createTextNode(token.text.slice(selected.end)));
489
+ run.dataset.rangeSelected = 'true';
206
490
  }
207
- const next = setText(session.getSnapshot().state, input.value, input.selectionStart ?? 0, input.selectionEnd ?? 0);
208
- dismissed = '';
209
- candidateIndex = 0;
210
- apply(next);
211
- };
212
- input.addEventListener('compositionstart', () => {
213
- composing = true;
214
- menu.hidden = true;
215
- });
216
- input.addEventListener('compositionend', () => {
217
- composing = false;
218
- dismissed = '';
219
- apply(setText(session.getSnapshot().state, input.value, input.selectionStart ?? 0, input.selectionEnd ?? 0));
220
- });
221
- input.onkeydown = keydown;
222
- run.append(input);
223
- }
224
- else {
225
- const preview = element('span', 'me-preview-token');
226
- preview.setAttribute('aria-label', name);
227
- preview.setAttribute('role', 'button');
228
- preview.tabIndex = -1;
229
- for (const token of tokenizeMathText(child.text)) {
230
- const part = element('span', `me-dom-${token.kind}`, token.text || (boundary ? '' : '□'));
231
- preview.append(part);
491
+ else
492
+ preview.textContent = token.text || (boundary ? '' : '□');
493
+ run.append(preview);
232
494
  }
233
- if (highlighted)
234
- run.classList.add('me-dom-selected');
235
- run.append(preview);
495
+ view.append(run);
236
496
  }
237
- view.append(run);
238
497
  continue;
239
498
  }
240
499
  if (isGrid(child)) {
241
500
  const grid = element('span', 'me-matrix');
242
501
  grid.dataset.structure = child.type;
502
+ grid.dataset.matrixId = child.id;
243
503
  grid.dataset.environment =
244
504
  child.type === 'matrix'
245
505
  ? child.environment
@@ -252,6 +512,10 @@ export function mountMathEditor(host, initial = {}) {
252
512
  cells.style.gridTemplateColumns = `repeat(${child.columns},max-content)`;
253
513
  child.slots.forEach((slot, i) => {
254
514
  const cell = element('span', 'me-matrix-cell');
515
+ cell.dataset.cellIndex = String(i);
516
+ if (matrixSelection?.matrix.id === child.id && matrixSelection.indices.includes(i)) {
517
+ cell.dataset.cellSelected = 'true';
518
+ }
255
519
  cell.append(renderRow(slot, labels.grid(name, child.type, i, child.columns), highlighted));
256
520
  cells.append(cell);
257
521
  });
@@ -261,6 +525,19 @@ export function mountMathEditor(host, initial = {}) {
261
525
  }
262
526
  const structure = element('span', `me-structure me-${child.type}`);
263
527
  structure.dataset.structure = child.type;
528
+ const structureSpacing = spacing.get(child.id);
529
+ if (structureSpacing) {
530
+ structure.dataset.mathSpacing = 'true';
531
+ structure.style.setProperty('--me-space-before', String(structureSpacing.before));
532
+ structure.style.setProperty('--me-space-after', String(structureSpacing.after));
533
+ structure.style.setProperty('--me-tight-before', String(structureSpacing.tightBefore ?? 0));
534
+ structure.style.setProperty('--me-tight-after', String(structureSpacing.tightAfter ?? 0));
535
+ }
536
+ const operatorLayout = layout.operators.get(child.id);
537
+ if (operatorLayout) {
538
+ structure.dataset.operatorStyle = operatorLayout.style;
539
+ structure.dataset.layoutLimits = operatorLayout.placement;
540
+ }
264
541
  if (child.mathStyle)
265
542
  structure.dataset.mathStyle = child.mathStyle;
266
543
  if (['limit', 'limsup', 'liminf'].includes(child.type)) {
@@ -341,11 +618,51 @@ export function mountMathEditor(host, initial = {}) {
341
618
  const input = currentInput();
342
619
  if (input) {
343
620
  input.focus();
344
- input.setSelectionRange(state.caret.start, state.caret.end);
621
+ const start = Number(input.dataset.tokenStart);
622
+ input.setSelectionRange(state.caret.start - start, state.caret.end - start);
345
623
  }
624
+ renderContextFooter();
346
625
  rendering = false;
347
626
  drawMenu();
348
627
  };
628
+ function matrixSelectionKey(event) {
629
+ const snapshot = session.getSnapshot(), selection = snapshot.matrixRange;
630
+ if (!selection || composing || event.isComposing)
631
+ return false;
632
+ if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'a') {
633
+ event.preventDefault();
634
+ session.execute({ type: 'matrix-select', all: true });
635
+ return true;
636
+ }
637
+ if (event.metaKey || event.ctrlKey || event.altKey)
638
+ return false;
639
+ if (['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown'].includes(event.key)) {
640
+ event.preventDefault();
641
+ const next = extendMatrixRange(snapshot.state.document, selection, event.key);
642
+ if (event.shiftKey)
643
+ session.selectMatrixRange(next);
644
+ else {
645
+ editing = true;
646
+ session.select(focusMatrixCell(snapshot.state, next.matrixId, next.focus).caret);
647
+ }
648
+ return true;
649
+ }
650
+ if (['Escape', 'Enter', 'F2'].includes(event.key)) {
651
+ event.preventDefault();
652
+ editing = true;
653
+ session.selectMatrixRange();
654
+ render();
655
+ return true;
656
+ }
657
+ if (['Backspace', 'Delete'].includes(event.key) || Array.from(event.key).length === 1) {
658
+ event.preventDefault();
659
+ perform(['Backspace', 'Delete'].includes(event.key)
660
+ ? { type: 'matrix-clear' }
661
+ : { type: 'text', value: event.key });
662
+ return true;
663
+ }
664
+ return false;
665
+ }
349
666
  function keyboardSelect(event) {
350
667
  if (composing ||
351
668
  event.isComposing ||
@@ -361,11 +678,70 @@ export function mountMathEditor(host, initial = {}) {
361
678
  surface.focus();
362
679
  return true;
363
680
  }
681
+ function selectionMenuKey(event) {
682
+ if (menu.hidden ||
683
+ menu.dataset.kind !== 'selection' ||
684
+ event.shiftKey ||
685
+ event.ctrlKey ||
686
+ event.metaKey ||
687
+ !['ArrowUp', 'ArrowDown', 'Enter'].includes(event.key))
688
+ return false;
689
+ const items = [...menu.querySelectorAll('[role="option"]')];
690
+ if (!items.length)
691
+ return false;
692
+ // The visible menu owns vertical arrows before a model range can collapse.
693
+ event.preventDefault();
694
+ if (event.key === 'Enter')
695
+ items[candidateIndex % items.length].click();
696
+ else {
697
+ candidateIndex =
698
+ (candidateIndex + (event.key === 'ArrowDown' ? 1 : items.length - 1)) % items.length;
699
+ drawMenu();
700
+ }
701
+ return true;
702
+ }
703
+ function keyboardCollapse(event) {
704
+ if (event.shiftKey || event.altKey || event.ctrlKey || event.metaKey)
705
+ return false;
706
+ const snapshot = session.getSnapshot();
707
+ const point = collapseKeyboardRange(snapshot.state.document, snapshot.range, event.key);
708
+ if (!point)
709
+ return false;
710
+ event.preventDefault();
711
+ editing = true;
712
+ session.select({ id: point.id, start: point.offset, end: point.offset });
713
+ return true;
714
+ }
364
715
  function keydown(event) {
365
716
  event.stopPropagation();
366
717
  if (event.isComposing || composing)
367
718
  return;
368
- if (keyboardSelect(event))
719
+ if (focusContextFooter(event))
720
+ return;
721
+ if (event.altKey &&
722
+ !event.shiftKey &&
723
+ !event.ctrlKey &&
724
+ !event.metaKey &&
725
+ event.key === 'ArrowDown') {
726
+ event.preventDefault();
727
+ dismissed = '';
728
+ const result = candidates();
729
+ candidateIndex = Math.max(0, result.candidates.findIndex((item) => item.transformFenceId || item.transformRootId));
730
+ const snapshot = session.getSnapshot();
731
+ const active = textNodes(snapshot.state.document).find((node) => node.id === snapshot.state.caret.id);
732
+ operandMenuKey = `${active.id}:${active.text}:${snapshot.state.caret.start}`;
733
+ drawMenu();
734
+ return;
735
+ }
736
+ if (event.altKey && event.shiftKey && event.code === 'KeyV') {
737
+ event.preventDefault();
738
+ openLatex();
739
+ return;
740
+ }
741
+ if (matrixSelectionKey(event) ||
742
+ keyboardSelect(event) ||
743
+ selectionMenuKey(event) ||
744
+ keyboardCollapse(event))
369
745
  return;
370
746
  const snapshot = session.getSnapshot(), state = snapshot.state, c = state.caret, active = textNodes(state.document).find((n) => n.id === c.id);
371
747
  if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'z') {
@@ -379,7 +755,10 @@ export function mountMathEditor(host, initial = {}) {
379
755
  if (result.candidates.length && ['ArrowDown', 'ArrowUp'].includes(event.key) && !event.altKey) {
380
756
  event.preventDefault();
381
757
  const key = `${active.id}:${active.text}:${c.start}`;
382
- const initialOperandChoice = result.candidates[0]?.wrapOperand && operandMenuKey !== key;
758
+ const initialOperandChoice = (result.candidates[0]?.wrapOperand ||
759
+ result.candidates[0]?.transformRootId ||
760
+ result.candidates[0]?.transformFenceId) &&
761
+ operandMenuKey !== key;
383
762
  operandMenuKey = key;
384
763
  candidateIndex = initialOperandChoice
385
764
  ? event.key === 'ArrowDown'
@@ -392,7 +771,9 @@ export function mountMathEditor(host, initial = {}) {
392
771
  }
393
772
  if (event.key === 'Escape') {
394
773
  event.preventDefault();
395
- if (result.candidates.length) {
774
+ if (dismissSelectionMenu())
775
+ return;
776
+ if (!menu.hidden && result.candidates.length) {
396
777
  dismissed = `${active.id}:${active.text}:${c.start}`;
397
778
  drawMenu();
398
779
  }
@@ -415,7 +796,9 @@ export function mountMathEditor(host, initial = {}) {
415
796
  behavior: options.enterBehavior,
416
797
  shiftKey: event.shiftKey,
417
798
  hasSuggestion: result.candidates.length > 0 &&
418
- (!result.candidates[0].wrapOperand ||
799
+ ((!result.candidates[0].wrapOperand &&
800
+ !result.candidates[0].transformRootId &&
801
+ !result.candidates[0].transformFenceId) ||
419
802
  operandMenuKey === `${active.id}:${active.text}:${c.start}`),
420
803
  gridKind: activeGrid(state)?.matrix.type,
421
804
  });
@@ -446,10 +829,23 @@ export function mountMathEditor(host, initial = {}) {
446
829
  if (event.key === 'Tab' ||
447
830
  (!event.shiftKey &&
448
831
  c.start === c.end &&
449
- ((event.key === 'ArrowLeft' && c.start === 0) ||
450
- (event.key === 'ArrowRight' && c.end === active.text.length)))) {
832
+ ((event.key === 'ArrowLeft' && c.start === Number(currentInput()?.dataset.tokenStart)) ||
833
+ (event.key === 'ArrowRight' && c.end === Number(currentInput()?.dataset.tokenEnd))))) {
451
834
  const direction = event.key === 'Tab' ? (event.shiftKey ? -1 : 1) : event.key === 'ArrowLeft' ? -1 : 1;
452
- const next = moveCaret(state, direction);
835
+ const parts = isLiteralText(state.document, c.id) ? [] : tokenizeMathText(active.text);
836
+ const target = parts[tokenIndexAt(parts, c.start, c.affinity) + direction];
837
+ const offset = target && (direction > 0 ? target.start : target.end);
838
+ const next = target
839
+ ? {
840
+ ...state,
841
+ caret: {
842
+ id: c.id,
843
+ start: offset,
844
+ end: offset,
845
+ affinity: direction > 0 ? 'forward' : 'backward',
846
+ },
847
+ }
848
+ : moveCaret(state, direction);
453
849
  if (next !== state) {
454
850
  event.preventDefault();
455
851
  session.select(next.caret);
@@ -462,9 +858,7 @@ export function mountMathEditor(host, initial = {}) {
462
858
  }
463
859
  if (!event.shiftKey && ['ArrowUp', 'ArrowDown'].includes(event.key)) {
464
860
  const direction = event.key === 'ArrowUp' ? -1 : 1;
465
- const next = activeGrid(state)
466
- ? moveMatrixVertical(state, direction)
467
- : moveLineVertical(state, direction);
861
+ const next = moveVertical(state, direction, renderedCaretGeometry(surface));
468
862
  if (next !== state) {
469
863
  event.preventDefault();
470
864
  session.select(next.caret);
@@ -472,6 +866,24 @@ export function mountMathEditor(host, initial = {}) {
472
866
  return;
473
867
  }
474
868
  if (c.start === c.end && ['Backspace', 'Delete'].includes(event.key)) {
869
+ const unwrapped = unwrapEmptySlot(state);
870
+ if (unwrapped !== state) {
871
+ event.preventDefault();
872
+ apply(unwrapped);
873
+ return;
874
+ }
875
+ const input = currentInput();
876
+ const left = event.key === 'Backspace';
877
+ if ((left && c.start > 0 && c.start === Number(input?.dataset.tokenStart)) ||
878
+ (!left && c.end < active.text.length && c.end === Number(input?.dataset.tokenEnd))) {
879
+ event.preventDefault();
880
+ const count = left
881
+ ? Array.from(active.text.slice(0, c.start)).at(-1).length
882
+ : Array.from(active.text.slice(c.end))[0].length;
883
+ const from = c.start - (left ? count : 0);
884
+ apply(setText(state, active.text.slice(0, from) + active.text.slice(from + count), from));
885
+ return;
886
+ }
475
887
  const grid = gridDeletionTarget(state, event.key === 'Backspace' ? -1 : 1);
476
888
  if (grid) {
477
889
  event.preventDefault();
@@ -506,9 +918,18 @@ export function mountMathEditor(host, initial = {}) {
506
918
  if (!best)
507
919
  return;
508
920
  const mirror = best.querySelector('.me-measure'), value = best.dataset.value;
921
+ // Empty caret slots have no text node for DOM Range to measure.
922
+ if (!value.length || !mirror.firstChild)
923
+ return {
924
+ id: best.dataset.runId,
925
+ offset: Number(best.dataset.tokenStart),
926
+ affinity: 'forward',
927
+ };
509
928
  let offset = 0, closest = Infinity;
510
- for (let i = 0; i <= value.length; i++) {
511
- const range = document.createRange();
929
+ let i = 0;
930
+ for (const character of ['', ...value]) {
931
+ i += character.length;
932
+ const range = doc.createRange();
512
933
  range.setStart(mirror.firstChild, i);
513
934
  range.collapse(true);
514
935
  const d = Math.abs(range.getBoundingClientRect().left - x);
@@ -517,70 +938,136 @@ export function mountMathEditor(host, initial = {}) {
517
938
  offset = i;
518
939
  }
519
940
  }
520
- return { id: best.dataset.runId, offset };
941
+ return {
942
+ id: best.dataset.runId,
943
+ offset: Number(best.dataset.tokenStart) + offset,
944
+ affinity: offset === 0 ? 'forward' : 'backward',
945
+ };
521
946
  };
522
947
  surface.onpointerdown = (event) => {
523
- if (event.button || composing || event.target.closest('.me-line-number'))
524
- return;
525
- if (event.target instanceof HTMLInputElement)
948
+ if (event.button || composing || drag || event.target.closest('.me-line-number'))
526
949
  return;
527
950
  const point = pointAt(event.clientX, event.clientY);
528
951
  if (point) {
529
- event.preventDefault();
952
+ event.stopPropagation();
953
+ selectionMenu.reset();
530
954
  candidateIndex = 0;
531
- drag = { point, x: event.clientX, y: event.clientY, moved: false };
532
- surface.setPointerCapture(event.pointerId);
955
+ if (event.shiftKey) {
956
+ const snapshot = session.getSnapshot();
957
+ const previous = snapshot.matrixRange;
958
+ const rectangle = matrixRangeBetween(snapshot.state.document, previous
959
+ ? focusMatrixCell(snapshot.state, previous.matrixId, previous.anchor).caret.id
960
+ : snapshot.state.caret.id, point.id);
961
+ if (rectangle) {
962
+ event.preventDefault();
963
+ editing = false;
964
+ session.selectMatrixRange(rectangle);
965
+ surface.focus({ preventScroll: true });
966
+ return;
967
+ }
968
+ }
969
+ const input = event.target === currentInput() ? currentInput() : undefined;
970
+ const nativeAnchor = input && event.shiftKey ? nativeSelectionAnchor(input) : null;
971
+ drag = {
972
+ point: nativeAnchor !== null
973
+ ? {
974
+ id: input.closest('.me-run').dataset.runId,
975
+ offset: Number(input.dataset.tokenStart) + nativeAnchor,
976
+ }
977
+ : point,
978
+ pointerId: event.pointerId,
979
+ x: event.clientX,
980
+ y: event.clientY,
981
+ moved: false,
982
+ input,
983
+ };
984
+ if (!input) {
985
+ event.preventDefault();
986
+ surface.setPointerCapture(event.pointerId);
987
+ }
533
988
  }
534
989
  };
535
- surface.onpointermove = (event) => {
536
- if (!drag || Math.hypot(event.clientX - drag.x, event.clientY - drag.y) < 4)
990
+ const movePointer = (event) => {
991
+ if (composing ||
992
+ !drag ||
993
+ !extendsMathSelection(drag, event, drag.input?.getBoundingClientRect()))
537
994
  return;
538
995
  const point = pointAt(event.clientX, event.clientY);
539
996
  if (point) {
997
+ event.preventDefault();
998
+ event.stopPropagation();
540
999
  drag.moved = true;
1000
+ // Transfer capture before rendering removes the native input that began this drag.
1001
+ surface.setPointerCapture(event.pointerId);
541
1002
  editing = false;
542
- session.selectRange({ anchor: drag.point, focus: point });
543
- surface.focus();
1003
+ selectedGrid = undefined;
1004
+ const snapshot = session.getSnapshot();
1005
+ const rectangle = dragMatrix ?? matrixRangeBetween(snapshot.state.document, drag.point.id, point.id);
1006
+ const bounds = rectangle && resolveMatrixRange(snapshot.state.document, rectangle);
1007
+ if (rectangle && bounds) {
1008
+ dragMatrix = matrixPointerRange(surface, rectangle, bounds.matrix.columns, event.clientX, event.clientY);
1009
+ session.selectMatrixRange(dragMatrix);
1010
+ }
1011
+ else
1012
+ session.selectRange({ anchor: drag.point, focus: point });
1013
+ surface.focus({ preventScroll: true });
544
1014
  }
545
1015
  };
546
- surface.onpointerup = (event) => {
1016
+ const endPointer = (event) => {
547
1017
  const previous = drag;
548
- drag = undefined;
549
- if (surface.hasPointerCapture(event.pointerId))
550
- surface.releasePointerCapture(event.pointerId);
551
- if (previous && !previous.moved) {
1018
+ if (!previous || previous.pointerId !== event.pointerId)
1019
+ return;
1020
+ cancelDrag();
1021
+ if (previous.moved) {
1022
+ event.preventDefault();
1023
+ event.stopPropagation();
1024
+ }
1025
+ else if (!previous.input) {
552
1026
  editing = true;
553
1027
  session.select({
554
1028
  id: previous.point.id,
555
1029
  start: previous.point.offset,
556
1030
  end: previous.point.offset,
1031
+ affinity: previous.point.affinity,
557
1032
  });
558
1033
  }
559
1034
  };
560
- surface.onpointercancel = () => {
561
- drag = undefined;
1035
+ const cancelPointer = (event) => {
1036
+ if (drag?.pointerId === event.pointerId)
1037
+ cancelDrag();
1038
+ };
1039
+ surface.onlostpointercapture = (event) => {
1040
+ if (event.target === surface)
1041
+ cancelPointer(event);
1042
+ };
1043
+ surface.ondragstart = (event) => {
1044
+ // A formula drag selects model content; it must not become host HTML drag-and-drop.
1045
+ event.preventDefault();
1046
+ event.stopPropagation();
562
1047
  };
1048
+ doc.addEventListener('pointermove', movePointer, true);
1049
+ doc.addEventListener('pointerup', endPointer, true);
1050
+ doc.addEventListener('pointercancel', cancelPointer, true);
1051
+ win.addEventListener('blur', cancelDrag);
563
1052
  surface.onkeydown = (event) => {
564
- if (event.target instanceof HTMLInputElement)
1053
+ if (event.target === currentInput())
565
1054
  return;
566
1055
  event.stopPropagation();
567
- if (keyboardSelect(event))
1056
+ if (composing || event.isComposing)
1057
+ return;
1058
+ if (focusContextFooter(event))
1059
+ return;
1060
+ if (event.altKey && event.shiftKey && event.code === 'KeyV') {
1061
+ event.preventDefault();
1062
+ openLatex();
568
1063
  return;
569
- const snapshot = session.getSnapshot();
570
- if (snapshot.range && ['ArrowDown', 'ArrowUp', 'Enter'].includes(event.key)) {
571
- const items = [...menu.querySelectorAll('[role="option"]')];
572
- if (items.length) {
573
- event.preventDefault();
574
- if (event.key === 'Enter')
575
- items[candidateIndex % items.length].click();
576
- else {
577
- candidateIndex =
578
- (candidateIndex + (event.key === 'ArrowDown' ? 1 : items.length - 1)) % items.length;
579
- drawMenu();
580
- }
581
- return;
582
- }
583
1064
  }
1065
+ if (matrixSelectionKey(event) ||
1066
+ keyboardSelect(event) ||
1067
+ selectionMenuKey(event) ||
1068
+ keyboardCollapse(event))
1069
+ return;
1070
+ const snapshot = session.getSnapshot();
584
1071
  if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'a') {
585
1072
  event.preventDefault();
586
1073
  const nodes = textNodes(snapshot.state.document);
@@ -594,7 +1081,11 @@ export function mountMathEditor(host, initial = {}) {
594
1081
  perform({ type: event.shiftKey ? 'redo' : 'undo' });
595
1082
  }
596
1083
  else if (event.key === 'Escape') {
1084
+ event.preventDefault();
1085
+ if (dismissSelectionMenu())
1086
+ return;
597
1087
  session.selectRange();
1088
+ options.onCancel?.();
598
1089
  }
599
1090
  else if (snapshot.range &&
600
1091
  !event.metaKey &&
@@ -617,12 +1108,17 @@ export function mountMathEditor(host, initial = {}) {
617
1108
  }
618
1109
  };
619
1110
  const copy = (event, cut = false) => {
1111
+ if (composing || !event.clipboardData)
1112
+ return;
620
1113
  const fragment = session.copy();
621
1114
  if (!fragment)
622
1115
  return;
623
1116
  event.preventDefault();
624
1117
  event.stopPropagation();
625
- event.clipboardData?.setData(MATH_CLIPBOARD_TYPE, JSON.stringify(fragment));
1118
+ const matrix = session.copyMatrix();
1119
+ if (matrix)
1120
+ event.clipboardData.setData(MATH_MATRIX_CLIPBOARD_TYPE, JSON.stringify(matrix));
1121
+ event.clipboardData.setData(MATH_CLIPBOARD_TYPE, JSON.stringify(fragment));
626
1122
  event.clipboardData?.setData('text/plain', toLatex(fragmentLatex(fragment)));
627
1123
  if (cut)
628
1124
  perform({ type: 'text', value: '' });
@@ -633,6 +1129,23 @@ export function mountMathEditor(host, initial = {}) {
633
1129
  if (composing)
634
1130
  return;
635
1131
  const data = event.clipboardData, fragment = parseFragment(data?.getData(MATH_CLIPBOARD_TYPE) ?? ''), text = data?.getData('text/plain') ?? '';
1132
+ const matrixSource = data?.getData(MATH_MATRIX_CLIPBOARD_TYPE) ?? '';
1133
+ const matrix = matrixSource ? parseMatrixFragment(matrixSource) : matrixFromTSV(text);
1134
+ const snapshot = session.getSnapshot();
1135
+ const inMatrix = !!snapshot.matrixRange || !!activeMatrix(snapshot.state);
1136
+ if (matrixSource || (inMatrix && text.includes('\t'))) {
1137
+ event.preventDefault();
1138
+ event.stopPropagation();
1139
+ if (!matrix) {
1140
+ notify('matrix.pasteRejected');
1141
+ return;
1142
+ }
1143
+ editing = true;
1144
+ const ok = inMatrix ? session.pasteMatrix(matrix) : session.paste(matrixFragment(matrix));
1145
+ if (!ok)
1146
+ notify('matrix.pasteRejected');
1147
+ return;
1148
+ }
636
1149
  if (!fragment && !text)
637
1150
  return;
638
1151
  event.preventDefault();
@@ -647,6 +1160,7 @@ export function mountMathEditor(host, initial = {}) {
647
1160
  !composing &&
648
1161
  !root.contains(event.relatedTarget) &&
649
1162
  !menu.contains(event.relatedTarget)) {
1163
+ cancelDrag();
650
1164
  editing = false;
651
1165
  render();
652
1166
  }
@@ -659,24 +1173,34 @@ export function mountMathEditor(host, initial = {}) {
659
1173
  ? mountMathToolbar(tools, session, {
660
1174
  kinds: Array.isArray(enabled) ? enabled : undefined,
661
1175
  maxItems: options.toolbarMaxItems,
1176
+ onPasteLatex: openLatex,
1177
+ onOpenPanel: () => {
1178
+ latexPanel?.destroy();
1179
+ latexPanel = undefined;
1180
+ },
662
1181
  onExecute: () => {
663
1182
  editing = true;
664
1183
  render();
1184
+ if (session.getSnapshot().matrixRange)
1185
+ surface.focus();
665
1186
  },
666
1187
  })
667
1188
  : undefined;
1189
+ toolbar?.setDisabled(composing);
668
1190
  };
669
1191
  const unsubscribe = session.subscribe((snapshot, changed) => {
1192
+ if (changed) {
1193
+ cancelDrag();
1194
+ selectionMenu.reset();
1195
+ }
670
1196
  render();
671
1197
  if (changed)
672
1198
  options.onChange?.(snapshot.state.document, snapshot.latex);
673
1199
  });
674
- const reposition = () => {
1200
+ const stopPosition = followMathMenuPosition(root, () => {
675
1201
  if (!menu.hidden)
676
1202
  drawMenu();
677
- };
678
- window.addEventListener('resize', reposition);
679
- window.addEventListener('scroll', reposition, true);
1203
+ }, menu);
680
1204
  rebuildToolbar();
681
1205
  render();
682
1206
  return {
@@ -684,6 +1208,8 @@ export function mountMathEditor(host, initial = {}) {
684
1208
  focus() {
685
1209
  editing = true;
686
1210
  render();
1211
+ if (session.getSnapshot().matrixRange)
1212
+ surface.focus();
687
1213
  },
688
1214
  update(next) {
689
1215
  options = { ...options, ...next };
@@ -695,11 +1221,17 @@ export function mountMathEditor(host, initial = {}) {
695
1221
  if (destroyed)
696
1222
  return;
697
1223
  destroyed = true;
1224
+ cancelDrag();
698
1225
  unsubscribe();
1226
+ latexPanel?.destroy();
699
1227
  toolbar?.destroy();
700
1228
  root.removeEventListener('focusout', blur);
701
- window.removeEventListener('resize', reposition);
702
- window.removeEventListener('scroll', reposition, true);
1229
+ stopPosition();
1230
+ doc.removeEventListener('pointermove', movePointer, true);
1231
+ doc.removeEventListener('pointerup', endPointer, true);
1232
+ doc.removeEventListener('pointercancel', cancelPointer, true);
1233
+ win.removeEventListener('blur', cancelDrag);
1234
+ stopTheme();
703
1235
  menu.remove();
704
1236
  root.remove();
705
1237
  if (ownsSession)
@@ -707,4 +1239,3 @@ export function mountMathEditor(host, initial = {}) {
707
1239
  },
708
1240
  };
709
1241
  }
710
- //# sourceMappingURL=dom.js.map