@k2b/ui 0.34.0 → 0.36.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.
- package/dist/browser/actions/Dropdown.js +1 -0
- package/dist/browser/actions/Dropdown.js.map +1 -1
- package/dist/browser/content/MapView.js +128 -13
- package/dist/browser/content/MapView.js.map +1 -1
- package/dist/browser/content/MapViewFrame.js +1 -1
- package/dist/browser/content/MapViewFrame.js.map +1 -1
- package/dist/browser/content/MatchText.js +61 -0
- package/dist/browser/content/MatchText.js.map +1 -0
- package/dist/browser/content/index.js +2 -0
- package/dist/browser/content/index.js.map +1 -1
- package/dist/browser/content/map-view-glyphs.js +23 -0
- package/dist/browser/content/map-view-glyphs.js.map +1 -0
- package/dist/browser/content/map-view-live.js +45 -22
- package/dist/browser/content/map-view-live.js.map +1 -1
- package/dist/browser/content/map-view-snapshot.js +191 -0
- package/dist/browser/content/map-view-snapshot.js.map +1 -0
- package/dist/browser/content/map-view-types.js.map +1 -1
- package/dist/browser/inputs/Combobox.js +139 -62
- package/dist/browser/inputs/Combobox.js.map +1 -1
- package/dist/browser/inputs/TextInput.js +1 -1
- package/dist/browser/inputs/TextInput.js.map +1 -1
- package/dist/browser/layout/AppWorkspace.js +73 -70
- package/dist/browser/layout/AppWorkspace.js.map +1 -1
- package/dist/browser/layout/ScrollArea.js +4 -1
- package/dist/browser/layout/ScrollArea.js.map +1 -1
- package/dist/browser/text-editor/TextEditor.js +1358 -0
- package/dist/browser/text-editor/TextEditor.js.map +1 -0
- package/dist/browser/text-editor/clipboard-html.js +152 -0
- package/dist/browser/text-editor/clipboard-html.js.map +1 -0
- package/dist/browser/text-editor/clipboard-loading.js +68 -0
- package/dist/browser/text-editor/clipboard-loading.js.map +1 -0
- package/dist/browser/text-editor/clipboard.js +51 -0
- package/dist/browser/text-editor/clipboard.js.map +1 -0
- package/dist/browser/text-editor/engine.js +125 -0
- package/dist/browser/text-editor/engine.js.map +1 -0
- package/dist/browser/text-editor/format.js +218 -0
- package/dist/browser/text-editor/format.js.map +1 -0
- package/dist/browser/text-editor/formatted.js +164 -0
- package/dist/browser/text-editor/formatted.js.map +1 -0
- package/dist/browser/text-editor/language.js +19 -0
- package/dist/browser/text-editor/language.js.map +1 -0
- package/dist/browser/text-editor/markdown-table.js +563 -0
- package/dist/browser/text-editor/markdown-table.js.map +1 -0
- package/dist/browser/text-editor/messages.js +233 -0
- package/dist/browser/text-editor/messages.js.map +1 -0
- package/dist/browser/text-editor/paste.js +115 -0
- package/dist/browser/text-editor/paste.js.map +1 -0
- package/dist/browser/text-editor/table.js +1760 -0
- package/dist/browser/text-editor/table.js.map +1 -0
- package/dist/browser/text-editor.js +5 -0
- package/dist/browser/text-editor.js.map +1 -0
- package/dist/ssr/actions/Dropdown.js +1 -0
- package/dist/ssr/actions/Dropdown.js.map +1 -1
- package/dist/ssr/content/MapView.js +128 -13
- package/dist/ssr/content/MapView.js.map +1 -1
- package/dist/ssr/content/MapViewFrame.js +1 -1
- package/dist/ssr/content/MapViewFrame.js.map +1 -1
- package/dist/ssr/content/MatchText.js +56 -0
- package/dist/ssr/content/MatchText.js.map +1 -0
- package/dist/ssr/content/index.js +2 -0
- package/dist/ssr/content/index.js.map +1 -1
- package/dist/ssr/content/map-view-glyphs.js +23 -0
- package/dist/ssr/content/map-view-glyphs.js.map +1 -0
- package/dist/ssr/content/map-view-snapshot.js +191 -0
- package/dist/ssr/content/map-view-snapshot.js.map +1 -0
- package/dist/ssr/content/map-view-types.js.map +1 -1
- package/dist/ssr/inputs/Combobox.js +66 -20
- package/dist/ssr/inputs/Combobox.js.map +1 -1
- package/dist/ssr/inputs/TextInput.js.map +1 -1
- package/dist/ssr/layout/AppWorkspace.js +1 -1
- package/dist/ssr/layout/AppWorkspace.js.map +1 -1
- package/dist/ssr/layout/ScrollArea.js +4 -1
- package/dist/ssr/layout/ScrollArea.js.map +1 -1
- package/dist/ssr/text-editor/TextEditor.js +1155 -0
- package/dist/ssr/text-editor/TextEditor.js.map +1 -0
- package/dist/ssr/text-editor/clipboard-html.js +152 -0
- package/dist/ssr/text-editor/clipboard-html.js.map +1 -0
- package/dist/ssr/text-editor/clipboard-loading.js +68 -0
- package/dist/ssr/text-editor/clipboard-loading.js.map +1 -0
- package/dist/ssr/text-editor/clipboard.js +51 -0
- package/dist/ssr/text-editor/clipboard.js.map +1 -0
- package/dist/ssr/text-editor/engine.js +125 -0
- package/dist/ssr/text-editor/engine.js.map +1 -0
- package/dist/ssr/text-editor/format.js +218 -0
- package/dist/ssr/text-editor/format.js.map +1 -0
- package/dist/ssr/text-editor/formatted.js +164 -0
- package/dist/ssr/text-editor/formatted.js.map +1 -0
- package/dist/ssr/text-editor/language.js +19 -0
- package/dist/ssr/text-editor/language.js.map +1 -0
- package/dist/ssr/text-editor/markdown-table.js +563 -0
- package/dist/ssr/text-editor/markdown-table.js.map +1 -0
- package/dist/ssr/text-editor/messages.js +233 -0
- package/dist/ssr/text-editor/messages.js.map +1 -0
- package/dist/ssr/text-editor/paste.js +115 -0
- package/dist/ssr/text-editor/paste.js.map +1 -0
- package/dist/ssr/text-editor/table.js +1760 -0
- package/dist/ssr/text-editor/table.js.map +1 -0
- package/dist/ssr/text-editor.js +5 -0
- package/dist/ssr/text-editor.js.map +1 -0
- package/dist/styles.css +1 -1
- package/dist/types/actions/Dropdown.d.ts +2 -0
- package/dist/types/content/MapViewFrame.d.ts +1 -1
- package/dist/types/content/MatchText.d.ts +21 -0
- package/dist/types/content/index.d.ts +3 -1
- package/dist/types/content/map-view-glyphs.d.ts +5 -0
- package/dist/types/content/map-view-live.d.ts +1 -1
- package/dist/types/content/map-view-snapshot.d.ts +13 -0
- package/dist/types/content/map-view-types.d.ts +15 -0
- package/dist/types/inputs/Combobox.d.ts +12 -0
- package/dist/types/inputs/TextInput.d.ts +2 -1
- package/dist/types/layout/AppWorkspace.d.ts +4 -1
- package/dist/types/layout/ScrollArea.d.ts +2 -0
- package/dist/types/text-editor/TextEditor.d.ts +69 -0
- package/dist/types/text-editor/clipboard-html.d.ts +9 -0
- package/dist/types/text-editor/clipboard-loading.d.ts +8 -0
- package/dist/types/text-editor/clipboard.d.ts +20 -0
- package/dist/types/text-editor/engine.d.ts +34 -0
- package/dist/types/text-editor/format.d.ts +34 -0
- package/dist/types/text-editor/formatted.d.ts +1 -0
- package/dist/types/text-editor/language.d.ts +8 -0
- package/dist/types/text-editor/markdown-table.d.ts +105 -0
- package/dist/types/text-editor/messages.d.ts +113 -0
- package/dist/types/text-editor/paste.d.ts +6 -0
- package/dist/types/text-editor/table.d.ts +81 -0
- package/dist/types/text-editor.d.ts +4 -0
- package/package.json +24 -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
|