@barocss/math-editor 0.2.0 → 0.4.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 (180) 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 +168 -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 +1 -1
  9. package/CHANGELOG.md +51 -1
  10. package/EMBEDDING.md +4 -0
  11. package/IMPLEMENTATION.md +27 -2
  12. package/JSON-MODEL.md +1 -0
  13. package/LATEX-GUIDE.md +13 -0
  14. package/LATEX-SCOPE.md +2 -2
  15. package/LICENSE +1 -3
  16. package/README.md +150 -36
  17. package/ROADMAP.md +96 -10
  18. package/STYLING.md +157 -0
  19. package/SUPPORT.md +29 -11
  20. package/VALIDATION.md +117 -4
  21. package/dist/core.d.ts +5 -1
  22. package/dist/core.js +5 -1
  23. package/dist/document-codec.d.ts +0 -1
  24. package/dist/document-codec.js +0 -1
  25. package/dist/dom/caret-geometry.d.ts +3 -0
  26. package/dist/dom/caret-geometry.js +41 -0
  27. package/dist/dom/elements.d.ts +1 -2
  28. package/dist/dom/elements.js +2 -3
  29. package/dist/dom/latex-panel.d.ts +10 -0
  30. package/dist/dom/latex-panel.js +59 -0
  31. package/dist/dom/matrix-selection.d.ts +11 -0
  32. package/dist/dom/matrix-selection.js +30 -0
  33. package/dist/dom/menu-position.d.ts +14 -2
  34. package/dist/dom/menu-position.js +84 -15
  35. package/dist/dom/presentation-controls.d.ts +4 -0
  36. package/dist/dom/presentation-controls.js +56 -0
  37. package/dist/dom/quick-panel.d.ts +12 -0
  38. package/dist/dom/quick-panel.js +102 -0
  39. package/dist/dom/selection.d.ts +29 -0
  40. package/dist/dom/selection.js +53 -0
  41. package/dist/dom/symbol-panel.d.ts +9 -0
  42. package/dist/dom/symbol-panel.js +146 -0
  43. package/dist/dom/theme.d.ts +6 -0
  44. package/dist/dom/theme.js +68 -0
  45. package/dist/dom/toolbar-catalog.d.ts +10 -0
  46. package/dist/dom/toolbar-catalog.js +35 -0
  47. package/dist/dom/toolbar.d.ts +13 -2
  48. package/dist/dom/toolbar.js +295 -28
  49. package/dist/dom.d.ts +7 -1
  50. package/dist/dom.js +527 -126
  51. package/dist/editor-labels.d.ts +0 -1
  52. package/dist/editor-labels.js +0 -1
  53. package/dist/enter-policy.d.ts +0 -1
  54. package/dist/enter-policy.js +0 -1
  55. package/dist/fences.d.ts +0 -1
  56. package/dist/fences.js +0 -1
  57. package/dist/i18n.d.ts +0 -1
  58. package/dist/i18n.js +0 -1
  59. package/dist/index.d.ts +4 -1
  60. package/dist/index.js +4 -1
  61. package/dist/latex-insertion.d.ts +12 -0
  62. package/dist/latex-insertion.js +26 -0
  63. package/dist/latex-paste-panel.d.ts +3 -0
  64. package/dist/latex-paste-panel.js +18 -0
  65. package/dist/latex.d.ts +0 -1
  66. package/dist/latex.js +0 -1
  67. package/dist/lines.d.ts +0 -1
  68. package/dist/lines.js +0 -1
  69. package/dist/locales/en.js +28 -2
  70. package/dist/locales/en.json +28 -2
  71. package/dist/locales/ko.js +28 -2
  72. package/dist/locales/ko.json +28 -2
  73. package/dist/math-editor-toolbar.d.ts +4 -2
  74. package/dist/math-editor-toolbar.js +3 -4
  75. package/dist/math-editor.d.ts +5 -2
  76. package/dist/math-editor.js +300 -44
  77. package/dist/matrix-range.d.ts +40 -0
  78. package/dist/matrix-range.js +242 -0
  79. package/dist/matrix.d.ts +0 -1
  80. package/dist/matrix.js +0 -1
  81. package/dist/model.d.ts +0 -1
  82. package/dist/model.js +0 -1
  83. package/dist/outputs.d.ts +0 -1
  84. package/dist/outputs.js +0 -1
  85. package/dist/preferences.d.ts +24 -0
  86. package/dist/preferences.js +66 -0
  87. package/dist/presentation-controls.d.ts +10 -0
  88. package/dist/presentation-controls.js +10 -0
  89. package/dist/presentation.d.ts +13 -0
  90. package/dist/presentation.js +86 -0
  91. package/dist/quick-panel.d.ts +2 -0
  92. package/dist/quick-panel.js +17 -0
  93. package/dist/range.d.ts +4 -1
  94. package/dist/range.js +57 -1
  95. package/dist/react.d.ts +0 -1
  96. package/dist/react.js +0 -1
  97. package/dist/session.d.ts +31 -2
  98. package/dist/session.js +108 -7
  99. package/dist/solid.d.ts +0 -1
  100. package/dist/solid.js +0 -1
  101. package/dist/suggestions.d.ts +4 -1
  102. package/dist/suggestions.js +103 -13
  103. package/dist/svelte.d.ts +0 -1
  104. package/dist/svelte.js +0 -1
  105. package/dist/symbol-browser.d.ts +0 -1
  106. package/dist/symbol-browser.js +0 -1
  107. package/dist/symbols.d.ts +0 -1
  108. package/dist/symbols.js +0 -1
  109. package/dist/templates.d.ts +0 -1
  110. package/dist/templates.js +0 -1
  111. package/dist/tokens.d.ts +0 -1
  112. package/dist/tokens.js +0 -1
  113. package/dist/vertical-navigation.d.ts +15 -0
  114. package/dist/vertical-navigation.js +145 -0
  115. package/dist/vue.d.ts +0 -1
  116. package/dist/vue.js +0 -1
  117. package/dist/web-component.d.ts +0 -1
  118. package/dist/web-component.js +0 -1
  119. package/package.json +1 -1
  120. package/src/style.css +254 -129
  121. package/dist/core.d.ts.map +0 -1
  122. package/dist/core.js.map +0 -1
  123. package/dist/document-codec.d.ts.map +0 -1
  124. package/dist/document-codec.js.map +0 -1
  125. package/dist/dom/elements.d.ts.map +0 -1
  126. package/dist/dom/elements.js.map +0 -1
  127. package/dist/dom/menu-position.d.ts.map +0 -1
  128. package/dist/dom/menu-position.js.map +0 -1
  129. package/dist/dom/toolbar.d.ts.map +0 -1
  130. package/dist/dom/toolbar.js.map +0 -1
  131. package/dist/dom.d.ts.map +0 -1
  132. package/dist/dom.js.map +0 -1
  133. package/dist/editor-labels.d.ts.map +0 -1
  134. package/dist/editor-labels.js.map +0 -1
  135. package/dist/enter-policy.d.ts.map +0 -1
  136. package/dist/enter-policy.js.map +0 -1
  137. package/dist/fences.d.ts.map +0 -1
  138. package/dist/fences.js.map +0 -1
  139. package/dist/i18n.d.ts.map +0 -1
  140. package/dist/i18n.js.map +0 -1
  141. package/dist/index.d.ts.map +0 -1
  142. package/dist/index.js.map +0 -1
  143. package/dist/latex.d.ts.map +0 -1
  144. package/dist/latex.js.map +0 -1
  145. package/dist/lines.d.ts.map +0 -1
  146. package/dist/lines.js.map +0 -1
  147. package/dist/math-editor-toolbar.d.ts.map +0 -1
  148. package/dist/math-editor-toolbar.js.map +0 -1
  149. package/dist/math-editor.d.ts.map +0 -1
  150. package/dist/math-editor.js.map +0 -1
  151. package/dist/matrix.d.ts.map +0 -1
  152. package/dist/matrix.js.map +0 -1
  153. package/dist/model.d.ts.map +0 -1
  154. package/dist/model.js.map +0 -1
  155. package/dist/outputs.d.ts.map +0 -1
  156. package/dist/outputs.js.map +0 -1
  157. package/dist/range.d.ts.map +0 -1
  158. package/dist/range.js.map +0 -1
  159. package/dist/react.d.ts.map +0 -1
  160. package/dist/react.js.map +0 -1
  161. package/dist/session.d.ts.map +0 -1
  162. package/dist/session.js.map +0 -1
  163. package/dist/solid.d.ts.map +0 -1
  164. package/dist/solid.js.map +0 -1
  165. package/dist/suggestions.d.ts.map +0 -1
  166. package/dist/suggestions.js.map +0 -1
  167. package/dist/svelte.d.ts.map +0 -1
  168. package/dist/svelte.js.map +0 -1
  169. package/dist/symbol-browser.d.ts.map +0 -1
  170. package/dist/symbol-browser.js.map +0 -1
  171. package/dist/symbols.d.ts.map +0 -1
  172. package/dist/symbols.js.map +0 -1
  173. package/dist/templates.d.ts.map +0 -1
  174. package/dist/templates.js.map +0 -1
  175. package/dist/tokens.d.ts.map +0 -1
  176. package/dist/tokens.js.map +0 -1
  177. package/dist/vue.d.ts.map +0 -1
  178. package/dist/vue.js.map +0 -1
  179. package/dist/web-component.d.ts.map +0 -1
  180. package/dist/web-component.js.map +0 -1
package/dist/dom.js CHANGED
@@ -1,25 +1,36 @@
1
+ import { mountLatexPanel } from './dom/latex-panel.js';
2
+ import { followMathTheme, syncMathTheme } from './dom/theme.js';
3
+ import { matrixPointerRange } from './dom/matrix-selection.js';
1
4
  import { positionMathMenu } from './dom/menu-position.js';
2
5
  import { fences } from './fences.js';
3
6
  import { createEditorLabels } from './editor-labels.js';
4
7
  import { mathEnterAction } from './enter-policy.js';
5
8
  export { mountMathLatex, mountMathPreview, } from './outputs.js';
6
- import { element } from './dom/elements.js';
9
+ import { elementFactory } from './dom/elements.js';
7
10
  import { mountMathToolbar } from './dom/toolbar.js';
8
11
  export { mountMathToolbar } from './dom/toolbar.js';
9
12
  import { createMathSession, } from './session.js';
10
13
  import { translate, mathLocaleDirection } from './i18n.js';
11
14
  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';
15
+ import { activeGrid, activeMatrix, resizeMatrix } from './matrix.js';
16
+ 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';
19
+ import { renderedCaretGeometry } from './dom/caret-geometry.js';
20
+ import { extendsMathSelection, nativeSelectionAnchor, createSelectionMenuState, textSelectionBounds, } from './dom/selection.js';
14
21
  import { acceptSuggestion, findSuggestions, mathStructures } from './suggestions.js';
15
- import { tokenizeMathText } from './tokens.js';
16
- import { fragmentLatex, parseFragment, MATH_CLIPBOARD_TYPE, resolveRange, copyRange, wrappingKinds, } from './range.js';
22
+ import { tokenizeMathText, tokenIndexAt } from './tokens.js';
23
+ import { fragmentLatex, parseFragment, MATH_CLIPBOARD_TYPE, extendKeyboardRange, collapseKeyboardRange, resolveRange, copyRange, wrappingKinds, } from './range.js';
17
24
  /** Native DOM renderer. No React or framework runtime is imported by this module. */
18
25
  export function mountMathEditor(host, initial = {}) {
26
+ const doc = host.ownerDocument;
27
+ const win = doc.defaultView;
28
+ const element = elementFactory(doc);
19
29
  let options = { ...initial };
20
30
  const session = initial.session ??
21
31
  createMathSession({
22
32
  document: initial.defaultValue,
33
+ preferences: initial.preferences,
23
34
  locale: initial.locale,
24
35
  mode: initial.mode,
25
36
  });
@@ -34,12 +45,40 @@ export function mountMathEditor(host, initial = {}) {
34
45
  status.setAttribute('role', 'status');
35
46
  const menu = element('div', 'me-suggestion-panel me-dom-menu');
36
47
  menu.hidden = true;
37
- (initial.menuHost ?? host.closest('dialog, [role="dialog"]') ?? document.body).append(menu);
48
+ (initial.menuHost ?? host.closest('dialog, [role="dialog"]') ?? doc.body).append(menu);
49
+ const stopTheme = followMathTheme(root, menu);
38
50
  let editing = false, rendering = false, composing = false, destroyed = false, dismissed = '', operandMenuKey = '', candidateIndex = 0, selectedGrid;
39
51
  let drag;
52
+ let dragMatrix;
53
+ const selectionMenu = createSelectionMenuState();
40
54
  let toolbar;
55
+ let latexPanel;
56
+ const closeLatex = () => {
57
+ latexPanel?.destroy();
58
+ latexPanel = undefined;
59
+ editing = true;
60
+ render();
61
+ };
62
+ const openLatex = () => {
63
+ if (composing || destroyed || latexPanel)
64
+ return;
65
+ toolbar?.closePanels();
66
+ menu.hidden = true;
67
+ latexPanel = mountLatexPanel(root, {
68
+ locale: session.getSnapshot().locale,
69
+ onInsert: (source) => session.pasteLatex(source),
70
+ onClose: closeLatex,
71
+ });
72
+ };
41
73
  const t = (value) => translate(session.getSnapshot().locale, value);
42
74
  const currentInput = () => surface.querySelector('input');
75
+ const cancelDrag = () => {
76
+ const previous = drag;
77
+ drag = undefined;
78
+ dragMatrix = undefined;
79
+ if (previous && surface.hasPointerCapture(previous.pointerId))
80
+ surface.releasePointerCapture(previous.pointerId);
81
+ };
43
82
  const notify = (key) => {
44
83
  status.textContent = t(key);
45
84
  };
@@ -61,6 +100,7 @@ export function mountMathEditor(host, initial = {}) {
61
100
  if (!editing ||
62
101
  isLiteralText(state.document, state.caret.id) ||
63
102
  snapshot.range ||
103
+ snapshot.matrixRange ||
64
104
  dismissed === `${active.id}:${active.text}:${state.caret.start}`)
65
105
  result.candidates = [];
66
106
  return result;
@@ -73,38 +113,94 @@ export function mountMathEditor(host, initial = {}) {
73
113
  dismissed = '';
74
114
  candidateIndex = 0;
75
115
  apply(acceptSuggestion(session.getSnapshot().state, result.query, item));
116
+ session.preferences.record(item.id);
76
117
  }
77
118
  };
78
- const drawMenu = () => {
119
+ const suggestionButton = (glyph, label, detail) => {
120
+ const button = element('button');
121
+ button.type = 'button';
122
+ button.setAttribute('role', 'option');
123
+ // Keep the concise option name while showing the same detail as the rich UI.
124
+ button.setAttribute('aria-label', `${glyph} ${label}`);
125
+ const copy = element('span', 'me-candidate-copy');
126
+ copy.append(element('strong', '', label), element('small', '', detail));
127
+ button.append(element('span', 'me-candidate-glyph', glyph), copy);
128
+ button.onmousedown = (event) => event.preventDefault();
129
+ button.onmousemove = (event) => {
130
+ // Rebuilding or scrolling the list can put a new row under a stationary
131
+ // pointer. Only real pointer movement may replace the keyboard choice.
132
+ if (composing || button.disabled || (!event.movementX && !event.movementY))
133
+ return;
134
+ const buttons = Array.from(button.parentElement.children);
135
+ const index = buttons.indexOf(button);
136
+ if (candidateIndex === index && button.getAttribute('aria-selected') === 'true')
137
+ return;
138
+ candidateIndex = index;
139
+ // Update the highlight in place: rebuilding would reset a wheel-scrolled list.
140
+ for (const option of buttons)
141
+ option.setAttribute('aria-selected', String(option === button));
142
+ // Host observers also see these attribute updates and must retain the input caret.
143
+ options.onRender?.();
144
+ };
145
+ return button;
146
+ };
147
+ const positionMenu = (anchor) => {
148
+ delete menu.dataset.compact;
149
+ positionMathMenu(menu, anchor, 340, options.menuAvoidElements?.());
150
+ // In a short dialog, leave the available space for selectable rows.
151
+ if (menu.getBoundingClientRect().height < 180) {
152
+ menu.dataset.compact = 'true';
153
+ positionMathMenu(menu, anchor, 340, options.menuAvoidElements?.());
154
+ }
155
+ };
156
+ const scrollMenuSelection = () => {
157
+ const option = menu.querySelector('[aria-selected="true"]');
158
+ const list = option?.parentElement;
159
+ if (!option || !list)
160
+ return;
161
+ // Move only the options list, never the surrounding page or host editor.
162
+ const itemRect = option.getBoundingClientRect();
163
+ const listRect = list.getBoundingClientRect();
164
+ 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;
169
+ };
170
+ const renderMenu = () => {
171
+ syncMathTheme(root, menu);
79
172
  const result = candidates(), input = currentInput();
80
173
  menu.replaceChildren();
81
174
  const snapshot = session.getSnapshot();
82
175
  const caret = snapshot.state.caret;
83
176
  const selectedRange = snapshot.range ??
84
- (caret.start !== caret.end
177
+ (input && caret.start !== caret.end
85
178
  ? {
86
179
  anchor: { id: caret.id, offset: caret.start },
87
180
  focus: { id: caret.id, offset: caret.end },
88
181
  }
89
182
  : undefined);
90
183
  const fragment = selectedRange && copyRange(snapshot.state.document, selectedRange);
184
+ const showSelectionMenu = selectionMenu.sync(fragment ? selectedRange : undefined);
91
185
  if (fragment) {
92
- menu.hidden = false;
186
+ menu.dataset.kind = 'selection';
187
+ menu.hidden = !showSelectionMenu;
188
+ input?.setAttribute('aria-expanded', String(showSelectionMenu));
189
+ if (!showSelectionMenu)
190
+ return;
93
191
  menu.lang = snapshot.locale;
94
192
  menu.dir = mathLocaleDirection(snapshot.locale);
193
+ const canWrap = fragment.rows.length === 1 &&
194
+ !isLiteralText(snapshot.state.document, selectedRange.anchor.id);
195
+ menu.append(element('div', 'me-suggestion-heading', t(canWrap ? 'selection.highlighted' : 'selection.singleRowRequired')));
95
196
  const list = element('div', 'me-suggestions');
96
197
  list.setAttribute('role', 'listbox');
97
198
  list.setAttribute('aria-label', t('selection.toolbar'));
98
199
  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');
200
+ const button = suggestionButton(item.glyph, t(item.label), t(item.detail));
102
201
  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);
202
+ button.disabled = !canWrap;
106
203
  button.setAttribute('aria-label', translate(snapshot.locale, 'selection.wrap', { kind: t(item.label) }));
107
- button.onmousedown = (event) => event.preventDefault();
108
204
  button.onclick = () => {
109
205
  if (!snapshot.range)
110
206
  session.selectRange(selectedRange);
@@ -113,39 +209,65 @@ export function mountMathEditor(host, initial = {}) {
113
209
  list.append(button);
114
210
  }
115
211
  menu.append(list);
116
- const selected = surface.querySelectorAll('.me-dom-selected');
212
+ const selected = surface.querySelectorAll('.me-dom-selected, .me-range-highlight');
117
213
  const rect = (selected[selected.length - 1] ?? input ?? surface).getBoundingClientRect();
118
- positionMathMenu(menu, rect, 340);
214
+ positionMenu(rect);
119
215
  return;
120
216
  }
217
+ menu.dataset.kind = 'suggestions';
121
218
  menu.hidden = !input || !result.candidates.length;
122
219
  input?.setAttribute('aria-expanded', String(!menu.hidden));
123
220
  if (menu.hidden)
124
221
  return;
125
222
  menu.lang = session.getSnapshot().locale;
126
223
  menu.dir = mathLocaleDirection(menu.lang);
224
+ const heading = element('div', 'me-suggestion-heading');
225
+ heading.append(element('span', 'me-suggestion-query', translate(snapshot.locale, 'suggestion.create', { query: result.query })), element('span', '', t(composing ? 'suggestion.composing' : 'suggestion.keyboardHint')));
127
226
  const list = element('div', 'me-suggestions');
128
227
  list.setAttribute('role', 'listbox');
129
228
  list.setAttribute('aria-label', t('suggestion.label'));
130
229
  candidateIndex = Math.min(candidateIndex, result.candidates.length - 1);
131
230
  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));
231
+ const button = suggestionButton(item.glyph, item.label, item.detail);
232
+ button.setAttribute('aria-selected', String(!composing && index === candidateIndex));
136
233
  button.disabled = composing;
137
- button.onmousedown = (event) => event.preventDefault();
138
234
  button.onclick = () => choose(index);
139
235
  list.append(button);
140
236
  });
141
- menu.append(list);
237
+ menu.append(heading, list, element('div', 'me-suggestion-footer', t(composing ? 'suggestion.imeHint' : 'suggestion.keepSource')));
142
238
  const rect = input.getBoundingClientRect();
143
- positionMathMenu(menu, rect, 230);
239
+ positionMenu(rect);
144
240
  input.setAttribute('aria-expanded', 'true');
145
241
  };
242
+ const drawMenu = () => {
243
+ renderMenu();
244
+ // Keyup and selection events can redraw the list after keydown has scrolled it.
245
+ if (!menu.hidden)
246
+ scrollMenuSelection();
247
+ // Focus-only renders and native selection updates need the same notification
248
+ // as model edits, before a host's mutation observer restores its own selection.
249
+ if (!destroyed && !composing && !rendering)
250
+ options.onRender?.();
251
+ };
252
+ const dismissSelectionMenu = () => {
253
+ if (menu.hidden || menu.dataset.kind !== 'selection' || !selectionMenu.dismiss())
254
+ return false;
255
+ drawMenu();
256
+ return true;
257
+ };
258
+ menu.onkeydown = (event) => {
259
+ if (event.key !== 'Escape' || composing || event.isComposing || !dismissSelectionMenu())
260
+ return;
261
+ event.preventDefault();
262
+ event.stopPropagation();
263
+ (currentInput() ?? surface).focus();
264
+ };
146
265
  const render = () => {
147
266
  if (destroyed || composing || rendering)
148
267
  return;
268
+ // External caret/configuration changes replace the input and end its pending native drag.
269
+ if (drag?.input && !drag.moved)
270
+ cancelDrag();
149
271
  rendering = true;
150
272
  const snapshot = session.getSnapshot(), { state } = snapshot;
151
273
  root.dataset.mode = snapshot.mode;
@@ -156,6 +278,14 @@ export function mountMathEditor(host, initial = {}) {
156
278
  surface.setAttribute('aria-label', t('selection.surface'));
157
279
  surface.replaceChildren();
158
280
  const spans = snapshot.range && resolveRange(state.document, snapshot.range);
281
+ const matrixSelection = snapshot.matrixRange && resolveMatrixRange(state.document, snapshot.matrixRange);
282
+ if (matrixSelection)
283
+ status.textContent = translate(snapshot.locale, 'matrix.rangeHint', {
284
+ rows: matrixSelection.rows,
285
+ columns: matrixSelection.columns,
286
+ });
287
+ else
288
+ status.textContent = '';
159
289
  const selected = (r) => spans?.find((span) => span.row.id === r.id);
160
290
  const labels = createEditorLabels(snapshot.locale);
161
291
  const renderRow = (r, name, inheritedSelection = false) => {
@@ -164,82 +294,111 @@ export function mountMathEditor(host, initial = {}) {
164
294
  let offset = 0;
165
295
  for (const child of r.children) {
166
296
  const length = child.type === 'text' ? child.text.length : 1;
297
+ const textSelection = child.type === 'text'
298
+ ? textSelectionBounds(length, offset, selection, inheritedSelection)
299
+ : undefined;
167
300
  const highlighted = inheritedSelection ||
168
301
  (!!selection && offset < selection.end && offset + length > selection.start);
169
302
  offset += length;
170
303
  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;
304
+ const parts = isLiteralText(state.document, child.id)
305
+ ? [{ text: child.text, start: 0, end: child.text.length, kind: 'draft' }]
306
+ : tokenizeMathText(child.text);
307
+ const activeIndex = tokenIndexAt(parts, state.caret.start, state.caret.affinity);
308
+ for (const [index, token] of parts.entries()) {
309
+ const boundary = !token.text && r.children.length > 1;
310
+ const run = element('span', `me-run${token.text ? '' : ' me-empty'}${boundary ? ' me-empty-boundary' : ''}`);
311
+ run.dataset.runId = child.id;
312
+ run.dataset.value = token.text;
313
+ run.dataset.tokenKind = token.kind;
314
+ run.dataset.tokenStart = String(token.start);
315
+ run.dataset.tokenEnd = String(token.end);
316
+ const mirror = element('span', 'me-measure', token.text || (boundary ? '' : '□'));
317
+ mirror.setAttribute('aria-hidden', 'true');
318
+ run.append(mirror);
319
+ const active = editing &&
320
+ !snapshot.range &&
321
+ !snapshot.matrixRange &&
322
+ state.caret.id === child.id &&
323
+ index === activeIndex;
324
+ if (active) {
325
+ const input = element('input', 'me-input');
326
+ input.value = token.text;
327
+ input.placeholder = boundary ? '' : '□';
328
+ input.dataset.tokenStart = String(token.start);
329
+ input.dataset.tokenEnd = String(token.end);
330
+ input.setAttribute('aria-label', name);
331
+ input.setAttribute('role', 'combobox');
332
+ input.setAttribute('aria-expanded', 'false');
333
+ input.autocomplete = 'off';
334
+ input.spellcheck = false;
335
+ const syncCaret = () => {
336
+ if (input.isConnected && !rendering && !composing) {
337
+ rendering = true;
338
+ session.select({
339
+ id: child.id,
340
+ start: token.start + (input.selectionStart ?? 0),
341
+ end: token.start + (input.selectionEnd ?? 0),
342
+ affinity: input.selectionStart === 0 ? 'forward' : 'backward',
343
+ });
344
+ rendering = false;
345
+ drawMenu();
346
+ }
347
+ };
348
+ const replaceToken = () => {
349
+ const current = session.getSnapshot().state;
350
+ const text = textNodes(current.document).find((node) => node.id === child.id).text;
351
+ return setText(current, text.slice(0, token.start) + input.value + text.slice(token.end), token.start + (input.selectionStart ?? 0), token.start + (input.selectionEnd ?? 0));
352
+ };
353
+ input.onselect = syncCaret;
354
+ input.onkeyup = syncCaret;
355
+ input.oninput = () => {
356
+ if (composing) {
357
+ // Do not split or replace this input before the browser commits its preedit.
358
+ mirror.textContent = input.value || '□';
359
+ return;
360
+ }
361
+ dismissed = '';
362
+ candidateIndex = 0;
363
+ apply(replaceToken());
364
+ };
365
+ input.addEventListener('compositionstart', () => {
366
+ cancelDrag();
367
+ composing = true;
368
+ toolbar?.setDisabled(true);
369
+ menu.hidden = true;
370
+ });
371
+ input.addEventListener('compositionend', () => {
372
+ composing = false;
373
+ toolbar?.setDisabled(false);
374
+ dismissed = '';
375
+ apply(replaceToken());
376
+ });
377
+ input.onkeydown = keydown;
378
+ run.append(input);
379
+ }
380
+ else {
381
+ const preview = element('span', 'me-preview-token');
382
+ preview.setAttribute('aria-label', name);
383
+ preview.setAttribute('role', 'button');
384
+ preview.tabIndex = -1;
385
+ const selected = textSelectionBounds(token.text.length, token.start, textSelection);
386
+ if (selected) {
387
+ 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)));
388
+ run.dataset.rangeSelected = 'true';
206
389
  }
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);
390
+ else
391
+ preview.textContent = token.text || (boundary ? '' : '□');
392
+ run.append(preview);
232
393
  }
233
- if (highlighted)
234
- run.classList.add('me-dom-selected');
235
- run.append(preview);
394
+ view.append(run);
236
395
  }
237
- view.append(run);
238
396
  continue;
239
397
  }
240
398
  if (isGrid(child)) {
241
399
  const grid = element('span', 'me-matrix');
242
400
  grid.dataset.structure = child.type;
401
+ grid.dataset.matrixId = child.id;
243
402
  grid.dataset.environment =
244
403
  child.type === 'matrix'
245
404
  ? child.environment
@@ -252,6 +411,10 @@ export function mountMathEditor(host, initial = {}) {
252
411
  cells.style.gridTemplateColumns = `repeat(${child.columns},max-content)`;
253
412
  child.slots.forEach((slot, i) => {
254
413
  const cell = element('span', 'me-matrix-cell');
414
+ cell.dataset.cellIndex = String(i);
415
+ if (matrixSelection?.matrix.id === child.id && matrixSelection.indices.includes(i)) {
416
+ cell.dataset.cellSelected = 'true';
417
+ }
255
418
  cell.append(renderRow(slot, labels.grid(name, child.type, i, child.columns), highlighted));
256
419
  cells.append(cell);
257
420
  });
@@ -341,15 +504,88 @@ export function mountMathEditor(host, initial = {}) {
341
504
  const input = currentInput();
342
505
  if (input) {
343
506
  input.focus();
344
- input.setSelectionRange(state.caret.start, state.caret.end);
507
+ const start = Number(input.dataset.tokenStart);
508
+ input.setSelectionRange(state.caret.start - start, state.caret.end - start);
345
509
  }
346
510
  rendering = false;
347
511
  drawMenu();
348
512
  };
513
+ function matrixSelectionKey(event) {
514
+ const snapshot = session.getSnapshot(), selection = snapshot.matrixRange;
515
+ if (!selection || composing || event.isComposing)
516
+ return false;
517
+ if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'a') {
518
+ event.preventDefault();
519
+ session.execute({ type: 'matrix-select', all: true });
520
+ return true;
521
+ }
522
+ if (event.metaKey || event.ctrlKey || event.altKey)
523
+ return false;
524
+ if (['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown'].includes(event.key)) {
525
+ event.preventDefault();
526
+ const next = extendMatrixRange(snapshot.state.document, selection, event.key);
527
+ if (event.shiftKey)
528
+ session.selectMatrixRange(next);
529
+ else {
530
+ editing = true;
531
+ session.select(focusMatrixCell(snapshot.state, next.matrixId, next.focus).caret);
532
+ }
533
+ return true;
534
+ }
535
+ if (['Escape', 'Enter', 'F2'].includes(event.key)) {
536
+ event.preventDefault();
537
+ editing = true;
538
+ session.selectMatrixRange();
539
+ render();
540
+ return true;
541
+ }
542
+ if (['Backspace', 'Delete'].includes(event.key) || Array.from(event.key).length === 1) {
543
+ event.preventDefault();
544
+ perform(['Backspace', 'Delete'].includes(event.key)
545
+ ? { type: 'matrix-clear' }
546
+ : { type: 'text', value: event.key });
547
+ return true;
548
+ }
549
+ return false;
550
+ }
551
+ function keyboardSelect(event) {
552
+ if (composing ||
553
+ event.isComposing ||
554
+ !event.shiftKey ||
555
+ event.altKey ||
556
+ event.ctrlKey ||
557
+ event.metaKey ||
558
+ !['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown'].includes(event.key))
559
+ return false;
560
+ event.preventDefault();
561
+ const snapshot = session.getSnapshot();
562
+ session.selectRange(extendKeyboardRange(snapshot.state, snapshot.range, event.key));
563
+ surface.focus();
564
+ return true;
565
+ }
566
+ function keyboardCollapse(event) {
567
+ if (event.shiftKey || event.altKey || event.ctrlKey || event.metaKey)
568
+ return false;
569
+ const snapshot = session.getSnapshot();
570
+ const point = collapseKeyboardRange(snapshot.state.document, snapshot.range, event.key);
571
+ if (!point)
572
+ return false;
573
+ event.preventDefault();
574
+ editing = true;
575
+ session.select({ id: point.id, start: point.offset, end: point.offset });
576
+ return true;
577
+ }
349
578
  function keydown(event) {
350
579
  event.stopPropagation();
351
580
  if (event.isComposing || composing)
352
581
  return;
582
+ if (event.altKey && event.shiftKey && event.code === 'KeyV') {
583
+ event.preventDefault();
584
+ openLatex();
585
+ return;
586
+ }
587
+ if (matrixSelectionKey(event) || keyboardSelect(event) || keyboardCollapse(event))
588
+ return;
353
589
  const snapshot = session.getSnapshot(), state = snapshot.state, c = state.caret, active = textNodes(state.document).find((n) => n.id === c.id);
354
590
  if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'z') {
355
591
  event.preventDefault();
@@ -375,7 +611,9 @@ export function mountMathEditor(host, initial = {}) {
375
611
  }
376
612
  if (event.key === 'Escape') {
377
613
  event.preventDefault();
378
- if (result.candidates.length) {
614
+ if (dismissSelectionMenu())
615
+ return;
616
+ if (!menu.hidden && result.candidates.length) {
379
617
  dismissed = `${active.id}:${active.text}:${c.start}`;
380
618
  drawMenu();
381
619
  }
@@ -429,10 +667,23 @@ export function mountMathEditor(host, initial = {}) {
429
667
  if (event.key === 'Tab' ||
430
668
  (!event.shiftKey &&
431
669
  c.start === c.end &&
432
- ((event.key === 'ArrowLeft' && c.start === 0) ||
433
- (event.key === 'ArrowRight' && c.end === active.text.length)))) {
670
+ ((event.key === 'ArrowLeft' && c.start === Number(currentInput()?.dataset.tokenStart)) ||
671
+ (event.key === 'ArrowRight' && c.end === Number(currentInput()?.dataset.tokenEnd))))) {
434
672
  const direction = event.key === 'Tab' ? (event.shiftKey ? -1 : 1) : event.key === 'ArrowLeft' ? -1 : 1;
435
- const next = moveCaret(state, direction);
673
+ const parts = isLiteralText(state.document, c.id) ? [] : tokenizeMathText(active.text);
674
+ const target = parts[tokenIndexAt(parts, c.start, c.affinity) + direction];
675
+ const offset = target && (direction > 0 ? target.start : target.end);
676
+ const next = target
677
+ ? {
678
+ ...state,
679
+ caret: {
680
+ id: c.id,
681
+ start: offset,
682
+ end: offset,
683
+ affinity: direction > 0 ? 'forward' : 'backward',
684
+ },
685
+ }
686
+ : moveCaret(state, direction);
436
687
  if (next !== state) {
437
688
  event.preventDefault();
438
689
  session.select(next.caret);
@@ -445,9 +696,7 @@ export function mountMathEditor(host, initial = {}) {
445
696
  }
446
697
  if (!event.shiftKey && ['ArrowUp', 'ArrowDown'].includes(event.key)) {
447
698
  const direction = event.key === 'ArrowUp' ? -1 : 1;
448
- const next = activeGrid(state)
449
- ? moveMatrixVertical(state, direction)
450
- : moveLineVertical(state, direction);
699
+ const next = moveVertical(state, direction, renderedCaretGeometry(surface));
451
700
  if (next !== state) {
452
701
  event.preventDefault();
453
702
  session.select(next.caret);
@@ -455,6 +704,18 @@ export function mountMathEditor(host, initial = {}) {
455
704
  return;
456
705
  }
457
706
  if (c.start === c.end && ['Backspace', 'Delete'].includes(event.key)) {
707
+ const input = currentInput();
708
+ const left = event.key === 'Backspace';
709
+ if ((left && c.start > 0 && c.start === Number(input?.dataset.tokenStart)) ||
710
+ (!left && c.end < active.text.length && c.end === Number(input?.dataset.tokenEnd))) {
711
+ event.preventDefault();
712
+ const count = left
713
+ ? Array.from(active.text.slice(0, c.start)).at(-1).length
714
+ : Array.from(active.text.slice(c.end))[0].length;
715
+ const from = c.start - (left ? count : 0);
716
+ apply(setText(state, active.text.slice(0, from) + active.text.slice(from + count), from));
717
+ return;
718
+ }
458
719
  const grid = gridDeletionTarget(state, event.key === 'Backspace' ? -1 : 1);
459
720
  if (grid) {
460
721
  event.preventDefault();
@@ -489,9 +750,18 @@ export function mountMathEditor(host, initial = {}) {
489
750
  if (!best)
490
751
  return;
491
752
  const mirror = best.querySelector('.me-measure'), value = best.dataset.value;
753
+ // Empty caret slots have no text node for DOM Range to measure.
754
+ if (!value.length || !mirror.firstChild)
755
+ return {
756
+ id: best.dataset.runId,
757
+ offset: Number(best.dataset.tokenStart),
758
+ affinity: 'forward',
759
+ };
492
760
  let offset = 0, closest = Infinity;
493
- for (let i = 0; i <= value.length; i++) {
494
- const range = document.createRange();
761
+ let i = 0;
762
+ for (const character of ['', ...value]) {
763
+ i += character.length;
764
+ const range = doc.createRange();
495
765
  range.setStart(mirror.firstChild, i);
496
766
  range.collapse(true);
497
767
  const d = Math.abs(range.getBoundingClientRect().left - x);
@@ -500,55 +770,136 @@ export function mountMathEditor(host, initial = {}) {
500
770
  offset = i;
501
771
  }
502
772
  }
503
- return { id: best.dataset.runId, offset };
773
+ return {
774
+ id: best.dataset.runId,
775
+ offset: Number(best.dataset.tokenStart) + offset,
776
+ affinity: offset === 0 ? 'forward' : 'backward',
777
+ };
504
778
  };
505
779
  surface.onpointerdown = (event) => {
506
- if (event.button || composing || event.target.closest('.me-line-number'))
507
- return;
508
- if (event.target instanceof HTMLInputElement)
780
+ if (event.button || composing || drag || event.target.closest('.me-line-number'))
509
781
  return;
510
782
  const point = pointAt(event.clientX, event.clientY);
511
783
  if (point) {
512
- event.preventDefault();
784
+ event.stopPropagation();
785
+ selectionMenu.reset();
513
786
  candidateIndex = 0;
514
- drag = { point, x: event.clientX, y: event.clientY, moved: false };
515
- surface.setPointerCapture(event.pointerId);
787
+ if (event.shiftKey) {
788
+ const snapshot = session.getSnapshot();
789
+ const previous = snapshot.matrixRange;
790
+ const rectangle = matrixRangeBetween(snapshot.state.document, previous
791
+ ? focusMatrixCell(snapshot.state, previous.matrixId, previous.anchor).caret.id
792
+ : snapshot.state.caret.id, point.id);
793
+ if (rectangle) {
794
+ event.preventDefault();
795
+ editing = false;
796
+ session.selectMatrixRange(rectangle);
797
+ surface.focus({ preventScroll: true });
798
+ return;
799
+ }
800
+ }
801
+ const input = event.target === currentInput() ? currentInput() : undefined;
802
+ const nativeAnchor = input && event.shiftKey ? nativeSelectionAnchor(input) : null;
803
+ drag = {
804
+ point: nativeAnchor !== null
805
+ ? {
806
+ id: input.closest('.me-run').dataset.runId,
807
+ offset: Number(input.dataset.tokenStart) + nativeAnchor,
808
+ }
809
+ : point,
810
+ pointerId: event.pointerId,
811
+ x: event.clientX,
812
+ y: event.clientY,
813
+ moved: false,
814
+ input,
815
+ };
816
+ if (!input) {
817
+ event.preventDefault();
818
+ surface.setPointerCapture(event.pointerId);
819
+ }
516
820
  }
517
821
  };
518
- surface.onpointermove = (event) => {
519
- if (!drag || Math.hypot(event.clientX - drag.x, event.clientY - drag.y) < 4)
822
+ const movePointer = (event) => {
823
+ if (composing ||
824
+ !drag ||
825
+ !extendsMathSelection(drag, event, drag.input?.getBoundingClientRect()))
520
826
  return;
521
827
  const point = pointAt(event.clientX, event.clientY);
522
828
  if (point) {
829
+ event.preventDefault();
830
+ event.stopPropagation();
523
831
  drag.moved = true;
832
+ // Transfer capture before rendering removes the native input that began this drag.
833
+ surface.setPointerCapture(event.pointerId);
524
834
  editing = false;
525
- session.selectRange({ anchor: drag.point, focus: point });
526
- surface.focus();
835
+ selectedGrid = undefined;
836
+ const snapshot = session.getSnapshot();
837
+ const rectangle = dragMatrix ?? matrixRangeBetween(snapshot.state.document, drag.point.id, point.id);
838
+ const bounds = rectangle && resolveMatrixRange(snapshot.state.document, rectangle);
839
+ if (rectangle && bounds) {
840
+ dragMatrix = matrixPointerRange(surface, rectangle, bounds.matrix.columns, event.clientX, event.clientY);
841
+ session.selectMatrixRange(dragMatrix);
842
+ }
843
+ else
844
+ session.selectRange({ anchor: drag.point, focus: point });
845
+ surface.focus({ preventScroll: true });
527
846
  }
528
847
  };
529
- surface.onpointerup = (event) => {
848
+ const endPointer = (event) => {
530
849
  const previous = drag;
531
- drag = undefined;
532
- if (surface.hasPointerCapture(event.pointerId))
533
- surface.releasePointerCapture(event.pointerId);
534
- if (previous && !previous.moved) {
850
+ if (!previous || previous.pointerId !== event.pointerId)
851
+ return;
852
+ cancelDrag();
853
+ if (previous.moved) {
854
+ event.preventDefault();
855
+ event.stopPropagation();
856
+ }
857
+ else if (!previous.input) {
535
858
  editing = true;
536
859
  session.select({
537
860
  id: previous.point.id,
538
861
  start: previous.point.offset,
539
862
  end: previous.point.offset,
863
+ affinity: previous.point.affinity,
540
864
  });
541
865
  }
542
866
  };
543
- surface.onpointercancel = () => {
544
- drag = undefined;
867
+ const cancelPointer = (event) => {
868
+ if (drag?.pointerId === event.pointerId)
869
+ cancelDrag();
870
+ };
871
+ surface.onlostpointercapture = (event) => {
872
+ if (event.target === surface)
873
+ cancelPointer(event);
545
874
  };
875
+ surface.ondragstart = (event) => {
876
+ // A formula drag selects model content; it must not become host HTML drag-and-drop.
877
+ event.preventDefault();
878
+ event.stopPropagation();
879
+ };
880
+ doc.addEventListener('pointermove', movePointer, true);
881
+ doc.addEventListener('pointerup', endPointer, true);
882
+ doc.addEventListener('pointercancel', cancelPointer, true);
883
+ win.addEventListener('blur', cancelDrag);
546
884
  surface.onkeydown = (event) => {
547
- if (event.target instanceof HTMLInputElement)
885
+ if (event.target === currentInput())
548
886
  return;
549
887
  event.stopPropagation();
888
+ if (composing || event.isComposing)
889
+ return;
890
+ if (event.altKey && event.shiftKey && event.code === 'KeyV') {
891
+ event.preventDefault();
892
+ openLatex();
893
+ return;
894
+ }
895
+ if (matrixSelectionKey(event) || keyboardSelect(event) || keyboardCollapse(event))
896
+ return;
550
897
  const snapshot = session.getSnapshot();
551
- if (snapshot.range && ['ArrowDown', 'ArrowUp', 'Enter'].includes(event.key)) {
898
+ if (snapshot.range &&
899
+ !event.ctrlKey &&
900
+ !event.metaKey &&
901
+ !event.shiftKey &&
902
+ (event.key === 'Enter' || (event.altKey && ['ArrowDown', 'ArrowUp'].includes(event.key)))) {
552
903
  const items = [...menu.querySelectorAll('[role="option"]')];
553
904
  if (items.length) {
554
905
  event.preventDefault();
@@ -575,7 +926,11 @@ export function mountMathEditor(host, initial = {}) {
575
926
  perform({ type: event.shiftKey ? 'redo' : 'undo' });
576
927
  }
577
928
  else if (event.key === 'Escape') {
929
+ event.preventDefault();
930
+ if (dismissSelectionMenu())
931
+ return;
578
932
  session.selectRange();
933
+ options.onCancel?.();
579
934
  }
580
935
  else if (snapshot.range &&
581
936
  !event.metaKey &&
@@ -598,12 +953,17 @@ export function mountMathEditor(host, initial = {}) {
598
953
  }
599
954
  };
600
955
  const copy = (event, cut = false) => {
956
+ if (composing || !event.clipboardData)
957
+ return;
601
958
  const fragment = session.copy();
602
959
  if (!fragment)
603
960
  return;
604
961
  event.preventDefault();
605
962
  event.stopPropagation();
606
- event.clipboardData?.setData(MATH_CLIPBOARD_TYPE, JSON.stringify(fragment));
963
+ const matrix = session.copyMatrix();
964
+ if (matrix)
965
+ event.clipboardData.setData(MATH_MATRIX_CLIPBOARD_TYPE, JSON.stringify(matrix));
966
+ event.clipboardData.setData(MATH_CLIPBOARD_TYPE, JSON.stringify(fragment));
607
967
  event.clipboardData?.setData('text/plain', toLatex(fragmentLatex(fragment)));
608
968
  if (cut)
609
969
  perform({ type: 'text', value: '' });
@@ -614,6 +974,23 @@ export function mountMathEditor(host, initial = {}) {
614
974
  if (composing)
615
975
  return;
616
976
  const data = event.clipboardData, fragment = parseFragment(data?.getData(MATH_CLIPBOARD_TYPE) ?? ''), text = data?.getData('text/plain') ?? '';
977
+ const matrixSource = data?.getData(MATH_MATRIX_CLIPBOARD_TYPE) ?? '';
978
+ const matrix = matrixSource ? parseMatrixFragment(matrixSource) : matrixFromTSV(text);
979
+ const snapshot = session.getSnapshot();
980
+ const inMatrix = !!snapshot.matrixRange || !!activeMatrix(snapshot.state);
981
+ if (matrixSource || (inMatrix && text.includes('\t'))) {
982
+ event.preventDefault();
983
+ event.stopPropagation();
984
+ if (!matrix) {
985
+ notify('matrix.pasteRejected');
986
+ return;
987
+ }
988
+ editing = true;
989
+ const ok = inMatrix ? session.pasteMatrix(matrix) : session.paste(matrixFragment(matrix));
990
+ if (!ok)
991
+ notify('matrix.pasteRejected');
992
+ return;
993
+ }
617
994
  if (!fragment && !text)
618
995
  return;
619
996
  event.preventDefault();
@@ -628,6 +1005,7 @@ export function mountMathEditor(host, initial = {}) {
628
1005
  !composing &&
629
1006
  !root.contains(event.relatedTarget) &&
630
1007
  !menu.contains(event.relatedTarget)) {
1008
+ cancelDrag();
631
1009
  editing = false;
632
1010
  render();
633
1011
  }
@@ -640,24 +1018,39 @@ export function mountMathEditor(host, initial = {}) {
640
1018
  ? mountMathToolbar(tools, session, {
641
1019
  kinds: Array.isArray(enabled) ? enabled : undefined,
642
1020
  maxItems: options.toolbarMaxItems,
1021
+ onPasteLatex: openLatex,
1022
+ onOpenPanel: () => {
1023
+ latexPanel?.destroy();
1024
+ latexPanel = undefined;
1025
+ },
643
1026
  onExecute: () => {
644
1027
  editing = true;
645
1028
  render();
1029
+ if (session.getSnapshot().matrixRange)
1030
+ surface.focus();
646
1031
  },
647
1032
  })
648
1033
  : undefined;
1034
+ toolbar?.setDisabled(composing);
649
1035
  };
650
1036
  const unsubscribe = session.subscribe((snapshot, changed) => {
1037
+ if (changed) {
1038
+ cancelDrag();
1039
+ selectionMenu.reset();
1040
+ }
651
1041
  render();
652
1042
  if (changed)
653
1043
  options.onChange?.(snapshot.state.document, snapshot.latex);
654
1044
  });
655
- const reposition = () => {
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;
656
1049
  if (!menu.hidden)
657
1050
  drawMenu();
658
1051
  };
659
- window.addEventListener('resize', reposition);
660
- window.addEventListener('scroll', reposition, true);
1052
+ win.addEventListener('resize', reposition);
1053
+ win.addEventListener('scroll', reposition, true);
661
1054
  rebuildToolbar();
662
1055
  render();
663
1056
  return {
@@ -665,6 +1058,8 @@ export function mountMathEditor(host, initial = {}) {
665
1058
  focus() {
666
1059
  editing = true;
667
1060
  render();
1061
+ if (session.getSnapshot().matrixRange)
1062
+ surface.focus();
668
1063
  },
669
1064
  update(next) {
670
1065
  options = { ...options, ...next };
@@ -676,11 +1071,18 @@ export function mountMathEditor(host, initial = {}) {
676
1071
  if (destroyed)
677
1072
  return;
678
1073
  destroyed = true;
1074
+ cancelDrag();
679
1075
  unsubscribe();
1076
+ latexPanel?.destroy();
680
1077
  toolbar?.destroy();
681
1078
  root.removeEventListener('focusout', blur);
682
- window.removeEventListener('resize', reposition);
683
- window.removeEventListener('scroll', reposition, true);
1079
+ win.removeEventListener('resize', reposition);
1080
+ win.removeEventListener('scroll', reposition, true);
1081
+ doc.removeEventListener('pointermove', movePointer, true);
1082
+ doc.removeEventListener('pointerup', endPointer, true);
1083
+ doc.removeEventListener('pointercancel', cancelPointer, true);
1084
+ win.removeEventListener('blur', cancelDrag);
1085
+ stopTheme();
684
1086
  menu.remove();
685
1087
  root.remove();
686
1088
  if (ownsSession)
@@ -688,4 +1090,3 @@ export function mountMathEditor(host, initial = {}) {
688
1090
  },
689
1091
  };
690
1092
  }
691
- //# sourceMappingURL=dom.js.map