@k2b/ui 0.34.0 → 0.35.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 (82) hide show
  1. package/dist/browser/actions/Dropdown.js +1 -0
  2. package/dist/browser/actions/Dropdown.js.map +1 -1
  3. package/dist/browser/content/MatchText.js +61 -0
  4. package/dist/browser/content/MatchText.js.map +1 -0
  5. package/dist/browser/content/index.js +1 -0
  6. package/dist/browser/content/index.js.map +1 -1
  7. package/dist/browser/text-editor/TextEditor.js +1358 -0
  8. package/dist/browser/text-editor/TextEditor.js.map +1 -0
  9. package/dist/browser/text-editor/clipboard-html.js +152 -0
  10. package/dist/browser/text-editor/clipboard-html.js.map +1 -0
  11. package/dist/browser/text-editor/clipboard-loading.js +68 -0
  12. package/dist/browser/text-editor/clipboard-loading.js.map +1 -0
  13. package/dist/browser/text-editor/clipboard.js +51 -0
  14. package/dist/browser/text-editor/clipboard.js.map +1 -0
  15. package/dist/browser/text-editor/engine.js +125 -0
  16. package/dist/browser/text-editor/engine.js.map +1 -0
  17. package/dist/browser/text-editor/format.js +218 -0
  18. package/dist/browser/text-editor/format.js.map +1 -0
  19. package/dist/browser/text-editor/formatted.js +164 -0
  20. package/dist/browser/text-editor/formatted.js.map +1 -0
  21. package/dist/browser/text-editor/language.js +19 -0
  22. package/dist/browser/text-editor/language.js.map +1 -0
  23. package/dist/browser/text-editor/markdown-table.js +563 -0
  24. package/dist/browser/text-editor/markdown-table.js.map +1 -0
  25. package/dist/browser/text-editor/messages.js +233 -0
  26. package/dist/browser/text-editor/messages.js.map +1 -0
  27. package/dist/browser/text-editor/paste.js +115 -0
  28. package/dist/browser/text-editor/paste.js.map +1 -0
  29. package/dist/browser/text-editor/table.js +1760 -0
  30. package/dist/browser/text-editor/table.js.map +1 -0
  31. package/dist/browser/text-editor.js +5 -0
  32. package/dist/browser/text-editor.js.map +1 -0
  33. package/dist/ssr/actions/Dropdown.js +1 -0
  34. package/dist/ssr/actions/Dropdown.js.map +1 -1
  35. package/dist/ssr/content/MatchText.js +56 -0
  36. package/dist/ssr/content/MatchText.js.map +1 -0
  37. package/dist/ssr/content/index.js +1 -0
  38. package/dist/ssr/content/index.js.map +1 -1
  39. package/dist/ssr/text-editor/TextEditor.js +1155 -0
  40. package/dist/ssr/text-editor/TextEditor.js.map +1 -0
  41. package/dist/ssr/text-editor/clipboard-html.js +152 -0
  42. package/dist/ssr/text-editor/clipboard-html.js.map +1 -0
  43. package/dist/ssr/text-editor/clipboard-loading.js +68 -0
  44. package/dist/ssr/text-editor/clipboard-loading.js.map +1 -0
  45. package/dist/ssr/text-editor/clipboard.js +51 -0
  46. package/dist/ssr/text-editor/clipboard.js.map +1 -0
  47. package/dist/ssr/text-editor/engine.js +125 -0
  48. package/dist/ssr/text-editor/engine.js.map +1 -0
  49. package/dist/ssr/text-editor/format.js +218 -0
  50. package/dist/ssr/text-editor/format.js.map +1 -0
  51. package/dist/ssr/text-editor/formatted.js +164 -0
  52. package/dist/ssr/text-editor/formatted.js.map +1 -0
  53. package/dist/ssr/text-editor/language.js +19 -0
  54. package/dist/ssr/text-editor/language.js.map +1 -0
  55. package/dist/ssr/text-editor/markdown-table.js +563 -0
  56. package/dist/ssr/text-editor/markdown-table.js.map +1 -0
  57. package/dist/ssr/text-editor/messages.js +233 -0
  58. package/dist/ssr/text-editor/messages.js.map +1 -0
  59. package/dist/ssr/text-editor/paste.js +115 -0
  60. package/dist/ssr/text-editor/paste.js.map +1 -0
  61. package/dist/ssr/text-editor/table.js +1760 -0
  62. package/dist/ssr/text-editor/table.js.map +1 -0
  63. package/dist/ssr/text-editor.js +5 -0
  64. package/dist/ssr/text-editor.js.map +1 -0
  65. package/dist/styles.css +1 -1
  66. package/dist/types/actions/Dropdown.d.ts +2 -0
  67. package/dist/types/content/MatchText.d.ts +21 -0
  68. package/dist/types/content/index.d.ts +1 -0
  69. package/dist/types/text-editor/TextEditor.d.ts +69 -0
  70. package/dist/types/text-editor/clipboard-html.d.ts +9 -0
  71. package/dist/types/text-editor/clipboard-loading.d.ts +8 -0
  72. package/dist/types/text-editor/clipboard.d.ts +20 -0
  73. package/dist/types/text-editor/engine.d.ts +34 -0
  74. package/dist/types/text-editor/format.d.ts +34 -0
  75. package/dist/types/text-editor/formatted.d.ts +1 -0
  76. package/dist/types/text-editor/language.d.ts +8 -0
  77. package/dist/types/text-editor/markdown-table.d.ts +105 -0
  78. package/dist/types/text-editor/messages.d.ts +113 -0
  79. package/dist/types/text-editor/paste.d.ts +6 -0
  80. package/dist/types/text-editor/table.d.ts +81 -0
  81. package/dist/types/text-editor.d.ts +4 -0
  82. package/package.json +23 -3
@@ -0,0 +1,1760 @@
1
+ /**
2
+ * Editable table grid for the formatted view.
3
+ *
4
+ * A Markdown table is replaced by a grid whose cells are small plain-text
5
+ * editing surfaces. Typing in a cell turns into the minimal text change inside
6
+ * that cell's Markdown range (see markdown-table.ts), so the stored format stays
7
+ * Markdown and concurrent edits merge at character level.
8
+ *
9
+ * Rules that keep it stable:
10
+ * - the grid DOM is patched in place (`updateDOM`), never rebuilt while a cell
11
+ * has focus unless the shape changed;
12
+ * - the focused cell is never rewritten while an IME composition runs;
13
+ * remote text for that cell waits until `compositionend`;
14
+ * - every structural action is one transaction, so one undo step.
15
+ */
16
+ import { history, historyKeymap, isolateHistory } from "@codemirror/commands";
17
+ import { syntaxTree } from "@codemirror/language";
18
+ import { ChangeSet, Compartment, EditorState, Facet, Prec, StateEffect, StateField, Text } from "@codemirror/state";
19
+ import { Decoration, EditorView, keymap, ViewPlugin, WidgetType } from "@codemirror/view";
20
+ import { convertClipboardHtml, inspectClipboard, parseClipboardHtml } from "./clipboard";
21
+ import { clipboardLoading } from "./clipboard-loading";
22
+ import { inlineFormat } from "./format";
23
+ import { markdownLanguage } from "./language";
24
+ import * as T from "./markdown-table";
25
+ import { textEditorMessages } from "./messages";
26
+
27
+ /** Resolve a server-returned identity against the current document immediately before focus. */
28
+
29
+ export const tableHost = Facet.define({
30
+ combine: v => v[0] ?? null
31
+ });
32
+ const active = new WeakMap();
33
+ const pendingContextMenus = new WeakMap();
34
+
35
+ /**
36
+ * What the focused cell's DOM was last synced to: the cell text and its start
37
+ * in the document, plus every document change since. A commit diffs the DOM
38
+ * against `base` and maps the result through `desc`, so text that other
39
+ * people typed meanwhile (during an IME composition, for example) survives.
40
+ */
41
+
42
+ const syncs = new WeakMap();
43
+ // An unmaterialized ragged cell has no source identity yet. Never infer its
44
+ // baseline from a later numeric position after another transaction changes it.
45
+ const missingCells = new WeakMap();
46
+ const rebuilding = new WeakSet();
47
+ /** A structural action that moves focus itself; a rebuild then does not restore the old caret. */
48
+ const targeted = new WeakSet();
49
+ const refreshTables = StateEffect.define();
50
+
51
+ // Browsers write a trailing or doubled space as U+00A0; a typed no-break space between words stays.
52
+
53
+ const parsedTables = new WeakMap();
54
+ export const tablesIn = state => {
55
+ const cached = parsedTables.get(state);
56
+ if (cached) return cached;
57
+ const tables = T.findTables(state.doc.toString());
58
+ parsedTables.set(state, tables);
59
+ return tables;
60
+ };
61
+ const currentTable = (view, dom) => {
62
+ if (!dom.isConnected || !view.dom.contains(dom)) return null;
63
+ const pos = view.posAtDOM(dom);
64
+ return tablesIn(view.state).find(t => pos >= t.from && pos <= t.to) ?? null;
65
+ };
66
+ const tableIndex = (view, dom) => {
67
+ if (!dom.isConnected || !view.dom.contains(dom)) return -1;
68
+ const pos = view.posAtDOM(dom);
69
+ return tablesIn(view.state).findIndex(t => pos >= t.from && pos <= t.to);
70
+ };
71
+ /** The grid element of the n-th table, looked up again: CodeMirror may have replaced it. */
72
+ const findWrap = (view, index) => [...view.contentDOM.querySelectorAll(".k2b-ste-table")].find(el => tableIndex(view, el) === index) ?? null;
73
+ const cellAtPos = (state, pos) => {
74
+ for (const table of tablesIn(state)) {
75
+ if (pos < table.from || pos > table.to) continue;
76
+ for (let r = 0; r < T.rowCount(table); r++) {
77
+ const line = T.gridLine(table, r);
78
+ if (pos < line.from || pos > line.to) continue;
79
+ const before = line.pipes.filter(p => p < pos).length;
80
+ const c = Math.max(0, Math.min(line.cells.length - 1, line.leadingPipe ? before - 1 : before));
81
+ const cell = line.cells[c];
82
+ return cell ? {
83
+ table,
84
+ r,
85
+ c,
86
+ cell
87
+ } : null;
88
+ }
89
+ }
90
+ return null;
91
+ };
92
+ const cellSurvives = sync => {
93
+ const survived = new Set();
94
+ sync.desc.iterGaps((from, _newFrom, length) => {
95
+ for (const edge of sync.edges) if (edge >= from && edge < from + length) survived.add(edge);
96
+ });
97
+ return survived.size === sync.edges.length;
98
+ };
99
+ const resync = (view, el, cell) => {
100
+ const hit = cellAtPos(view.state, cell.from);
101
+ const line = hit && T.gridLine(hit.table, hit.r);
102
+ const edges = [];
103
+ if (line && hit) {
104
+ const left = line.leadingPipe ? line.pipes[hit.c] : hit.c === 0 ? line.from - 1 : line.pipes[hit.c - 1];
105
+ const right = (line.leadingPipe ? line.pipes[hit.c + 1] : line.pipes[hit.c]) ?? line.to;
106
+ if (left !== undefined && left >= 0) edges.push(left);
107
+ if (right < view.state.doc.length) edges.push(right);
108
+ }
109
+ syncs.set(view, {
110
+ el,
111
+ baseFrom: cell.from,
112
+ base: cell.text,
113
+ desc: ChangeSet.empty(view.state.doc.length).desc,
114
+ committing: false,
115
+ refocusSelection: null,
116
+ edges
117
+ });
118
+ };
119
+ const escapeHtml = s => s.replace(/[&<>"]/g, ch => `&${{
120
+ "&": "amp",
121
+ "<": "lt",
122
+ ">": "gt",
123
+ '"': "quot"
124
+ }[ch]};`);
125
+ /** Render the same Markdown tokens as prose, keeping code and escaped markers literal. */
126
+ const renderInline = raw => {
127
+ const text = raw;
128
+ const tree = markdownLanguage.parser.parse(text);
129
+ const render = node => {
130
+ const tags = {
131
+ StrongEmphasis: ["<strong>", "</strong>", ["EmphasisMark"]],
132
+ Emphasis: ["<em>", "</em>", ["EmphasisMark"]],
133
+ Strikethrough: ["<s>", "</s>", ["StrikethroughMark"]],
134
+ InlineCode: ['<code class="k2b-ste-code">', "</code>", ["CodeMark"]],
135
+ Link: ['<span class="k2b-ste-link">', "</span>", ["LinkMark", "URL", "LinkTitle"]]
136
+ };
137
+ if (node.name === "Escape") return escapeHtml(text.slice(node.from + 1, node.to));
138
+ const spec = tags[node.name];
139
+ let result = spec?.[0] ?? "";
140
+ let from = node.from;
141
+ for (let child = node.firstChild; child; child = child.nextSibling) {
142
+ result += escapeHtml(node.name === "InlineCode" ? T.displayCell(text.slice(from, child.from)) : text.slice(from, child.from));
143
+ if (!spec?.[2].includes(child.name)) result += render(child);
144
+ from = child.to;
145
+ }
146
+ const tail = text.slice(from, node.to);
147
+ return result + escapeHtml(node.name === "InlineCode" ? T.displayCell(tail) : tail) + (spec?.[1] ?? "");
148
+ };
149
+ return render(tree.topNode);
150
+ };
151
+ const renderCell = (view, element, raw, table, row, column) => {
152
+ const render = view.state.facet(tableHost)?.renderCell;
153
+ if (render) render(element, raw, table, row, column);else element.innerHTML = renderInline(raw);
154
+ };
155
+ const cellAt = (dom, r, c) => dom.querySelector(`.k2b-ste-cell[data-r="${r}"][data-c="${c}"]`);
156
+ const caretOffset = el => {
157
+ const sel = el.ownerDocument.getSelection();
158
+ if (!sel || !sel.rangeCount || !el.contains(sel.anchorNode)) return el.textContent?.length ?? 0;
159
+ const range = sel.getRangeAt(0).cloneRange();
160
+ range.selectNodeContents(el);
161
+ range.setEnd(sel.anchorNode, sel.anchorOffset);
162
+ return range.toString().length;
163
+ };
164
+ const selectionOffsets = el => {
165
+ const selection = el.ownerDocument.getSelection();
166
+ const offset = (node, at) => {
167
+ if (!node || !el.contains(node)) return caretOffset(el);
168
+ const range = el.ownerDocument.createRange();
169
+ range.selectNodeContents(el);
170
+ range.setEnd(node, at);
171
+ return range.toString().length;
172
+ };
173
+ return [offset(selection?.anchorNode ?? null, selection?.anchorOffset ?? 0), offset(selection?.focusNode ?? null, selection?.focusOffset ?? 0)];
174
+ };
175
+ const placeSelection = (el, anchor, head = anchor) => {
176
+ el.focus({
177
+ preventScroll: true
178
+ });
179
+ const selection = el.ownerDocument.getSelection();
180
+ const text = el.firstChild;
181
+ if (!selection) return;
182
+ if (text?.nodeType === Node.TEXT_NODE) {
183
+ const length = text.textContent?.length ?? 0;
184
+ selection.setBaseAndExtent(text, Math.max(0, Math.min(anchor, length)), text, Math.max(0, Math.min(head, length)));
185
+ } else selection.setBaseAndExtent(el, 0, el, 0);
186
+ };
187
+ const placeCaret = (el, offset) => placeSelection(el, offset);
188
+ const hasHeader = t => t.lines[0].cells.some(cell => cell.text.length > 0);
189
+ const firstRow = t => hasHeader(t) ? 0 : 1;
190
+ const shapeOf = t => `${T.rowCount(t)}x${width(t)}:${firstRow(t)}`;
191
+ const renderedDocuments = new WeakMap();
192
+ const measureScroll = (view, wrap) => view.requestMeasure({
193
+ key: wrap,
194
+ read: () => ({
195
+ left: wrap.scrollLeft,
196
+ width: wrap.clientWidth,
197
+ total: wrap.scrollWidth
198
+ }),
199
+ write: ({
200
+ left,
201
+ width,
202
+ total
203
+ }) => {
204
+ if (!wrap.isConnected) return;
205
+ wrap.toggleAttribute("data-scrolled", left > 0);
206
+ wrap.toggleAttribute("data-more", left + width < total - 1);
207
+ }
208
+ });
209
+ class GridWidget extends WidgetType {
210
+ constructor(table, readOnly) {
211
+ super();
212
+ this.table = table;
213
+ this.readOnly = readOnly;
214
+ }
215
+ // Always update the existing DOM on document/refresh transactions. A shape
216
+ // deferred during composition must be patched even when the next table value
217
+ // is equal; app-owned formulas may also depend on cells outside this table.
218
+ toDOM(view) {
219
+ const wrap = document.createElement("div");
220
+ wrap.className = "k2b-ste-table";
221
+ wrap.contentEditable = "false";
222
+ wrap.dataset.shape = shapeOf(this.table);
223
+ wrap.append(buildGrid(view, this.table, wrap));
224
+ wrap.addEventListener("pointerleave", () => {
225
+ wrap.querySelectorAll(".k2b-ste-columnMenu").forEach(grip => delete grip.dataset.hovered);
226
+ });
227
+ wrap.addEventListener("scroll", () => {
228
+ wrap.toggleAttribute("data-scrolled", wrap.scrollLeft > 0);
229
+ wrap.toggleAttribute("data-more", wrap.scrollLeft + wrap.clientWidth < wrap.scrollWidth - 1);
230
+ });
231
+ renderedDocuments.set(wrap, view.state.doc);
232
+ setEditable(wrap, !this.readOnly, view);
233
+ measureScroll(view, wrap);
234
+ return wrap;
235
+ }
236
+ updateDOM(dom, view) {
237
+ const act = active.get(view);
238
+ measureScroll(view, dom);
239
+ if (dom.dataset.shape !== shapeOf(this.table)) {
240
+ const focused = dom.contains(document.activeElement) ? document.activeElement : null;
241
+ // Never rebuild under a running IME composition; the commit at compositionend refreshes.
242
+ if (focused && act?.composing) {
243
+ dom.dataset.pendingShape = "1";
244
+ return true;
245
+ }
246
+ const sync = syncs.get(view);
247
+ if (focused && sync?.el === focused && !targeted.has(view)) sync.refocusSelection = selectionOffsets(focused);
248
+ dom.dataset.shape = shapeOf(this.table);
249
+ delete dom.dataset.pendingShape;
250
+ rebuilding.add(view);
251
+ const source = sources.get(dom)?.element;
252
+ if (source) {
253
+ // Keep the draft editor connected while the confirmed grid changes.
254
+ // Detaching it loses native focus before its receipt can return the
255
+ // caret to the cell selected in the draft.
256
+ for (const child of [...dom.childNodes]) if (child !== source) child.remove();
257
+ dom.insertBefore(buildGrid(view, this.table, dom), source);
258
+ } else dom.replaceChildren(buildGrid(view, this.table, dom));
259
+ renderedDocuments.set(dom, view.state.doc);
260
+ rebuilding.delete(view);
261
+ setEditable(dom, !this.readOnly, view);
262
+ dom.dispatchEvent(new CustomEvent("k2b-ste-table-structure", {
263
+ bubbles: true,
264
+ detail: {
265
+ rows: T.rowCount(this.table),
266
+ columns: this.table.columns
267
+ }
268
+ }));
269
+ return true;
270
+ }
271
+ // Host formulas depend on document changes, not selection-only effects.
272
+ // Replacing an inactive cell's descendants during pointerdown removes the
273
+ // click target before the browser focuses it and can select the whole note.
274
+ const refreshPresentation = renderedDocuments.get(dom) !== view.state.doc;
275
+ patchGrid(view, this.table, dom, refreshPresentation);
276
+ renderedDocuments.set(dom, view.state.doc);
277
+ setEditable(dom, !this.readOnly, view);
278
+ return true;
279
+ }
280
+ destroy(dom) {
281
+ const source = sources.get(dom);
282
+ if (source) {
283
+ const draft = source.editor.state.doc.toString();
284
+ if (draft !== source.base) queueMicrotask(() => source.recover(draft));
285
+ source.cleanup();
286
+ source.editor.destroy();
287
+ sources.delete(dom);
288
+ }
289
+ }
290
+ ignoreEvent() {
291
+ return true;
292
+ }
293
+ get estimatedHeight() {
294
+ return (T.rowCount(this.table) - firstRow(this.table)) * 41 + 16;
295
+ }
296
+ }
297
+ const sources = new WeakMap();
298
+
299
+ /** A local source draft for one table. Invalid drafts never replace shared text.
300
+ * Collaborative structure changes require the host's confirmed anchored action. */
301
+ export const editTableSource = (view, wrap) => {
302
+ const table = currentTable(view, wrap);
303
+ if (!table || view.state.readOnly || sources.has(wrap)) return;
304
+ const host = view.state.facet(tableHost);
305
+ const en = textEditorMessages.resolve(["en"]).t;
306
+ const labels = host?.sourceLabels?.() ?? {
307
+ edit: en.editMarkdown,
308
+ valid: en.markdownValid,
309
+ invalid: en.markdownInvalid,
310
+ changed: en.markdownChanged,
311
+ structural: en.markdownStructural,
312
+ done: en.showTable,
313
+ discard: en.discardMarkdown,
314
+ continue: en.continueMarkdown,
315
+ line: en.markdownLine,
316
+ separator: (header, separator) => en.markdownSeparator({
317
+ header,
318
+ separator
319
+ }),
320
+ extra: en.markdownExtra,
321
+ complete: en.completeColumns
322
+ };
323
+ const base = view.state.sliceDoc(table.from, table.to);
324
+ const original = active.get(view);
325
+ const originalCell = original && T.gridLine(table, original.r)?.cells[original.c];
326
+ const overlay = document.createElement("div");
327
+ overlay.className = "k2b-ste-table-source";
328
+ const bar = document.createElement("div");
329
+ bar.className = "k2b-ste-source-bar";
330
+ const status = document.createElement("span");
331
+ status.textContent = labels.valid;
332
+ status.setAttribute("role", "status");
333
+ const body = document.createElement("div");
334
+ body.className = "k2b-ste-source-body";
335
+ const error = document.createElement("div");
336
+ error.className = "k2b-ste-source-error";
337
+ error.setAttribute("role", "alert");
338
+ error.hidden = true;
339
+ const button = (label, run) => {
340
+ const element = document.createElement("button");
341
+ element.type = "button";
342
+ element.textContent = label;
343
+ element.onclick = run;
344
+ return element;
345
+ };
346
+ const close = (r, c, changed = [], restoreFocus = true) => {
347
+ sources.get(wrap)?.cleanup();
348
+ sources.delete(wrap);
349
+ source.destroy();
350
+ overlay.remove();
351
+ delete wrap.dataset.source;
352
+ wrap.style.removeProperty("height");
353
+ const current = currentTable(view, wrap);
354
+ if (current && restoreFocus) move(view, wrap, Math.max(firstRow(current), Math.min(r, T.rowCount(current) - 1)), Math.min(c, current.columns - 1));
355
+ wrap.querySelectorAll("tr").forEach(row => row.toggleAttribute("data-source-changed", changed.includes(Number(row.dataset.r))));
356
+ };
357
+ const fail = (message, line, complete, focus = true) => {
358
+ error.hidden = false;
359
+ error.dataset.tone = complete ? "warning" : "error";
360
+ status.textContent = labels.edit;
361
+ const selectLine = () => {
362
+ const target = source.state.doc.line(Math.min(line ?? 1, source.state.doc.lines));
363
+ source.dispatch({
364
+ selection: {
365
+ anchor: target.from,
366
+ head: target.to
367
+ },
368
+ scrollIntoView: true
369
+ });
370
+ source.focus();
371
+ };
372
+ error.replaceChildren(document.createTextNode(`${complete || line === undefined ? "" : labels.line?.(line) ?? en.markdownLine(line)}${message}`), ...(complete ? [button(labels.complete ?? en.completeColumns, complete)] : []), button(labels.continue, selectLine), button(labels.discard, () => close(original?.r ?? 0, original?.c ?? 0)));
373
+ if (focus) source.focus();
374
+ };
375
+ const parsed = () => {
376
+ const draft = source.state.doc.toString();
377
+ const tables = T.findTables(draft);
378
+ const next = tables[0];
379
+ return next && tables.length === 1 && next.from === 0 && next.to === draft.length && !T.inspectTableStructure(draft).extraCells && !T.inspectTableStructure(draft).missingSeparator ? next : null;
380
+ };
381
+ const apply = () => {
382
+ if (view.state.readOnly || sources.get(wrap)?.pending) return;
383
+ const current = currentTable(view, wrap);
384
+ const draft = source.state.doc.toString();
385
+ const candidate = T.findTables(draft)[0];
386
+ const extra = candidate?.lines.find(line => line.cells.length > candidate.columns);
387
+ if (candidate && candidate.from === 0 && candidate.to === draft.length && extra) {
388
+ const line = source.state.doc.lineAt(extra.from).number;
389
+ return fail(labels.extra?.(line) ?? en.markdownExtra(line), line, () => {
390
+ source.dispatch({
391
+ changes: T.completeColumns(candidate)
392
+ });
393
+ apply();
394
+ });
395
+ }
396
+ const next = parsed();
397
+ if (!next) {
398
+ // Count source cells for diagnostics only; the public GFM parser remains
399
+ // the authority for whether this draft can replace a table.
400
+ const split = text => {
401
+ const cells = [""];
402
+ for (let index = 0; index < text.length; index++) {
403
+ const char = text[index];
404
+ if (char === "\\" && index + 1 < text.length) cells[cells.length - 1] += char + text[++index];else if (char === "|") cells.push("");else cells[cells.length - 1] += char;
405
+ }
406
+ if (cells.length > 1 && !cells[0].trim()) cells.shift();
407
+ if (cells.length > 1 && !cells.at(-1).trim()) cells.pop();
408
+ return cells.map(cell => cell.trim());
409
+ };
410
+ if (source.state.doc.lines > 1) {
411
+ const header = split(source.state.doc.line(1).text),
412
+ separator = split(source.state.doc.line(2).text);
413
+ if (separator.every(cell => /^:?-+:?$/.test(cell)) && header.length !== separator.length) return fail(labels.separator?.(header.length, separator.length) ?? en.markdownSeparator({
414
+ header: header.length,
415
+ separator: separator.length
416
+ }), 2);
417
+ if (!separator.every(cell => /^:?-+:?$/.test(cell))) return fail(labels.invalid, 2);
418
+ }
419
+ return fail(labels.invalid, candidate ? source.state.doc.lineAt(Math.min(candidate.to + 1, draft.length)).number : 1);
420
+ }
421
+ if (!current || view.state.sliceDoc(current.from, current.to) !== base) return fail(labels.changed);
422
+ let changes = [];
423
+ let structural = false;
424
+ if (host?.structure) {
425
+ structural = shapeOf(current) !== shapeOf(next) || view.state.sliceDoc(current.lines[1].from, current.lines[1].to) !== source.state.sliceDoc(next.lines[1].from, next.lines[1].to);
426
+ if (!structural) {
427
+ for (let r = 0; r < T.rowCount(next); r++) {
428
+ const old = T.gridLine(current, r);
429
+ const row = T.gridLine(next, r);
430
+ for (let c = 0; c < next.columns; c++) {
431
+ const before = old.cells[c],
432
+ after = row.cells[c];
433
+ if (!before || !after) {
434
+ structural = true;
435
+ break;
436
+ }
437
+ if (before.text !== after.text) changes.push(...T.cellChanges(before.text, T.displayCell(after.text), before.from));
438
+ }
439
+ }
440
+ // Formatting pipes and padding is structural even when dimensions match.
441
+ structural ||= T.applyChanges(base, changes.map(change => ({
442
+ ...change,
443
+ from: change.from - current.from,
444
+ to: change.to - current.from
445
+ }))) !== draft;
446
+ }
447
+ } else changes = [{
448
+ from: current.from,
449
+ to: current.to,
450
+ insert: draft
451
+ }];
452
+ const hit = cellAtPos(EditorState.create({
453
+ doc: source.state.doc
454
+ }), source.state.selection.main.head);
455
+ const changed = Array.from({
456
+ length: T.rowCount(next)
457
+ }, (_, row) => row).filter(row => {
458
+ const before = T.gridLine(current, row),
459
+ after = T.gridLine(next, row);
460
+ return !before || !after || view.state.sliceDoc(before.from, before.to) !== source.state.sliceDoc(after.from, after.to);
461
+ });
462
+ if (structural) {
463
+ const structure = host?.structure;
464
+ if (!structure?.replaceSource) return fail(labels.structural);
465
+ if (!structure.available()) {
466
+ host?.onError("structureUnavailable");
467
+ return;
468
+ }
469
+ const session = sources.get(wrap);
470
+ session.pending = true;
471
+ // Keep native focus on the action while waiting. Disabling a focused
472
+ // button blurs it to body, losing the draft's focus ownership on receipt.
473
+ // The pending guard above prevents keyboard and pointer resubmission.
474
+ done.setAttribute("aria-disabled", "true");
475
+ overlay.setAttribute("aria-busy", "true");
476
+ status.textContent = labels.applying ?? en.applyingTable;
477
+ source.dispatch({
478
+ effects: readOnly.reconfigure(EditorState.readOnly.of(true))
479
+ });
480
+ // The host captures the table witnesses synchronously, before any await.
481
+ const target = {
482
+ tableFrom: current.from,
483
+ rowFrom: current.lines[0].from,
484
+ columnFrom: current.lines[0].cells[0].from,
485
+ row: 0,
486
+ column: 0
487
+ };
488
+ const settled = () => {
489
+ if (sources.get(wrap) !== session) return false;
490
+ session.pending = false;
491
+ done.removeAttribute("aria-disabled");
492
+ overlay.removeAttribute("aria-busy");
493
+ source.dispatch({
494
+ effects: readOnly.reconfigure(EditorState.readOnly.of(view.state.readOnly))
495
+ });
496
+ return true;
497
+ };
498
+ try {
499
+ void structure.replaceSource(target, base, draft, {
500
+ row: hit?.r ?? 0,
501
+ column: hit?.c ?? 0
502
+ }).then(resolve => {
503
+ if (settled()) {
504
+ const restoreFocus = overlay.contains(view.dom.ownerDocument.activeElement);
505
+ close(0, 0, changed, false);
506
+ if (restoreFocus && resolve) focusTableTarget(view, resolve());
507
+ }
508
+ }, () => {
509
+ if (settled()) fail(labels.applyFailed ?? en.markdownApplyFailed, undefined, undefined, overlay.contains(view.dom.ownerDocument.activeElement));
510
+ });
511
+ } catch {
512
+ if (settled()) fail(labels.applyFailed ?? en.markdownApplyFailed);
513
+ }
514
+ return;
515
+ }
516
+ if (changes.length && !dispatchChanges(view, changes, true)) return;
517
+ close(hit?.r ?? original?.r ?? 0, hit?.c ?? original?.c ?? 0, changed);
518
+ };
519
+ const readOnly = new Compartment();
520
+ const source = new EditorView({
521
+ parent: body,
522
+ state: EditorState.create({
523
+ doc: base,
524
+ selection: {
525
+ anchor: Math.max(0, (originalCell?.from ?? table.from) - table.from)
526
+ },
527
+ extensions: [readOnly.of(EditorState.readOnly.of(false)), history(), keymap.of([{
528
+ key: "Escape",
529
+ run: () => {
530
+ apply();
531
+ return true;
532
+ }
533
+ }, ...historyKeymap]), EditorView.contentAttributes.of({
534
+ "aria-label": labels.edit
535
+ }), EditorView.updateListener.of(update => {
536
+ if (update.docChanged) {
537
+ status.textContent = parsed() ? labels.valid : labels.invalid;
538
+ error.hidden = true;
539
+ }
540
+ })]
541
+ })
542
+ });
543
+ const done = button(labels.done, apply);
544
+ done.className = "k2b-button";
545
+ done.dataset.size = "sm";
546
+ bar.append(status, done);
547
+ overlay.append(bar, body, error);
548
+ const beforeUnload = event => {
549
+ if (source.state.doc.toString() === base) return;
550
+ event.preventDefault();
551
+ event.returnValue = "";
552
+ };
553
+ view.dom.ownerDocument.defaultView?.addEventListener("beforeunload", beforeUnload);
554
+ sources.set(wrap, {
555
+ element: overlay,
556
+ pending: false,
557
+ editor: source,
558
+ base,
559
+ readOnly,
560
+ recover: text => host?.onRecovery(text),
561
+ cleanup: () => view.dom.ownerDocument.defaultView?.removeEventListener("beforeunload", beforeUnload)
562
+ });
563
+ wrap.style.height = `${wrap.getBoundingClientRect().height}px`;
564
+ wrap.dataset.source = "1";
565
+ wrap.querySelectorAll("tr").forEach(row => row.removeAttribute("data-source-changed"));
566
+ wrap.append(overlay);
567
+ source.focus();
568
+ };
569
+ const setEditable = (wrap, on, view) => {
570
+ wrap.dataset.readonly = on ? "0" : "1";
571
+ const source = sources.get(wrap);
572
+ if (source && source.editor.state.readOnly !== (!on || source.pending)) source.editor.dispatch({
573
+ effects: source.readOnly.reconfigure(EditorState.readOnly.of(!on || source.pending))
574
+ });
575
+ wrap.querySelectorAll(".k2b-ste-grip, .k2b-ste-table-add button").forEach(button => {
576
+ const structure = view.state.facet(tableHost)?.structure;
577
+ button.disabled = !on || button.classList.contains("k2b-ste-addColumn") && !!structure && !structure.available();
578
+ button.hidden = !on;
579
+ });
580
+ wrap.querySelector("table")?.setAttribute("aria-readonly", String(!on));
581
+ wrap.querySelectorAll(".k2b-ste-cell").forEach(cell => {
582
+ cell.setAttribute("aria-readonly", String(!on));
583
+ if (!on) {
584
+ cell.contentEditable = "false";
585
+ // Losing edit access must restore presentation and detach the mutable
586
+ // cell baseline before later remote updates reconcile the document.
587
+ if (cell.dataset.editing === "1") {
588
+ cell.dataset.editing = "0";
589
+ const table = currentTable(view, wrap);
590
+ if (table) renderCell(view, cell, cell.dataset.raw ?? "", table, Number(cell.dataset.r), Number(cell.dataset.c));
591
+ }
592
+ if (syncs.get(view)?.el === cell) syncs.delete(view);
593
+ if (missingCells.get(view)?.el === cell) missingCells.delete(view);
594
+ } else {
595
+ const wasReadOnly = cell.contentEditable === "false";
596
+ cell.contentEditable = "plaintext-only";
597
+ if (cell.contentEditable !== "plaintext-only") cell.contentEditable = "true";
598
+ // Regaining access while this cell still has focus must enter editing
599
+ // through the same handler as a click, before typing can replace a result.
600
+ if (wasReadOnly && cell === cell.ownerDocument.activeElement) {
601
+ cell.blur();
602
+ cell.focus({
603
+ preventScroll: true
604
+ });
605
+ }
606
+ }
607
+ });
608
+ };
609
+
610
+ /** Grid width: the widest row, so text beyond the header's columns stays visible (never hidden). */
611
+ const width = t => t.lines.reduce((width, line) => Math.max(width, line.cells.length), t.columns);
612
+ const cells = t => {
613
+ const columns = width(t);
614
+ return Array.from({
615
+ length: T.rowCount(t)
616
+ }, (_, r) => Array.from({
617
+ length: columns
618
+ }, (_, c) => T.gridLine(t, r)?.cells[c]?.text ?? ""));
619
+ };
620
+ const focusCell = (el, offset) => {
621
+ el.dataset.editing = "1";
622
+ el.textContent = T.displayCell(el.dataset.raw ?? "");
623
+ placeCaret(el, offset);
624
+ };
625
+ const buildGrid = (view, t, wrap) => {
626
+ const table = document.createElement("table");
627
+ const grid = cells(t);
628
+ wrap.dataset.header = String(hasHeader(t));
629
+ grid.forEach((row, r) => {
630
+ if (r < firstRow(t)) return;
631
+ const tr = document.createElement("tr");
632
+ tr.dataset.r = String(r);
633
+ row.forEach((raw, c) => {
634
+ const td = document.createElement(r === 0 ? "th" : "td");
635
+ td.style.textAlign = t.align[c] ?? "left";
636
+ // A cell beyond the header's columns: email renderers drop it, so it is marked for review.
637
+ if (c >= t.columns && (T.gridLine(t, r)?.cells.length ?? 0) > c) td.dataset.extra = "1";
638
+ const cell = document.createElement("div");
639
+ cell.className = "k2b-ste-cell";
640
+ cell.dataset.r = String(r);
641
+ cell.dataset.c = String(c);
642
+ cell.dataset.raw = raw;
643
+ cell.contentEditable = "plaintext-only";
644
+ if (cell.contentEditable !== "plaintext-only") cell.contentEditable = "true";
645
+ cell.spellcheck = true;
646
+ cell.tabIndex = -1;
647
+ cell.setAttribute("role", "textbox");
648
+ cell.setAttribute("aria-label", view.state.facet(tableHost)?.cellLabel(r, c, T.displayCell(grid[0]?.[c] ?? "")) ?? `${r + 1}, ${c + 1}`);
649
+ if (r === 1 && c === 0 && !raw) cell.dataset.placeholder = view.state.facet(tableHost)?.actionLabel?.("emptyCell") ?? "Type or paste cells";
650
+ renderCell(view, cell, raw, t, r, c);
651
+ wire(view, cell, wrap);
652
+ td.setAttribute("role", r === 0 ? "columnheader" : "gridcell");
653
+ td.append(cell);
654
+ const grip = kind => {
655
+ const button = document.createElement("button");
656
+ button.type = "button";
657
+ button.tabIndex = -1;
658
+ button.className = `k2b-ste-grip k2b-ste-${kind}`;
659
+ button.setAttribute("aria-label", view.state.facet(tableHost)?.actionLabel?.(kind) ?? (kind === "rowMenu" ? "Row menu" : "Column menu"));
660
+ button.setAttribute("aria-haspopup", "menu");
661
+ button.innerHTML = '<i class="ti ti-grip-vertical" aria-hidden="true"></i>';
662
+ button.onmousedown = event => event.preventDefault();
663
+ button.onclick = () => {
664
+ cell.focus({
665
+ preventScroll: true
666
+ });
667
+ wrap.dispatchEvent(new CustomEvent("k2b-ste-cell-menu", {
668
+ bubbles: true,
669
+ detail: {
670
+ cell,
671
+ anchor: button
672
+ }
673
+ }));
674
+ };
675
+ td.append(button);
676
+ };
677
+ if (c === 0) grip("rowMenu");
678
+ if (r === firstRow(t)) grip("columnMenu");
679
+ tr.append(td);
680
+ });
681
+ tr.setAttribute("role", "row");
682
+ table.append(tr);
683
+ });
684
+ table.setAttribute("role", "grid");
685
+ // Exactly one grid entry point; internal movement uses the cell keys.
686
+ const first = table.querySelector(".k2b-ste-cell");
687
+ if (first) first.tabIndex = 0;
688
+ const footer = document.createElement("div");
689
+ footer.className = "k2b-ste-table-add";
690
+ for (const action of ["addRow", "addColumn"]) {
691
+ const button = document.createElement("button");
692
+ button.type = "button";
693
+ button.className = `k2b-ste-${action}`;
694
+ const label = view.state.facet(tableHost)?.actionLabel?.(action) ?? (action === "addRow" ? "Row" : "Column");
695
+ button.textContent = action === "addRow" ? `+ ${label}` : "+";
696
+ if (action === "addColumn") button.setAttribute("aria-label", label);
697
+ button.onmousedown = event => event.preventDefault();
698
+ button.onclick = () => {
699
+ const current = currentTable(view, wrap);
700
+ if (current) runTableAction(view, current.from, T.rowCount(current) - 1, current.columns - 1, action === "addRow" ? "rowBelow" : "colRight");
701
+ };
702
+ footer.append(button);
703
+ }
704
+ const fragment = document.createDocumentFragment();
705
+ fragment.append(table, footer);
706
+ return fragment;
707
+ };
708
+ const patchGrid = (view, t, dom, refreshPresentation) => {
709
+ const grid = cells(t);
710
+ dom.querySelectorAll(".k2b-ste-cell").forEach(el => {
711
+ const r = Number(el.dataset.r);
712
+ const c = Number(el.dataset.c);
713
+ const raw = grid[r]?.[c] ?? "";
714
+ const parent = el.parentElement;
715
+ parent.style.textAlign = t.align[c] ?? "left";
716
+ el.setAttribute("aria-label", view.state.facet(tableHost)?.cellLabel(r, c, T.displayCell(grid[0]?.[c] ?? "")) ?? `${r + 1}, ${c + 1}`);
717
+ if (c >= t.columns && (T.gridLine(t, r)?.cells.length ?? 0) > c) parent.dataset.extra = "1";else delete parent.dataset.extra;
718
+ if (el.dataset.raw === raw && !(refreshPresentation && view.state.facet(tableHost)?.renderCell)) return;
719
+ el.dataset.raw = raw;
720
+ if (el.dataset.editing === "1") {
721
+ // The focused cell is refreshed after the update, with mapped caret (see `followChanges`).
722
+ el.dataset.stale = "1";
723
+ return;
724
+ }
725
+ renderCell(view, el, raw, t, r, c);
726
+ });
727
+ };
728
+ const dispatchChanges = (view, changes, structural = false, selection) => {
729
+ if (!changes.length || view.state.readOnly) return false;
730
+ const length = view.state.doc.length + changes.reduce((n, c) => n + c.insert.length - (c.to - c.from), 0);
731
+ if (length > (view.state.facet(tableHost)?.maxLength ?? Infinity)) {
732
+ view.state.facet(tableHost)?.onError("textTooLarge");
733
+ return false;
734
+ }
735
+ const host = view.state.facet(tableHost);
736
+ if (structural) host?.boundary();
737
+ if (structural) targeted.add(view);
738
+ try {
739
+ view.dispatch({
740
+ changes: [...changes].sort((a, b) => a.from - b.from).map(change => ({
741
+ ...change,
742
+ insert: Text.of(change.insert.split("\n"))
743
+ })),
744
+ selection,
745
+ annotations: structural ? isolateHistory.of("full") : undefined,
746
+ userEvent: structural ? "input.table.structure" : "input.type.table"
747
+ });
748
+ } finally {
749
+ targeted.delete(view);
750
+ }
751
+ if (structural) host?.boundary();
752
+ return true;
753
+ };
754
+ const recoverMissingCell = (view, el) => {
755
+ const input = el.textContent ?? "";
756
+ if (input) view.state.facet(tableHost)?.onRecovery(input);
757
+ view.state.facet(tableHost)?.onError("cellRemoved");
758
+ el.dataset.editing = "0";
759
+ const wrap = el.closest(".k2b-ste-table");
760
+ const table = wrap && currentTable(view, wrap);
761
+ if (table) renderCell(view, el, el.dataset.raw ?? "", table, Number(el.dataset.r), Number(el.dataset.c));
762
+ el.blur();
763
+ };
764
+ const commitCell = (view, el, wrap) => {
765
+ const missing = missingCells.get(view);
766
+ if (missing?.el === el && missing.changed) {
767
+ recoverMissingCell(view, el);
768
+ return;
769
+ }
770
+ let sync = syncs.get(view);
771
+ if (!sync || sync.el !== el) {
772
+ const t = currentTable(view, wrap);
773
+ const cell = t && T.gridLine(t, Number(el.dataset.r))?.cells[Number(el.dataset.c)];
774
+ if (!cell) {
775
+ if (!t || view.state.readOnly) {
776
+ const host = view.state.facet(tableHost);
777
+ host?.onRecovery(el.textContent ?? "");
778
+ if (!t) host?.onError("cellRemoved");
779
+ return;
780
+ }
781
+ const row = Number(el.dataset.r),
782
+ column = Number(el.dataset.c);
783
+ const changes = T.setCellText(t, row, column, T.escapeCell(el.textContent ?? ""));
784
+ missingCells.delete(view);
785
+ if (!dispatchChanges(view, changes)) {
786
+ view.state.facet(tableHost)?.onRecovery(el.textContent ?? "");
787
+ el.textContent = "";
788
+ return;
789
+ }
790
+ const updated = tablesIn(view.state).find(table => table.from === t.from);
791
+ const created = updated && T.gridLine(updated, row)?.cells[column];
792
+ if (created) {
793
+ resync(view, el, created);
794
+ el.dataset.raw = created.text;
795
+ }
796
+ return;
797
+ }
798
+ resync(view, el, cell);
799
+ sync = syncs.get(view);
800
+ }
801
+ const next = (el.textContent ?? "").replace(/\u00a0(?=\s|$)|(?<=\s|^)\u00a0/g, " ");
802
+ const mappedFrom = sync.desc.mapPos(sync.baseFrom, 1);
803
+ const hit = cellAtPos(view.state, mappedFrom);
804
+ if (!cellSurvives(sync) || !hit) {
805
+ const host = view.state.facet(tableHost);
806
+ host?.onRecovery(next);
807
+ host?.onError("cellRemoved");
808
+ syncs.delete(view);
809
+ view.dispatch({
810
+ effects: refreshTables.of(null)
811
+ });
812
+ return;
813
+ }
814
+ const changes = T.mappedCellChanges(sync.base, next, sync.baseFrom, sync.desc);
815
+ if (!changes.length) {
816
+ // A cancelled composition still needs to reveal remote changes and shape updates.
817
+ view.dispatch({
818
+ effects: refreshTables.of(null)
819
+ });
820
+ return;
821
+ }
822
+ const set = ChangeSet.of(changes, view.state.doc.length);
823
+ const from = set.mapPos(sync.desc.mapPos(sync.baseFrom, -1), -1);
824
+ const to = set.mapPos(sync.desc.mapPos(sync.baseFrom + sync.base.length, 1), 1);
825
+ const [anchorOffset, headOffset] = selectionOffsets(el);
826
+ const localChanges = T.cellChanges(sync.base, next, sync.baseFrom);
827
+ const localSet = ChangeSet.of(localChanges, sync.desc.length);
828
+ const localRaw = T.applyChanges(sync.base, localChanges.map(change => ({
829
+ ...change,
830
+ from: change.from - sync.baseFrom,
831
+ to: change.to - sync.baseFrom
832
+ })));
833
+ const localMap = T.cellDisplayMap(localRaw);
834
+ const remoteAfterLocal = sync.desc.mapDesc(localSet.desc);
835
+ const position = offset => remoteAfterLocal.mapPos(localSet.mapPos(sync.baseFrom, -1) + (localMap.offsets[offset] ?? localRaw.length), 1);
836
+ const selection = {
837
+ anchor: position(anchorOffset),
838
+ head: position(headOffset)
839
+ };
840
+ sync.committing = true;
841
+ const accepted = dispatchChanges(view, changes, false, selection);
842
+ sync.committing = false;
843
+ if (!accepted) {
844
+ view.state.facet(tableHost)?.onRecovery(next);
845
+ el.textContent = T.displayCell(sync.base);
846
+ view.dispatch({
847
+ effects: refreshTables.of(null)
848
+ });
849
+ return;
850
+ }
851
+ // Keep the focused input's full extent, including a just-typed trailing space.
852
+ // The Markdown parser trims padding and cannot distinguish it from that space.
853
+ const raw = view.state.sliceDoc(from, to);
854
+ const current = cellAtPos(view.state, from);
855
+ const index = current ? tablesIn(view.state).findIndex(table => table.from === current.table.from) : -1;
856
+ const currentWrap = findWrap(view, index);
857
+ const target = current && currentWrap && cellAt(currentWrap, current.r, current.c);
858
+ if (!target) {
859
+ syncs.delete(view);
860
+ return;
861
+ }
862
+ // Composition deferred reconciliation. Even a connected element may now
863
+ // occupy another column after a same-size structural edit.
864
+ if (target !== el) {
865
+ if (el.isConnected) {
866
+ el.dataset.editing = "0";
867
+ const oldTable = currentTable(view, wrap);
868
+ if (oldTable) renderCell(view, el, el.dataset.raw ?? "", oldTable, Number(el.dataset.r), Number(el.dataset.c));
869
+ }
870
+ focusCell(target, 0);
871
+ }
872
+ target.dataset.raw = raw;
873
+ target.dataset.stale = "0";
874
+ resync(view, target, {
875
+ from,
876
+ to,
877
+ text: raw
878
+ });
879
+ const shown = T.displayCell(raw);
880
+ if (((target.textContent ?? "") !== shown || target !== el) && !active.get(view)?.composing) {
881
+ target.textContent = shown;
882
+ placeSelection(target, T.displayCell(view.state.sliceDoc(from, selection.anchor)).length, T.displayCell(view.state.sliceDoc(from, selection.head)).length);
883
+ }
884
+ if (wrap.dataset.pendingShape === "1") view.dispatch({
885
+ effects: refreshTables.of(null)
886
+ });
887
+ queueCellSelection(view, target);
888
+ };
889
+
890
+ /** After every update: track changes for the focused cell, refresh it, or refocus it after a rebuild. */
891
+ const followChanges = EditorView.updateListener.of(update => {
892
+ const view = update.view;
893
+ const sync = syncs.get(view);
894
+ const missing = missingCells.get(view);
895
+ if (missing && !missing.changed && update.docChanged) {
896
+ missing.changed = true;
897
+ if (!active.get(view)?.composing) recoverMissingCell(view, missing.el);
898
+ }
899
+ if (!sync) return;
900
+ if (update.docChanged) sync.desc = sync.desc.composeDesc(update.changes.desc);
901
+ if (sync.committing || active.get(view)?.composing) return;
902
+ const caretAt = caret => sync.desc.mapPos(sync.baseFrom + (T.cellDisplayMap(sync.base).offsets[caret] ?? sync.base.length), 1);
903
+ const explicit = update.transactions.some(tr => tr.selection !== undefined);
904
+ if (!sync.refocusSelection && sync.el.dataset.stale !== "1" && !update.docChanged && !explicit) return;
905
+ const selected = sync.refocusSelection ?? (document.activeElement === sync.el ? selectionOffsets(sync.el) : sync.selection ?? [0, 0]);
906
+ const anchor = explicit ? update.state.selection.main.anchor : caretAt(selected[0]),
907
+ head = explicit ? update.state.selection.main.head : caretAt(selected[1]);
908
+ const hadFocus = sync.refocusSelection !== null || document.activeElement === sync.el;
909
+ sync.refocusSelection = null;
910
+ const hit = cellAtPos(view.state, sync.desc.mapPos(sync.baseFrom, 1));
911
+ if (!hit) return;
912
+ const index = tablesIn(view.state).findIndex(t => t.from === hit.table.from);
913
+ const wrap = findWrap(view, index);
914
+ const el = wrap && cellAt(wrap, hit.r, hit.c);
915
+ if (!el) return;
916
+ const survives = cellSurvives(sync);
917
+ const from = survives ? sync.desc.mapPos(sync.baseFrom, -1) : hit.cell.from,
918
+ to = survives ? sync.desc.mapPos(sync.baseFrom + sync.base.length, 1) : hit.cell.to;
919
+ const raw = view.state.sliceDoc(from, to);
920
+ const mapped = T.cellDisplayMap(raw);
921
+ const offset = pos => {
922
+ const at = mapped.offsets.findIndex(n => n >= pos - from);
923
+ return at < 0 ? mapped.text.length : at;
924
+ };
925
+ if (el !== sync.el && sync.el.isConnected) {
926
+ sync.el.dataset.editing = "0";
927
+ const oldWrap = sync.el.closest(".k2b-ste-table");
928
+ const oldTable = oldWrap && currentTable(view, oldWrap);
929
+ if (oldTable) renderCell(view, sync.el, sync.el.dataset.raw ?? "", oldTable, Number(sync.el.dataset.r), Number(sync.el.dataset.c));
930
+ }
931
+ el.dataset.stale = "0";
932
+ if (hadFocus) {
933
+ focusCell(el, 0);
934
+ el.dataset.raw = raw;
935
+ el.textContent = mapped.text;
936
+ placeSelection(el, offset(anchor), offset(head));
937
+ }
938
+ resync(view, el, {
939
+ from,
940
+ to,
941
+ text: raw
942
+ });
943
+ const current = syncs.get(view);
944
+ if (current) current.selection = [offset(anchor), offset(head)];
945
+ });
946
+ const move = (view, wrap, r, c, edge = "end") => {
947
+ const index = active.get(view)?.index ?? -1;
948
+ const target = wrap.isConnected ? wrap : findWrap(view, index);
949
+ const el = target && cellAt(target, r, c);
950
+ if (el) {
951
+ focusCell(el, edge === "start" ? 0 : T.displayCell(el.dataset.raw ?? "").length);
952
+ if (target) {
953
+ target.querySelectorAll(".k2b-ste-cell").forEach(cell => cell.tabIndex = cell === el ? 0 : -1);
954
+ const bounds = target.getBoundingClientRect();
955
+ const box = el.getBoundingClientRect();
956
+ const frozen = c > 0 ? target.querySelector("tr > :first-child")?.getBoundingClientRect().width ?? 0 : 0;
957
+ if (box.left < bounds.left + frozen) target.scrollLeft -= bounds.left + frozen - box.left;else if (box.right > bounds.right - 16) target.scrollLeft += box.right - bounds.right + 16;
958
+ }
959
+ }
960
+ };
961
+
962
+ /** Source identities, never visual row indexes, decide menu and shortcut boundaries. */
963
+ const actionAllowed = (t, r, c, action) => {
964
+ if (!Number.isInteger(r) || !Number.isInteger(c) || r < 0 || r >= T.rowCount(t) || c < 0 || c >= width(t)) return false;
965
+ switch (action) {
966
+ case "rowAbove":
967
+ case "deleteRow":
968
+ return r > 0;
969
+ case "moveRowUp":
970
+ return r > 1;
971
+ case "moveRowDown":
972
+ return r > 0 && r < T.rowCount(t) - 1;
973
+ case "deleteCol":
974
+ return t.columns > 1 || c >= t.columns;
975
+ case "moveColLeft":
976
+ case "moveColRight":
977
+ return c < t.columns && (action === "moveColLeft" ? c > 0 : c < t.columns - 1) && t.lines.every(line => line.cells.length === t.columns);
978
+ case "toggleHeader":
979
+ return hasHeader(t) || !!(t.lines[2]?.cells.some(cell => cell.text) && t.lines[2].cells.length <= t.columns);
980
+ default:
981
+ return true;
982
+ }
983
+ };
984
+ export const tableActionEnabled = (view, wrap, action) => {
985
+ const t = currentTable(view, wrap),
986
+ act = active.get(view);
987
+ if (!t || !act || view.state.readOnly || !actionAllowed(t, act.r, act.c, action)) return false;
988
+ const structure = view.state.facet(tableHost)?.structure;
989
+ return !structure || structure.available() || action === "rowBelow" && act.r === T.rowCount(t) - 1;
990
+ };
991
+
992
+ /** Copy the visible table through the browser clipboard, with spreadsheet and rich-text payloads. */
993
+ export const copyTable = async (view, wrap, format) => {
994
+ const t = currentTable(view, wrap);
995
+ if (!t) throw new Error("Table is no longer available");
996
+ if (format === "markdown") {
997
+ await navigator.clipboard.writeText(view.state.sliceDoc(t.from, t.to));
998
+ return;
999
+ }
1000
+ // Reuse the host's presentation for values (including computed cells), even
1001
+ // when the live cell currently exposes its editable source. Controls never
1002
+ // become spreadsheet contents. Markdown copy above remains byte-exact.
1003
+ const grid = cells(t).slice(firstRow(t)).map((row, r) => row.map((raw, c) => {
1004
+ const element = view.dom.ownerDocument.createElement("div");
1005
+ renderCell(view, element, raw, t, r + firstRow(t), c);
1006
+ element.querySelectorAll('button, [role="button"], [aria-hidden="true"], [hidden], script, style, template').forEach(node => node.remove());
1007
+ return element.textContent ?? "";
1008
+ }));
1009
+ const text = grid.map(row => row.map(value => /[\t\r\n"]/.test(value) ? `"${value.replaceAll('"', '""')}"` : value).join("\t")).join("\n");
1010
+ const html = `<table>${grid.map((row, r) => `<tr>${row.map(value => {
1011
+ const tag = hasHeader(t) && r === 0 ? "th" : "td";
1012
+ return `<${tag}>${escapeHtml(value)}</${tag}>`;
1013
+ }).join("")}</tr>`).join("")}</table>`;
1014
+ if (typeof ClipboardItem !== "undefined" && navigator.clipboard?.write) await navigator.clipboard.write([new ClipboardItem({
1015
+ "text/plain": new Blob([text], {
1016
+ type: "text/plain"
1017
+ }),
1018
+ "text/html": new Blob([html], {
1019
+ type: "text/html"
1020
+ })
1021
+ })]);else await navigator.clipboard.writeText(text);
1022
+ };
1023
+ export const runMenuAction = (view, wrap, action) => {
1024
+ const t = currentTable(view, wrap);
1025
+ const act = active.get(view);
1026
+ if (t && act) runTableAction(view, t.from, act.r, act.c, action);
1027
+ };
1028
+
1029
+ /** No ordinal fallback: only a freshly resolved table/row/column can receive focus. */
1030
+ const focusTableTarget = (view, target) => {
1031
+ if (!target || !view.dom.isConnected || view.state.readOnly) return;
1032
+ const tables = tablesIn(view.state);
1033
+ const index = tables.findIndex(table => table.from === target.tableFrom);
1034
+ const table = tables[index];
1035
+ if (!table || target.row < firstRow(table) || T.gridLine(table, target.row)?.from !== target.rowFrom || table.lines[0]?.cells[target.column]?.from !== target.columnFrom) return;
1036
+ const wrap = findWrap(view, index);
1037
+ if (!wrap) return;
1038
+ active.set(view, {
1039
+ index,
1040
+ r: target.row,
1041
+ c: target.column,
1042
+ composing: false
1043
+ });
1044
+ move(view, wrap, target.row, target.column);
1045
+ const selected = cellAt(wrap, target.row, target.column);
1046
+ if (selected && view.dom.ownerDocument.activeElement !== selected) wrap.dispatchEvent(new CustomEvent("k2b-ste-cell-focus", {
1047
+ bubbles: true,
1048
+ detail: {
1049
+ cell: selected
1050
+ }
1051
+ }));
1052
+ };
1053
+
1054
+ /** Structural actions also work in source mode, without a mounted table widget. */
1055
+ export const runTableAction = (view, tableFrom, r, c, action, /** `false` when the gesture already moved the caret out of the table: the result must not pull it back. */
1056
+ options = {}) => {
1057
+ const tables = tablesIn(view.state);
1058
+ const index = tables.findIndex(table => table.from === tableFrom);
1059
+ const t = tables[index];
1060
+ if (!t || view.state.readOnly || !actionAllowed(t, r, c, action)) return;
1061
+ const host = view.state.facet(tableHost);
1062
+ // F3 A: only an append at the end is safe without the server.
1063
+ if (host?.structure && !(action === "rowBelow" && r === T.rowCount(t) - 1)) {
1064
+ if (!host.structure.available()) {
1065
+ host.onError("structureUnavailable");
1066
+ return;
1067
+ }
1068
+ const row = T.gridLine(t, r);
1069
+ const column = t.lines[0]?.cells[Math.min(c, t.columns - 1)];
1070
+ if (!row || !column) {
1071
+ host.onError("structureFailed");
1072
+ return;
1073
+ }
1074
+ // A new user gesture while waiting owns the caret. Remote redraws do not.
1075
+ const interactionRoot = view.dom;
1076
+ let follow = options.follow !== false,
1077
+ settled = false;
1078
+ const relinquish = () => {
1079
+ follow = false;
1080
+ };
1081
+ const finish = () => {
1082
+ settled = true;
1083
+ interactionRoot.removeEventListener("pointerdown", relinquish, true);
1084
+ interactionRoot.removeEventListener("keydown", relinquish, true);
1085
+ };
1086
+ queueMicrotask(() => {
1087
+ if (settled) return;
1088
+ interactionRoot.addEventListener("pointerdown", relinquish, true);
1089
+ interactionRoot.addEventListener("keydown", relinquish, true);
1090
+ });
1091
+ // Invocation is synchronous so a host can capture CRDT anchors before any await.
1092
+ try {
1093
+ void host.structure.perform(action, {
1094
+ tableFrom: t.from,
1095
+ rowFrom: row.from,
1096
+ columnFrom: column.from,
1097
+ row: r,
1098
+ column: c
1099
+ }).then(resolve => {
1100
+ const focused = view.dom.ownerDocument.activeElement;
1101
+ const ownsFocus = !focused || focused === view.dom.ownerDocument.body || view.dom.contains(focused) || focused.closest(".k2b-gesture-menu__sheet-menu");
1102
+ if (follow && ownsFocus && resolve) focusTableTarget(view, resolve());
1103
+ }).catch(() => host.onError("structureFailed")).finally(finish);
1104
+ } catch {
1105
+ finish();
1106
+ host.onError("structureFailed");
1107
+ }
1108
+ return;
1109
+ }
1110
+ const ops = {
1111
+ completeColumns: () => T.completeColumns(t),
1112
+ rowAbove: () => T.insertRow(t, Math.max(0, r - 1)),
1113
+ rowBelow: () => T.insertRow(t, r),
1114
+ colLeft: () => T.insertColumn(t, c),
1115
+ colRight: () => T.insertColumn(t, c + 1),
1116
+ deleteRow: () => T.deleteRow(t, r),
1117
+ deleteCol: () => T.deleteColumn(t, c),
1118
+ moveRowUp: () => T.moveRow(view.state.doc.toString(), t, r, r - 1),
1119
+ moveRowDown: () => T.moveRow(view.state.doc.toString(), t, r, r + 1),
1120
+ moveColLeft: () => T.moveColumn(t, c, c - 1),
1121
+ moveColRight: () => T.moveColumn(t, c, c + 1),
1122
+ toggleHeader: () => T.toggleHeader(view.state.doc.toString(), t),
1123
+ deleteTable: () => T.deleteTable(t),
1124
+ alignLeft: () => T.setAlign(t, c, "left"),
1125
+ alignCenter: () => T.setAlign(t, c, "center"),
1126
+ alignRight: () => T.setAlign(t, c, "right")
1127
+ };
1128
+ const next = {
1129
+ moveRowUp: [r - 1, c],
1130
+ moveRowDown: [r + 1, c],
1131
+ moveColLeft: [r, c - 1],
1132
+ moveColRight: [r, c + 1],
1133
+ toggleHeader: [hasHeader(t) ? r + 1 : Math.max(0, r - 1), c],
1134
+ rowAbove: [r === 0 ? 1 : r, c],
1135
+ rowBelow: [r + 1, c],
1136
+ colLeft: [r, c],
1137
+ colRight: [r, c + 1],
1138
+ deleteRow: [Math.max(1, r - 1), c],
1139
+ deleteCol: [r, Math.max(0, c - 1)]
1140
+ };
1141
+ if (!dispatchChanges(view, ops[action](), true) || options.follow === false) return;
1142
+ if (action === "deleteTable") {
1143
+ active.delete(view);
1144
+ syncs.delete(view);
1145
+ view.focus();
1146
+ view.dispatch({
1147
+ selection: {
1148
+ anchor: Math.min(t.from, view.state.doc.length)
1149
+ }
1150
+ });
1151
+ return;
1152
+ }
1153
+ const target = next[action] ?? [r, c];
1154
+ const wrap = findWrap(view, index);
1155
+ if (wrap) {
1156
+ active.set(view, {
1157
+ index,
1158
+ r: target[0],
1159
+ c: target[1],
1160
+ composing: false
1161
+ });
1162
+ move(view, wrap, target[0], target[1]);
1163
+ // A modal sheet makes the grid inert. Still update its selected context
1164
+ // from this synchronous local transaction; focus returns when it closes.
1165
+ const selected = cellAt(wrap, target[0], target[1]);
1166
+ if (selected && view.dom.ownerDocument.activeElement !== selected) wrap.dispatchEvent(new CustomEvent("k2b-ste-cell-focus", {
1167
+ bubbles: true,
1168
+ detail: {
1169
+ cell: selected
1170
+ }
1171
+ }));
1172
+ }
1173
+ };
1174
+
1175
+ /** Cells one paste may write; larger blocks are refused visibly instead of running thousands of transactions. */
1176
+ const MAX_PASTE_CELLS = 2000;
1177
+ const pasteGrid = (view, wrap, startR, startC, grid) => {
1178
+ const cellCount = grid.reduce((n, row) => n + row.length, 0);
1179
+ if (view.state.readOnly) return;
1180
+ if (cellCount > (view.state.facet(tableHost)?.maxPasteCells ?? MAX_PASTE_CELLS)) {
1181
+ view.state.facet(tableHost)?.onError("pasteTooLarge");
1182
+ wrap.dispatchEvent(new CustomEvent("k2b-ste-paste-too-large", {
1183
+ bubbles: true,
1184
+ detail: {
1185
+ cells: cellCount,
1186
+ max: MAX_PASTE_CELLS
1187
+ }
1188
+ }));
1189
+ return;
1190
+ }
1191
+ const host = view.state.facet(tableHost);
1192
+ const initial = currentTable(view, wrap);
1193
+ if (!initial) return;
1194
+ if (host?.structure && grid.some(row => startC + row.length > initial.columns)) {
1195
+ host.onError("structureUnavailable");
1196
+ return;
1197
+ }
1198
+ // One transaction gives paste a single undo step and avoids a partial paste.
1199
+ let text = view.state.sliceDoc(initial.from, initial.to);
1200
+ let composed = ChangeSet.empty(text.length);
1201
+ const apply = changes => {
1202
+ const set = ChangeSet.of(changes.map(change => ({
1203
+ ...change,
1204
+ insert: Text.of(change.insert.split("\n"))
1205
+ })), text.length);
1206
+ text = set.apply(Text.of(text.split("\n"))).toString();
1207
+ composed = composed.compose(set);
1208
+ };
1209
+ for (let i = 0; i < grid.length; i++) {
1210
+ let t = T.findTables(text)[0];
1211
+ if (!t) return;
1212
+ if (startR + i >= T.rowCount(t)) apply(T.insertRow(t, T.rowCount(t) - 1));
1213
+ for (let j = 0; j < grid[i].length; j++) {
1214
+ t = T.findTables(text)[0];
1215
+ if (!t) return;
1216
+ if (startC + j >= t.columns) apply(T.insertColumn(t, t.columns));
1217
+ t = T.findTables(text)[0];
1218
+ if (!t) return;
1219
+ apply(T.setCellText(t, startR + i, startC + j, T.escapeCell(grid[i][j] ?? "")));
1220
+ }
1221
+ }
1222
+ const changes = [];
1223
+ composed.iterChanges((from, to, _newFrom, _newTo, insert) => changes.push({
1224
+ from: initial.from + from,
1225
+ to: initial.from + to,
1226
+ insert: insert.toString()
1227
+ }));
1228
+ dispatchChanges(view, changes, true);
1229
+ };
1230
+ const wire = (view, el, wrap) => {
1231
+ const pos = () => ({
1232
+ r: Number(el.dataset.r),
1233
+ c: Number(el.dataset.c)
1234
+ });
1235
+ el.addEventListener("focus", () => {
1236
+ const grid = wrap.querySelector("table");
1237
+ if (grid && !grid.hasAttribute("data-sized")) {
1238
+ const widths = Array.from(grid.rows[0]?.cells ?? []).map(cell => cell.getBoundingClientRect().width);
1239
+ if (widths.every(width => width > 0)) {
1240
+ const columns = document.createElement("colgroup");
1241
+ for (const width of widths) {
1242
+ const column = document.createElement("col");
1243
+ column.style.width = `${width}px`;
1244
+ columns.append(column);
1245
+ }
1246
+ grid.prepend(columns);
1247
+ grid.style.width = `${widths.reduce((sum, width) => sum + width, 0)}px`;
1248
+ grid.style.tableLayout = "fixed";
1249
+ grid.dataset.sized = "1";
1250
+ }
1251
+ }
1252
+ active.set(view, {
1253
+ index: tableIndex(view, wrap),
1254
+ ...pos(),
1255
+ composing: false
1256
+ });
1257
+ wrap.querySelectorAll(".k2b-ste-cell").forEach(cell => cell.tabIndex = cell === el ? 0 : -1);
1258
+ wrap.dataset.activeR = el.dataset.r;
1259
+ wrap.dataset.activeC = el.dataset.c;
1260
+ if (view.state.readOnly) {
1261
+ // A read-only cell selects rendered text; it never enters source editing.
1262
+ // Keyboard entry may have prepared editable text before focusing it.
1263
+ if (el.dataset.editing === "1") {
1264
+ el.dataset.editing = "0";
1265
+ const table = currentTable(view, wrap);
1266
+ if (table) renderCell(view, el, el.dataset.raw ?? "", table, Number(el.dataset.r), Number(el.dataset.c));
1267
+ }
1268
+ syncs.delete(view);
1269
+ missingCells.delete(view);
1270
+ wrap.dispatchEvent(new CustomEvent("k2b-ste-cell-focus", {
1271
+ bubbles: true,
1272
+ detail: {
1273
+ cell: el
1274
+ }
1275
+ }));
1276
+ return;
1277
+ }
1278
+ const t = currentTable(view, wrap);
1279
+ const cell = t && T.gridLine(t, Number(el.dataset.r))?.cells[Number(el.dataset.c)];
1280
+ missingCells.delete(view);
1281
+ if (cell) resync(view, el, cell);else {
1282
+ syncs.delete(view);
1283
+ missingCells.set(view, {
1284
+ el,
1285
+ changed: false
1286
+ });
1287
+ }
1288
+ if (el.dataset.editing !== "1") {
1289
+ el.dataset.editing = "1";
1290
+ const shown = T.displayCell(el.dataset.raw ?? "");
1291
+ // Only cells with formatting change their text; plain cells keep the caret where the tap put it.
1292
+ if (el.textContent !== shown || el.childElementCount > 0) {
1293
+ el.textContent = shown;
1294
+ placeCaret(el, shown.length);
1295
+ }
1296
+ }
1297
+ wrap.dispatchEvent(new CustomEvent("k2b-ste-cell-focus", {
1298
+ bubbles: true,
1299
+ detail: {
1300
+ cell: el
1301
+ }
1302
+ }));
1303
+ queueCellSelection(view, el);
1304
+ });
1305
+ el.addEventListener("blur", () => {
1306
+ if (rebuilding.has(view)) return;
1307
+ const sync = syncs.get(view);
1308
+ if (sync?.el === el) sync.selection = selectionOffsets(el);
1309
+ el.dataset.editing = "0";
1310
+ queueMicrotask(() => {
1311
+ // Chromium blurs a focused cell while CodeMirror removes its widget.
1312
+ // Wait for that update before consulting CodeMirror's DOM mapping. A
1313
+ // removed widget has no mapping in the newly configured source view.
1314
+ if (!wrap.isConnected) return;
1315
+ const focused = el.ownerDocument.activeElement;
1316
+ const table = currentTable(view, wrap);
1317
+ // Read-only focus never changed presentation. Replacing it on blur would
1318
+ // detach the text nodes held by the person's native selection.
1319
+ if (table && !view.state.readOnly && el.dataset.editing !== "1") renderCell(view, el, el.dataset.raw ?? "", table, Number(el.dataset.r), Number(el.dataset.c));
1320
+ if (!wrap.contains(focused)) {
1321
+ delete wrap.dataset.activeR;
1322
+ wrap.dispatchEvent(new CustomEvent("k2b-ste-cell-blur", {
1323
+ bubbles: true
1324
+ }));
1325
+ if (!(focused instanceof HTMLElement && focused.classList.contains("k2b-ste-cell") && view.dom.contains(focused))) view.state.facet(tableHost)?.onCellSelection?.(null);
1326
+ }
1327
+ });
1328
+ });
1329
+ el.addEventListener("compositionstart", () => {
1330
+ if (view.state.readOnly) return;
1331
+ const act = active.get(view);
1332
+ if (act) act.composing = true;
1333
+ });
1334
+ el.addEventListener("compositionend", () => {
1335
+ if (view.state.readOnly) return;
1336
+ const act = active.get(view);
1337
+ if (act) act.composing = false;
1338
+ commitCell(view, el, wrap);
1339
+ });
1340
+ el.addEventListener("beforeinput", event => {
1341
+ if (view.state.readOnly) {
1342
+ event.preventDefault();
1343
+ return;
1344
+ }
1345
+ if (event.inputType === "historyUndo" || event.inputType === "historyRedo") {
1346
+ event.preventDefault();
1347
+ const host = view.state.facet(tableHost);
1348
+ if (event.inputType === "historyUndo") host?.undo();else host?.redo();
1349
+ }
1350
+ });
1351
+ el.addEventListener("input", event => {
1352
+ // During composition the text is provisional; the browser owns the DOM.
1353
+ if (view.state.readOnly || event.isComposing || active.get(view)?.composing) return;
1354
+ commitCell(view, el, wrap);
1355
+ });
1356
+ el.addEventListener("paste", event => {
1357
+ event.preventDefault();
1358
+ if (view.state.readOnly) return;
1359
+ const data = event.clipboardData;
1360
+ if (!data) return;
1361
+ const host = view.state.facet(tableHost);
1362
+ const clipboard = inspectClipboard(data, {
1363
+ maxLength: host?.maxLength ?? Infinity,
1364
+ maxPasteCells: host?.maxPasteCells ?? 2000
1365
+ });
1366
+ if ("error" in clipboard) {
1367
+ host?.onError(clipboard.error);
1368
+ return;
1369
+ }
1370
+ const {
1371
+ html,
1372
+ text,
1373
+ images,
1374
+ files
1375
+ } = clipboard;
1376
+ if (images) host?.onDroppedImages?.(images, files);
1377
+ const grid = (html ? htmlTableGrid(html) : null) ?? T.parseTsv(text);
1378
+ const {
1379
+ r,
1380
+ c
1381
+ } = pos();
1382
+ if (grid?.length) pasteGrid(view, wrap, r, c, grid);else {
1383
+ const inline = html && convertClipboardHtml(html, false)?.markdown || text;
1384
+ document.execCommand("insertText", false, inline.replace(/\s*\r?\n\s*/g, " "));
1385
+ }
1386
+ });
1387
+ el.addEventListener("pointerenter", () => {
1388
+ wrap.querySelectorAll(".k2b-ste-columnMenu").forEach((grip, column) => {
1389
+ grip.toggleAttribute("data-hovered", column === Number(el.dataset.c));
1390
+ });
1391
+ });
1392
+ el.addEventListener("contextmenu", event => {
1393
+ event.preventDefault();
1394
+ pendingContextMenus.get(view)?.abort();
1395
+ el.focus({
1396
+ preventScroll: true
1397
+ });
1398
+ const open = () => {
1399
+ if (el.isConnected) wrap.dispatchEvent(new CustomEvent("k2b-ste-cell-menu", {
1400
+ bubbles: true,
1401
+ detail: {
1402
+ cell: el,
1403
+ x: event.clientX,
1404
+ y: event.clientY
1405
+ }
1406
+ }));
1407
+ };
1408
+ // WebKit fires contextmenu on right-button down. Opening a native popover
1409
+ // then lets that same press dismiss it on release. Chrome fires after up.
1410
+ if (event.button === 2 && (event.buttons & 2) !== 0) {
1411
+ const pending = new AbortController();
1412
+ pendingContextMenus.set(view, pending);
1413
+ const cancel = () => {
1414
+ pending.abort();
1415
+ if (pendingContextMenus.get(view) === pending) pendingContextMenus.delete(view);
1416
+ };
1417
+ el.ownerDocument.addEventListener("mouseup", release => {
1418
+ if (release.button !== 2) return;
1419
+ cancel();
1420
+ open();
1421
+ }, {
1422
+ signal: pending.signal
1423
+ });
1424
+ el.ownerDocument.addEventListener("pointercancel", cancel, {
1425
+ signal: pending.signal,
1426
+ once: true
1427
+ });
1428
+ el.ownerDocument.defaultView?.addEventListener("blur", cancel, {
1429
+ signal: pending.signal,
1430
+ once: true
1431
+ });
1432
+ } else open();
1433
+ });
1434
+ el.addEventListener("keydown", event => {
1435
+ if (event.defaultPrevented) return; // A host completion/snippet handled this key.
1436
+ const host = view.state.facet(tableHost);
1437
+ const t = currentTable(view, wrap);
1438
+ if (!t) return;
1439
+ const {
1440
+ r,
1441
+ c
1442
+ } = pos();
1443
+ const mod = event.metaKey || event.ctrlKey;
1444
+ if (event.isComposing) return;
1445
+ if (event.key === "ContextMenu" || event.key === "F10" && event.shiftKey) {
1446
+ event.preventDefault();
1447
+ wrap.dispatchEvent(new CustomEvent("k2b-ste-cell-menu", {
1448
+ bubbles: true,
1449
+ detail: {
1450
+ cell: el
1451
+ }
1452
+ }));
1453
+ return;
1454
+ }
1455
+ // Preserve native selection/copy/navigation for non-editable rendered text.
1456
+ if (view.state.readOnly) return;
1457
+ if (mod && !event.shiftKey && event.key.toLowerCase() === "a") {
1458
+ // Nested contenteditables do not scope native Select All consistently.
1459
+ // Keep replacement and formatting inside this cell in every host.
1460
+ event.preventDefault();
1461
+ event.stopPropagation();
1462
+ placeSelection(el, 0, el.textContent?.length ?? 0);
1463
+ queueCellSelection(view, el);
1464
+ return;
1465
+ }
1466
+ if (mod && ["b", "i", "k"].includes(event.key.toLowerCase())) {
1467
+ event.preventDefault();
1468
+ const command = event.key.toLowerCase() === "b" ? "bold" : event.key.toLowerCase() === "i" ? "italic" : "link";
1469
+ formatActiveCell(view, command);
1470
+ return;
1471
+ }
1472
+ if (mod && event.key.toLowerCase() === "z") {
1473
+ event.preventDefault();
1474
+ if (event.shiftKey) host?.redo();else host?.undo();
1475
+ return;
1476
+ }
1477
+ if (mod && event.key.toLowerCase() === "y") {
1478
+ event.preventDefault();
1479
+ host?.redo();
1480
+ return;
1481
+ }
1482
+ if (event.altKey && !mod && !event.shiftKey) {
1483
+ const actions = {
1484
+ ArrowUp: "moveRowUp",
1485
+ ArrowDown: "moveRowDown",
1486
+ ArrowLeft: "moveColLeft",
1487
+ ArrowRight: "moveColRight"
1488
+ };
1489
+ const action = actions[event.key];
1490
+ if (action) {
1491
+ event.preventDefault();
1492
+ runTableAction(view, t.from, r, c, action);
1493
+ return;
1494
+ }
1495
+ }
1496
+ if (event.key === "Tab") {
1497
+ if (host?.tabMovesFocus?.()) return;
1498
+ event.preventDefault();
1499
+ const last = r === T.rowCount(t) - 1 && c === t.columns - 1;
1500
+ if (!event.shiftKey && last) {
1501
+ dispatchChanges(view, T.insertRow(t, r), true);
1502
+ move(view, wrap, r + 1, 0);
1503
+ return;
1504
+ }
1505
+ const index = r * t.columns + c + (event.shiftKey ? -1 : 1);
1506
+ if (index < firstRow(t) * t.columns) {
1507
+ leave(view, wrap, "before");
1508
+ return;
1509
+ }
1510
+ move(view, wrap, Math.floor(index / t.columns), index % t.columns);
1511
+ const next = findWrap(view, tableIndex(view, wrap));
1512
+ const cell = next && cellAt(next, Math.floor(index / t.columns), index % t.columns);
1513
+ if (cell) placeSelection(cell, 0, cell.textContent?.length ?? 0);
1514
+ return;
1515
+ }
1516
+ if (event.key === "Enter") {
1517
+ event.preventDefault();
1518
+ if (event.shiftKey) return; // a cell holds one line
1519
+ const last = T.rowCount(t) - 1;
1520
+ if (r < last) move(view, wrap, r + 1, c);else if (r > 1 && T.gridLine(t, last)?.cells.every(cell => !T.displayCell(cell.text).trim())) {
1521
+ // Match the list-like exit: remove the empty trailing row when allowed.
1522
+ // F3 A keeps deletion server-only; offline we can still leave the table.
1523
+ if (!host?.structure || host.structure.available()) runTableAction(view, t.from, r, c, "deleteRow", {
1524
+ follow: false
1525
+ });
1526
+ leave(view, wrap, "after");
1527
+ } else if (dispatchChanges(view, T.insertRow(t, last), true)) move(view, wrap, last + 1, c);
1528
+ return;
1529
+ }
1530
+ if ((event.key === "ArrowLeft" || event.key === "ArrowRight") && !event.shiftKey && !mod && !event.altKey) {
1531
+ const [anchor, head] = selectionOffsets(el);
1532
+ const right = event.key === "ArrowRight";
1533
+ if (anchor !== head || head !== (right ? el.textContent?.length ?? 0 : 0)) return;
1534
+ event.preventDefault();
1535
+ const next = r * t.columns + c + (right ? 1 : -1);
1536
+ if (next >= firstRow(t) * t.columns && next < T.rowCount(t) * t.columns) move(view, wrap, Math.floor(next / t.columns), next % t.columns, right ? "start" : "end");else leave(view, wrap, right ? "after" : "before");
1537
+ return;
1538
+ }
1539
+ if (event.key === "ArrowUp" || event.key === "ArrowDown") {
1540
+ if (event.shiftKey || event.altKey || mod || !atCellVisualEdge(el, event.key === "ArrowDown")) return;
1541
+ const next = r + (event.key === "ArrowUp" ? -1 : 1);
1542
+ event.preventDefault();
1543
+ if (next >= firstRow(t) && next < T.rowCount(t)) move(view, wrap, next, c);else leave(view, wrap, next < firstRow(t) ? "before" : "after");
1544
+ return;
1545
+ }
1546
+ if (event.key === "Escape") {
1547
+ event.preventDefault();
1548
+ leave(view, wrap, "after");
1549
+ return;
1550
+ }
1551
+ // Mod-Home/End leave the grid for the note's start or end; the browser would only move inside the cell's page region.
1552
+ if (mod && !event.shiftKey && !event.altKey && (event.key === "Home" || event.key === "End")) {
1553
+ event.preventDefault();
1554
+ if (event.key === "End" && t.to === view.state.doc.length) return leave(view, wrap, "after");
1555
+ view.focus();
1556
+ view.dispatch({
1557
+ selection: {
1558
+ anchor: event.key === "Home" ? 0 : view.state.doc.length
1559
+ },
1560
+ scrollIntoView: true
1561
+ });
1562
+ }
1563
+ });
1564
+ };
1565
+
1566
+ /** Route formatting to the active cell instead of the hidden outer-editor caret. */
1567
+ export const formatActiveCell = (view, name) => {
1568
+ const act = active.get(view);
1569
+ const wrap = act && findWrap(view, act.index);
1570
+ const el = act && wrap && cellAt(wrap, act.r, act.c);
1571
+ const table = wrap && currentTable(view, wrap);
1572
+ const source = wrap && sources.get(wrap);
1573
+ if (source?.element.contains(view.dom.ownerDocument.activeElement)) {
1574
+ if (!view.state.readOnly && !source.editor.state.readOnly) source.editor.dispatch(inlineFormat(source.editor.state, name));
1575
+ return true;
1576
+ }
1577
+ if (!el || !table || !act || view.state.readOnly) return false;
1578
+ const missing = missingCells.get(view);
1579
+ if (missing?.el === el && missing.changed) {
1580
+ recoverMissingCell(view, el);
1581
+ return true;
1582
+ }
1583
+ let sync = syncs.get(view);
1584
+ let base = view.state;
1585
+ let materialized = ChangeSet.empty(base.doc.length);
1586
+ let cell = T.gridLine(table, act.r)?.cells[act.c];
1587
+ if (!cell) {
1588
+ const changes = T.setCellText(table, act.r, act.c, "");
1589
+ materialized = base.changes(changes);
1590
+ base = base.update({
1591
+ changes: materialized
1592
+ }).state;
1593
+ cell = T.gridLine(tablesIn(base).find(t => t.from === table.from), act.r)?.cells[act.c];
1594
+ sync = undefined;
1595
+ }
1596
+ if (!cell) return true;
1597
+ const offsets = sync?.el === el ? el.dataset.editing === "1" ? selectionOffsets(el) : sync.selection ?? [0, 0] : [0, 0];
1598
+ const map = T.cellDisplayMap(sync?.el === el ? sync.base : cell.text);
1599
+ const position = offset => sync?.el === el ? sync.desc.mapPos(sync.baseFrom + (map.offsets[offset] ?? sync.base.length), 1) : cell.from + (map.offsets[offset] ?? cell.text.length);
1600
+ base = base.update({
1601
+ selection: {
1602
+ anchor: position(offsets[0]),
1603
+ head: position(offsets[1])
1604
+ }
1605
+ }).state;
1606
+ const formatted = base.update(inlineFormat(base, name));
1607
+ const changes = [];
1608
+ materialized.compose(formatted.changes).iterChanges((from, to, _a, _b, insert) => changes.push({
1609
+ from,
1610
+ to,
1611
+ insert: insert.toString()
1612
+ }));
1613
+ const selection = formatted.state.selection.main;
1614
+ missingCells.delete(view);
1615
+ if (sync) sync.committing = true;
1616
+ const accepted = dispatchChanges(view, changes, true, selection);
1617
+ if (sync) sync.committing = false;
1618
+ if (!accepted) return true;
1619
+ const hit = cellAtPos(view.state, selection.head);
1620
+ const currentWrap = hit && findWrap(view, tablesIn(view.state).findIndex(t => t.from === hit.table.from));
1621
+ const target = hit && currentWrap && cellAt(currentWrap, hit.r, hit.c);
1622
+ if (target && hit) {
1623
+ focusCell(target, 0);
1624
+ placeSelection(target, T.displayCell(view.state.sliceDoc(hit.cell.from, selection.anchor)).length, T.displayCell(view.state.sliceDoc(hit.cell.from, selection.head)).length);
1625
+ resync(view, target, hit.cell);
1626
+ }
1627
+ return true;
1628
+ };
1629
+ const syncCellSelection = (view, el = syncs.get(view)?.el) => {
1630
+ const sync = syncs.get(view);
1631
+ if (!el?.isConnected || document.activeElement !== el || sync?.el !== el || active.get(view)?.composing) return;
1632
+ const [a, h] = selectionOffsets(el);
1633
+ const map = T.cellDisplayMap(sync.base);
1634
+ const position = offset => sync.desc.mapPos(sync.baseFrom + (map.offsets[offset] ?? sync.base.length), 1);
1635
+ const anchor = position(a),
1636
+ head = position(h);
1637
+ if (anchor !== view.state.selection.main.anchor || head !== view.state.selection.main.head) view.dispatch({
1638
+ selection: {
1639
+ anchor,
1640
+ head
1641
+ }
1642
+ });
1643
+ view.state.facet(tableHost)?.onCellSelection?.({
1644
+ anchor,
1645
+ head
1646
+ });
1647
+ };
1648
+ const pendingSelection = new WeakSet();
1649
+ const queueCellSelection = (view, el) => {
1650
+ if (pendingSelection.has(view)) return;
1651
+ pendingSelection.add(view);
1652
+ queueMicrotask(() => {
1653
+ pendingSelection.delete(view);
1654
+ syncCellSelection(view, el);
1655
+ });
1656
+ };
1657
+ const atCellVisualEdge = (el, down) => {
1658
+ const selection = el.ownerDocument.getSelection();
1659
+ if (!selection?.rangeCount || !selection.isCollapsed) return false;
1660
+ const range = selection.getRangeAt(0).cloneRange();
1661
+ range.collapse(false);
1662
+ let caret = range.getBoundingClientRect();
1663
+ if (!caret.height) {
1664
+ // A collapsed range at a container boundary has no rectangle in WebKit.
1665
+ // Measure the neighbouring character without changing the user's selection.
1666
+ const at = selectionOffsets(el)[1];
1667
+ const walker = el.ownerDocument.createTreeWalker(el, NodeFilter.SHOW_TEXT);
1668
+ let remaining = at;
1669
+ for (let node = walker.nextNode(); node; node = walker.nextNode()) {
1670
+ const length = node.textContent?.length ?? 0;
1671
+ if (remaining <= length && length) {
1672
+ const start = Math.min(remaining, length - 1);
1673
+ range.setStart(node, start);
1674
+ range.setEnd(node, start + 1);
1675
+ caret = range.getBoundingClientRect();
1676
+ break;
1677
+ }
1678
+ remaining -= length;
1679
+ }
1680
+ }
1681
+ if (!caret.height) return !(el.textContent ?? "").length;
1682
+ const box = el.getBoundingClientRect(),
1683
+ style = getComputedStyle(el);
1684
+ const lineHeight = Number.parseFloat(style.lineHeight) || caret.height;
1685
+ return down ? box.bottom - caret.bottom <= Number.parseFloat(style.paddingBottom) + lineHeight / 2 : caret.top - box.top <= Number.parseFloat(style.paddingTop) + lineHeight / 2;
1686
+ };
1687
+ const enterTable = (view, down) => {
1688
+ const selection = view.state.selection.main;
1689
+ if (!selection.empty) return false;
1690
+ const line = view.state.doc.lineAt(selection.head);
1691
+ if (view.state.doc.lineAt(view.moveVertically(selection, down).head).number === line.number) return false;
1692
+ const tables = tablesIn(view.state);
1693
+ const index = tables.findIndex(t => down ? t.from > line.to && view.state.doc.lineAt(t.from).number === line.number + 1 : t.to < line.from && view.state.doc.lineAt(t.to).number === line.number - 1);
1694
+ const table = tables[index];
1695
+ const wrap = findWrap(view, index);
1696
+ const el = table && wrap && cellAt(wrap, down ? firstRow(table) : T.rowCount(table) - 1, 0);
1697
+ if (!el) return false;
1698
+ focusCell(el, 0);
1699
+ return true;
1700
+ };
1701
+ const leave = (view, wrap, where) => {
1702
+ const t = currentTable(view, wrap);
1703
+ if (!t) return;
1704
+ // An EOF table needs an actual paragraph to receive the caret.
1705
+ if (where === "after" && t.to === view.state.doc.length && !view.state.readOnly) dispatchChanges(view, [{
1706
+ from: t.to,
1707
+ to: t.to,
1708
+ insert: "\n\n"
1709
+ }]);
1710
+ const anchor = where === "before" ? Math.max(0, t.from - 1) : Math.min(view.state.doc.length, t.to + 1);
1711
+ view.focus();
1712
+ view.dispatch({
1713
+ selection: {
1714
+ anchor
1715
+ },
1716
+ scrollIntoView: true
1717
+ });
1718
+ };
1719
+
1720
+ /** First `<table>` of pasted HTML as a grid (Excel, Sheets, Word, web pages). */
1721
+ export const htmlTableGrid = html => {
1722
+ const doc = parseClipboardHtml(html);
1723
+ const table = doc.querySelector("table");
1724
+ if (!table) return null;
1725
+ const rows = [...table.querySelectorAll("tr")].map(tr => [...tr.querySelectorAll("th,td")].map(cell => (cell.textContent ?? "").replace(/\s+/g, " ").trim()));
1726
+ return rows.filter(row => row.length).length ? rows.filter(row => row.length) : null;
1727
+ };
1728
+ const decorate = state => Decoration.set(tablesIn(state).map(t => Decoration.replace({
1729
+ widget: new GridWidget(t, state.readOnly),
1730
+ block: true
1731
+ }).range(t.from, t.to)), true);
1732
+ export const tableGrid = () => [clipboardLoading(view => view.state.facet(tableHost), syncCellSelection), Prec.highest(keymap.of([{
1733
+ key: "ArrowDown",
1734
+ run: view => enterTable(view, true)
1735
+ }, {
1736
+ key: "ArrowUp",
1737
+ run: view => enterTable(view, false)
1738
+ }])), ViewPlugin.define(view => {
1739
+ const selection = () => {
1740
+ const el = syncs.get(view)?.el;
1741
+ if (el) queueCellSelection(view, el);
1742
+ };
1743
+ view.dom.ownerDocument.addEventListener("selectionchange", selection);
1744
+ return {
1745
+ destroy() {
1746
+ pendingContextMenus.get(view)?.abort();
1747
+ pendingContextMenus.delete(view);
1748
+ view.dom.ownerDocument.removeEventListener("selectionchange", selection);
1749
+ syncs.delete(view);
1750
+ missingCells.delete(view);
1751
+ active.delete(view);
1752
+ view.state.facet(tableHost)?.onCellSelection?.(null);
1753
+ }
1754
+ };
1755
+ }), StateField.define({
1756
+ create: decorate,
1757
+ update: (value, tr) => tr.docChanged || tr.effects.some(e => e.is(refreshTables)) || tr.startState.readOnly !== tr.state.readOnly || syntaxTree(tr.startState) !== syntaxTree(tr.state) ? decorate(tr.state) : value,
1758
+ provide: field => [EditorView.decorations.from(field), EditorView.atomicRanges.of(view => view.state.field(field))]
1759
+ }), followChanges];
1760
+ //# sourceMappingURL=table.js.map