@openpresentation/opf-editor 0.10.6 → 0.11.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. package/README.md +333 -8
  2. package/dist/annotations.d.ts +71 -0
  3. package/dist/annotations.js +281 -0
  4. package/dist/assets.d.ts +67 -0
  5. package/dist/assets.js +176 -0
  6. package/dist/background-options.d.ts +48 -0
  7. package/dist/background-options.js +134 -0
  8. package/dist/block-convert.d.ts +64 -0
  9. package/dist/block-convert.js +142 -0
  10. package/dist/canvas.d.ts +16 -0
  11. package/dist/canvas.js +82 -21
  12. package/dist/chart-data.d.ts +32 -0
  13. package/dist/chart-data.js +101 -0
  14. package/dist/chart-options-panel.d.ts +16 -0
  15. package/dist/chart-options-panel.js +127 -0
  16. package/dist/chart-options.d.ts +49 -0
  17. package/dist/chart-options.js +157 -0
  18. package/dist/content-actions.d.ts +91 -0
  19. package/dist/content-actions.js +207 -0
  20. package/dist/content-controls.js +326 -0
  21. package/dist/data-grid.d.ts +37 -0
  22. package/dist/data-grid.js +1035 -0
  23. package/dist/design-controls.d.ts +43 -0
  24. package/dist/design-controls.js +1077 -0
  25. package/dist/design-options.d.ts +108 -0
  26. package/dist/design-options.js +412 -0
  27. package/dist/edit-helpers.js +52 -0
  28. package/dist/export.d.ts +77 -0
  29. package/dist/export.js +216 -0
  30. package/dist/find-panel.d.ts +44 -0
  31. package/dist/find-panel.js +431 -0
  32. package/dist/find-replace.d.ts +100 -0
  33. package/dist/find-replace.js +374 -0
  34. package/dist/grid-model.d.ts +135 -0
  35. package/dist/grid-model.js +836 -0
  36. package/dist/grid-text.d.ts +33 -0
  37. package/dist/grid-text.js +251 -0
  38. package/dist/image-crop.d.ts +59 -0
  39. package/dist/image-crop.js +336 -0
  40. package/dist/image-cropper.d.ts +29 -0
  41. package/dist/image-cropper.js +519 -0
  42. package/dist/index.d.ts +11 -1
  43. package/dist/index.js +104 -171
  44. package/dist/numbering-panel.d.ts +21 -0
  45. package/dist/numbering-panel.js +200 -0
  46. package/dist/numbering.d.ts +62 -0
  47. package/dist/numbering.js +223 -0
  48. package/dist/outline-view.d.ts +17 -0
  49. package/dist/outline-view.js +278 -0
  50. package/dist/outline.d.ts +56 -0
  51. package/dist/outline.js +271 -0
  52. package/dist/persistence-ui.d.ts +24 -0
  53. package/dist/persistence-ui.js +81 -0
  54. package/dist/persistence.d.ts +105 -0
  55. package/dist/persistence.js +429 -0
  56. package/dist/review-panel.d.ts +44 -0
  57. package/dist/review-panel.js +359 -0
  58. package/dist/review.d.ts +75 -0
  59. package/dist/review.js +170 -0
  60. package/dist/slide-manager.d.ts +44 -0
  61. package/dist/slide-manager.js +695 -0
  62. package/dist/slides.d.ts +96 -0
  63. package/dist/slides.js +433 -0
  64. package/dist/switches.d.ts +26 -0
  65. package/dist/switches.js +127 -43
  66. package/dist/table-options.d.ts +80 -0
  67. package/dist/table-options.js +419 -0
  68. package/dist/table-structure.d.ts +30 -0
  69. package/dist/table-structure.js +92 -0
  70. package/dist/template-panel.d.ts +31 -0
  71. package/dist/template-panel.js +377 -0
  72. package/dist/templates.d.ts +126 -0
  73. package/dist/templates.js +331 -0
  74. package/dist/zip.d.ts +4 -0
  75. package/dist/zip.js +71 -0
  76. package/package.json +150 -10
@@ -0,0 +1,1035 @@
1
+ // The data grid (RR-24): a spreadsheet-like DOM grid bound to a chart's inline data or to a table. It reads the document through
2
+ // the session, and every edit (a cell, a paste, a row or column operation) commits one validated patch, so Undo and Redo move by
3
+ // one step and the host's preview redraws from the same session events. Framework-free: `createDataGrid(container, options)`
4
+ // builds the DOM, `refresh()` re-reads the selection, `destroy()` removes everything.
5
+ //
6
+ // Semantics: a `role="grid"` table whose cells are `gridcell`s with `aria-rowindex`/`aria-colindex`, merged table cells
7
+ // drawn with real spans (`aria-colspan`, `aria-rowspan`), selected cells marked `aria-selected`, and one cell in the tab order.
8
+ // Keyboard: arrows move (Shift extends), Home/End, Ctrl+Home/End, PageUp/PageDown, Enter or F2 edits, typing replaces the text,
9
+ // Enter/Shift+Enter and Tab/Shift+Tab commit and move, Escape cancels, Delete clears, Ctrl+C/X/V copy, cut and paste TSV, Ctrl+A selects
10
+ // all, Ctrl+Z / Ctrl+Y undo and redo. Outside an edit Tab leaves the grid. Row and column operations are in the toolbar.
11
+ import {
12
+ columnLabel,
13
+ deleteGridColumns,
14
+ deleteGridRows,
15
+ describeAddress,
16
+ describeDataGrid,
17
+ gridCellIssues,
18
+ insertGridColumns,
19
+ insertGridRows,
20
+ moveGridColumns,
21
+ moveGridRows,
22
+ pasteGridText,
23
+ resolveDataGridTarget,
24
+ setGridCells,
25
+ setGridHeader,
26
+ sortGridRows,
27
+ transposeGridData,
28
+ } from "./grid-model.js";
29
+ import { NUMBER_FORMATS, parseDelimited, resolveNumberFormat, toDelimited } from "./grid-text.js";
30
+
31
+ export * from "./grid-model.js";
32
+
33
+ let instances = 0;
34
+ const STYLE_ID = "opf-data-grid-style";
35
+ const CSS = `
36
+ .opf-grid{--g-border:#d9d6e2;--g-ruler:#f3f2f7;--g-text:#2b2833;--g-muted:#6a6573;--g-select:#e8e4fb;--g-focus:#5b4fc4;--g-error:#a02525;--g-warn:#8a5a00;--g-gap:#8b8696;font:12px/1.45 system-ui,sans-serif;color:var(--g-text)}
37
+ .opf-grid[hidden],.opf-grid [hidden]{display:none!important}
38
+ .opf-grid-title{display:flex;gap:8px;align-items:baseline;margin:0 0 6px}.opf-grid-title strong{font-size:12px}.opf-grid-path{font:10px ui-monospace,monospace;color:var(--g-muted);overflow-wrap:anywhere}
39
+ .opf-grid-toolbar{display:flex;flex-wrap:wrap;gap:4px;align-items:center;margin:0 0 6px}
40
+ .opf-grid-toolbar button,.opf-grid-toolbar select{font:inherit;min-height:28px;border:1px solid var(--g-border);border-radius:4px;background:#fff;color:inherit;padding:2px 8px}
41
+ .opf-grid-toolbar button{cursor:pointer}.opf-grid-toolbar button:hover{background:#f1effa}
42
+ .opf-grid-toolbar [aria-disabled=true]{opacity:.45;cursor:default}.opf-grid-toolbar [aria-disabled=true]:hover{background:#fff}
43
+ .opf-grid-toolbar .opf-grid-sep{width:1px;align-self:stretch;background:var(--g-border);margin:0 2px}
44
+ .opf-grid-toolbar label{display:inline-flex;gap:4px;align-items:center;font-size:11px;color:var(--g-muted)}
45
+ .opf-grid-toolbar label.opf-grid-check{color:inherit}
46
+ .opf-grid button:focus-visible,.opf-grid select:focus-visible,.opf-grid textarea:focus-visible,.opf-grid summary:focus-visible,.opf-grid input:focus-visible{outline:2px solid var(--g-focus);outline-offset:1px}
47
+ .opf-grid-scroll{overflow:auto;max-height:var(--opf-grid-max-height,320px);border:1px solid var(--g-border);border-radius:4px;background:#fff}
48
+ .opf-grid table{border-collapse:separate;border-spacing:0;min-width:100%;table-layout:auto}
49
+ .opf-grid th,.opf-grid td{border-right:1px solid var(--g-border);border-bottom:1px solid var(--g-border);padding:3px 6px;vertical-align:top;font-weight:400;text-align:left;min-width:64px;max-width:240px;white-space:pre-wrap;overflow-wrap:anywhere}
50
+ .opf-grid thead th{position:sticky;top:0;z-index:2;background:var(--g-ruler);font-size:11px;color:var(--g-muted);font-weight:600;text-align:center}
51
+ .opf-grid tbody th{position:sticky;left:0;z-index:1;background:var(--g-ruler);font-size:10px;color:var(--g-muted);min-width:44px;max-width:64px;text-align:center;cursor:pointer}
52
+ .opf-grid thead th:first-child{left:0;z-index:3;cursor:pointer}
53
+ .opf-grid-role{display:block;font-weight:400;font-size:9px}
54
+ .opf-grid td{cursor:cell;position:relative;background:#fff;user-select:none;-webkit-user-select:none}.opf-grid td textarea{user-select:text;-webkit-user-select:text}
55
+ .opf-grid td.opf-grid-header{font-weight:600;background:#faf9fd}
56
+ .opf-grid td.opf-grid-number{text-align:right;font-variant-numeric:tabular-nums}
57
+ .opf-grid td.opf-grid-gap::before{content:"gap";color:var(--g-gap);font-style:italic;font-size:10px}
58
+ .opf-grid td[aria-selected=true]{background:var(--g-select)}
59
+ .opf-grid td.opf-grid-active{outline:2px solid var(--g-focus);outline-offset:-2px}
60
+ .opf-grid td[aria-invalid=true]{box-shadow:inset 0 0 0 2px var(--g-error)}
61
+ .opf-grid td.opf-grid-warn::after{content:"!";position:absolute;top:1px;right:3px;color:var(--g-warn);font-weight:700;font-size:10px}
62
+ .opf-grid td.opf-grid-merged{background-image:linear-gradient(135deg,transparent 92%,#cfc9e8 92%)}
63
+ .opf-grid td.opf-grid-editing{padding:0}
64
+ .opf-grid td textarea{display:block;width:100%;min-height:100%;box-sizing:border-box;margin:0;border:0;padding:3px 6px;font:inherit;resize:none;background:#fff;overflow:hidden}
65
+ .opf-grid-notes{margin:6px 0 0;padding-left:18px;font-size:11px;color:var(--g-warn)}.opf-grid-notes:empty{display:none}
66
+ .opf-grid-status{margin:6px 0 0;font-size:11px;color:#3d5e47;min-height:1.2em}.opf-grid-error{margin:6px 0 0;font-size:11px;color:var(--g-error)}.opf-grid-error:empty{display:none}
67
+ .opf-grid details{margin:6px 0 0;font-size:11px}.opf-grid details summary{cursor:pointer}.opf-grid details textarea{display:block;width:100%;box-sizing:border-box;min-height:64px;margin:4px 0;font:11px ui-monospace,monospace}
68
+ .opf-grid-help{margin:6px 0 0;font-size:10px;color:var(--g-muted)}
69
+ @media (forced-colors:active){.opf-grid td.opf-grid-active{outline:3px solid Highlight}.opf-grid td[aria-selected=true]{outline:1px solid Highlight}.opf-grid td[aria-invalid=true]{outline:2px solid LinkText}}
70
+ `;
71
+
72
+ // A printable character starts an edit (a plain space does not: it is how a person scrolls or presses a focused control).
73
+ const KEYS_PRINTABLE = (event) => event.key.length === 1 && event.key !== " " && !event.ctrlKey && !event.metaKey && !event.altKey;
74
+ const messageOf = (error) => error?.issues?.[0]?.message ?? error?.message ?? String(error);
75
+ const plural = (count, word) => `${count} ${word}${count === 1 ? "" : "s"}`;
76
+
77
+ /**
78
+ * Mount the grid into `container`. Options: `editor` (a session), `path` (a chart or table path to bind to) or `getSelectedPath()`
79
+ * (the grid follows the host's selection into a chart or table; call `refresh()` when it changes), `numberFormat` ("auto", ".", ","),
80
+ * `locale` (for "auto"), `maxHeight` (CSS length of the scrolling area), `onChange(change)`, `onStatus(message, { error })`
81
+ * and `onTargetChange(target | undefined)`. Returns `{ element, refresh, focus, destroy, target, numberFormat, setNumberFormat }`.
82
+ */
83
+ export function createDataGrid(container, options = {}) {
84
+ const editor = options.editor;
85
+ if (!editor || typeof editor.applyPatch !== "function" || typeof editor.subscribe !== "function") throw new TypeError("createDataGrid needs an editor session.");
86
+ if (!container || typeof container.appendChild !== "function") throw new TypeError("createDataGrid needs a container element.");
87
+ const doc = container.ownerDocument;
88
+ const uid = `opf-grid-${++instances}`;
89
+ if (!doc.getElementById(STYLE_ID)) {
90
+ const style = doc.createElement("style");
91
+ style.id = STYLE_ID;
92
+ style.textContent = CSS;
93
+ doc.head.append(style);
94
+ }
95
+
96
+ const h = (tag, attrs = {}, ...children) => {
97
+ const node = doc.createElement(tag);
98
+ for (const [key, value] of Object.entries(attrs)) {
99
+ if (value === undefined || value === false || value === null) continue;
100
+ if (key === "class") node.className = value;
101
+ else if (key.startsWith("on")) node.addEventListener(key.slice(2), value);
102
+ else if (value === true) node.setAttribute(key, "");
103
+ else node.setAttribute(key, String(value));
104
+ }
105
+ for (const child of children.flat()) if (child !== undefined && child !== null && child !== false) node.append(child);
106
+ return node;
107
+ };
108
+
109
+ // --- state ---------------------------------------------------------------------------------
110
+ let destroyed = false;
111
+ let target; // { kind, path, editable, reason }
112
+ let grid; // describeDataGrid result
113
+ let signature = "";
114
+ let numberFormat = options.numberFormat ?? "auto";
115
+ const selection = { anchor: { u: 0, c: 0 }, focus: { u: 0, c: 0 } };
116
+ let editing = null; // { u, c, text, caret }
117
+ let dragging = false;
118
+ let invalid = new Map();
119
+ let hadFocus = false;
120
+ // Replacing the table removes a focused editor, which fires blur: that blur is not the person leaving the cell.
121
+ let rebuilding = false;
122
+
123
+ const decimal = () => resolveNumberFormat(numberFormat, options.locale ?? doc.documentElement?.lang ?? doc.defaultView?.navigator?.language).decimal;
124
+ const opts = () => ({ decimal: decimal() });
125
+ const getPath = () => options.path ?? options.getSelectedPath?.();
126
+
127
+ // --- skeleton --------------------------------------------------------------------------------
128
+ const root = h("div", { class: "opf-grid", "data-opf-component": "data-grid", hidden: true });
129
+ const titleText = h("strong", {});
130
+ const pathText = h("span", { class: "opf-grid-path" });
131
+ const toolbar = h("div", { class: "opf-grid-toolbar", role: "toolbar", "aria-label": "Data grid actions", "aria-orientation": "horizontal" });
132
+ const unavailable = h("p", { class: "opf-grid-help", role: "note" });
133
+ const tbody = h("tbody", {});
134
+ const thead = h("thead", {});
135
+ const table = h("table", { role: "grid", "aria-multiselectable": "true", "aria-readonly": "false" }, thead, tbody);
136
+ const scroll = h("div", { class: "opf-grid-scroll" }, table);
137
+ const notes = h("ul", { class: "opf-grid-notes", "aria-label": "Data notes" });
138
+ const statusEl = h("p", { class: "opf-grid-status", role: "status", "aria-live": "polite" });
139
+ const errorEl = h("p", { class: "opf-grid-error", role: "alert" });
140
+ const help = h("p", { class: "opf-grid-help", id: `${uid}-help` }, "Arrow keys move, Enter or F2 edits, Escape cancels, Delete clears. Paste TSV or CSV from a spreadsheet with Ctrl+V; Ctrl+C copies the selection as TSV.");
141
+ const pasteArea = h("textarea", { id: `${uid}-paste`, rows: 4, spellcheck: "false", placeholder: "Paste cells copied from Excel or Sheets, or CSV text" });
142
+ const pasteButton = h("button", { type: "button", "aria-disabled": "false" }, "Paste at selected cell");
143
+ const pasteBox = h("details", { class: "opf-grid-paste" }, h("summary", {}, "Paste text"), h("label", { for: `${uid}-paste` }, "Text from a spreadsheet or CSV file"), pasteArea, pasteButton);
144
+ table.setAttribute("aria-describedby", help.id);
145
+ root.append(h("div", { class: "opf-grid-title" }, titleText, pathText), unavailable, toolbar, scroll, notes, help, pasteBox, statusEl, errorEl);
146
+ container.append(root);
147
+ scroll.style.setProperty("--opf-grid-max-height", options.maxHeight ?? "320px");
148
+
149
+ const say = (message) => {
150
+ errorEl.textContent = "";
151
+ statusEl.textContent = message;
152
+ options.onStatus?.(message, { error: false });
153
+ };
154
+ const complain = (message) => {
155
+ statusEl.textContent = "";
156
+ errorEl.textContent = message;
157
+ options.onStatus?.(message, { error: true });
158
+ };
159
+
160
+ // --- toolbar -----------------------------------------------------------------------------------
161
+ const buttons = new Map();
162
+ const addButton = (name, label, handler, title) => {
163
+ const button = h("button", { type: "button", "data-action": name, title: title ?? label, "aria-disabled": "false" }, label);
164
+ button.addEventListener("click", () => {
165
+ if (button.getAttribute("aria-disabled") === "true") return;
166
+ handler();
167
+ });
168
+ buttons.set(name, button);
169
+ return button;
170
+ };
171
+ const sep = () => h("span", { class: "opf-grid-sep", role: "separator" });
172
+ const formatSelect = h("select", { "aria-label": "Number format", "data-role": "number-format" });
173
+ const headerBox = h("input", { type: "checkbox", id: `${uid}-header` });
174
+ const headerLabel = h("label", { class: "opf-grid-check", for: `${uid}-header` }, headerBox, "Header row");
175
+ toolbar.append(
176
+ addButton("insert-row-above", "Insert row above", () => rowOp("insert-above")),
177
+ addButton("insert-row-below", "Insert row below", () => rowOp("insert-below")),
178
+ addButton("delete-rows", "Delete rows", () => rowOp("delete")),
179
+ addButton("move-row-up", "Move row up", () => rowOp("up")),
180
+ addButton("move-row-down", "Move row down", () => rowOp("down")),
181
+ sep(),
182
+ addButton("insert-column-left", "Insert column left", () => columnOp("insert-left")),
183
+ addButton("insert-column-right", "Insert column right", () => columnOp("insert-right")),
184
+ addButton("delete-columns", "Delete columns", () => columnOp("delete")),
185
+ addButton("move-column-left", "Move column left", () => columnOp("left")),
186
+ addButton("move-column-right", "Move column right", () => columnOp("right")),
187
+ sep(),
188
+ addButton("sort-ascending", "Sort A to Z", () => sort("asc"), "Sort the rows by the selected column, ascending"),
189
+ addButton("sort-descending", "Sort Z to A", () => sort("desc"), "Sort the rows by the selected column, descending"),
190
+ addButton("transpose", "Swap rows and columns", () => transpose(), "Swap categories and series"),
191
+ headerLabel,
192
+ sep(),
193
+ addButton("copy", "Copy as TSV", () => copyToClipboard()),
194
+ h("label", {}, "Number format", formatSelect),
195
+ );
196
+ formatSelect.append(
197
+ h("option", { value: "auto" }, `Automatic (${resolveNumberFormat("auto", options.locale ?? doc.documentElement?.lang ?? doc.defaultView?.navigator?.language).label})`),
198
+ h("option", { value: "." }, `${NUMBER_FORMATS["."].label} (decimal point)`),
199
+ h("option", { value: "," }, `${NUMBER_FORMATS[","].label} (decimal comma)`),
200
+ );
201
+ formatSelect.value = numberFormat;
202
+ formatSelect.addEventListener("change", () => {
203
+ numberFormat = formatSelect.value;
204
+ rebuild();
205
+ say(`Numbers are read and shown as ${NUMBER_FORMATS[decimal()].label}.`);
206
+ });
207
+ headerBox.addEventListener("change", () => {
208
+ const enabled = headerBox.checked;
209
+ attempt(() => setGridHeader(editor, target.path, enabled, {}), () => (enabled ? "The first row is now the header row." : "The header row is now an ordinary row."), () => {
210
+ headerBox.checked = !enabled;
211
+ });
212
+ });
213
+ toolbar.addEventListener("keydown", (event) => {
214
+ const items = [...toolbar.querySelectorAll("button")].filter((button) => !button.hidden);
215
+ const index = items.indexOf(doc.activeElement);
216
+ if (index < 0 || !["ArrowRight", "ArrowLeft", "Home", "End"].includes(event.key)) return;
217
+ event.preventDefault();
218
+ const next = event.key === "Home" ? 0 : event.key === "End" ? items.length - 1 : (index + (event.key === "ArrowRight" ? 1 : -1) + items.length) % items.length;
219
+ items[next].focus();
220
+ });
221
+ toolbar.addEventListener("focusin", (event) => {
222
+ if (event.target.tagName !== "BUTTON") return;
223
+ for (const button of toolbar.querySelectorAll("button")) button.tabIndex = button === event.target ? 0 : -1;
224
+ });
225
+ for (const button of toolbar.querySelectorAll("button")) button.tabIndex = button.dataset.action === "insert-row-above" ? 0 : -1;
226
+
227
+ pasteButton.addEventListener("click", () => {
228
+ if (!pasteArea.value) return complain("Paste some text into the box first.");
229
+ if (pasteText(pasteArea.value)) pasteArea.value = "";
230
+ });
231
+
232
+ // --- selection geometry ---------------------------------------------------------------------
233
+ const cellAt = (u, c) => grid?.lines[u]?.[c];
234
+ const anchorOf = (u, c) => {
235
+ const cell = cellAt(u, c);
236
+ return cell?.covered ? cellAt(cell.owner.line, cell.owner.column) : cell;
237
+ };
238
+ const off = () => (grid?.hasHeader ? 1 : 0);
239
+
240
+ function expand(rect) {
241
+ let next = { ...rect };
242
+ let changed = true;
243
+ while (changed) {
244
+ changed = false;
245
+ for (const line of grid.lines)
246
+ for (const cell of line) {
247
+ if (cell.covered || (cell.rowSpan === 1 && cell.colSpan === 1)) continue;
248
+ const merge = { top: cell.line, left: cell.column, bottom: cell.line + cell.rowSpan - 1, right: cell.column + cell.colSpan - 1 };
249
+ const touches = !(merge.bottom < next.top || merge.top > next.bottom || merge.right < next.left || merge.left > next.right);
250
+ const inside = merge.top >= next.top && merge.left >= next.left && merge.bottom <= next.bottom && merge.right <= next.right;
251
+ if (touches && !inside) {
252
+ next = { top: Math.min(next.top, merge.top), left: Math.min(next.left, merge.left), bottom: Math.max(next.bottom, merge.bottom), right: Math.max(next.right, merge.right) };
253
+ changed = true;
254
+ }
255
+ }
256
+ }
257
+ return next;
258
+ }
259
+ const rect = () =>
260
+ expand({
261
+ top: Math.min(selection.anchor.u, selection.focus.u),
262
+ bottom: Math.max(selection.anchor.u, selection.focus.u),
263
+ left: Math.min(selection.anchor.c, selection.focus.c),
264
+ right: Math.max(selection.anchor.c, selection.focus.c),
265
+ });
266
+ const addr = (u, c) => (grid.hasHeader && u === 0 ? { section: "header", row: 0, column: c } : { section: "body", row: u - off(), column: c });
267
+ const clampCell = (u, c) => ({ u: Math.max(0, Math.min(grid.lines.length - 1, u)), c: Math.max(0, Math.min(grid.columnCount - 1, c)) });
268
+ function setSelection(anchor, focus, { scroll: scrollIt = true } = {}) {
269
+ if (!grid) return;
270
+ const a = anchorOf(anchor.u, anchor.c);
271
+ const f = anchorOf(focus.u, focus.c);
272
+ selection.anchor = { u: a.line, c: a.column };
273
+ selection.focus = { u: f.line, c: f.column };
274
+ paint();
275
+ if (scrollIt) cellNode(f.line, f.column)?.scrollIntoView?.({ block: "nearest", inline: "nearest" });
276
+ }
277
+ const activeCell = () => anchorOf(selection.focus.u, selection.focus.c);
278
+
279
+ // --- rendering -----------------------------------------------------------------------------------
280
+ const cellNode = (u, c) => tbody.querySelector(`td[data-line="${u}"][data-column="${c}"]`);
281
+
282
+ function renderRuns(node, runs) {
283
+ for (const run of runs) {
284
+ if (typeof run === "string") node.append(run);
285
+ else {
286
+ const span = h("span", {}, run.text);
287
+ if (run.bold) span.style.fontWeight = "700";
288
+ if (run.italic) span.style.fontStyle = "italic";
289
+ const decoration = [run.underline ? "underline" : "", run.strikethrough ? "line-through" : ""].filter(Boolean).join(" ");
290
+ if (decoration) span.style.textDecoration = decoration;
291
+ if (run.superscript) span.style.verticalAlign = "super";
292
+ if (run.subscript) span.style.verticalAlign = "sub";
293
+ if (run.superscript || run.subscript) span.style.fontSize = "0.8em";
294
+ node.append(span);
295
+ }
296
+ }
297
+ }
298
+
299
+ function buildTable() {
300
+ thead.replaceChildren();
301
+ tbody.replaceChildren();
302
+ notes.replaceChildren();
303
+ const roles = grid.columnRoles;
304
+ const rulerRow = h("tr", { role: "row", "aria-rowindex": 1 });
305
+ const corner = h("th", { role: "columnheader", scope: "col", "aria-label": "Select all cells", "data-ruler": "corner", "aria-colindex": 1 }, "");
306
+ rulerRow.append(corner);
307
+ for (let c = 0; c < grid.columnCount; c += 1) {
308
+ const role = roles[c];
309
+ const hint = role === "category" ? "categories" : role === "series" ? "series" : role === "x" ? "x values" : role === "label" ? "labels" : "";
310
+ const th = h("th", { role: "columnheader", scope: "col", "aria-colindex": c + 2, "data-ruler": "column", "data-column": c, "aria-label": `Column ${columnLabel(c)}${hint ? `, ${hint}` : ""}` }, columnLabel(c), hint ? h("span", { class: "opf-grid-role" }, hint) : null);
311
+ rulerRow.append(th);
312
+ }
313
+ thead.append(rulerRow);
314
+ table.setAttribute("aria-rowcount", String(grid.lines.length + 1));
315
+ table.setAttribute("aria-colcount", String(grid.columnCount + 1));
316
+ table.setAttribute("aria-label", `${target.kind === "chart" ? "Chart" : "Table"} data`);
317
+ grid.lines.forEach((line, u) => {
318
+ const header = grid.hasHeader && u === 0;
319
+ const tr = h("tr", { role: "row", "aria-rowindex": u + 2 }, h("th", { role: "rowheader", scope: "row", "data-ruler": "row", "data-line": u, "aria-colindex": 1 }, header ? "Header" : String(u - off() + 1)));
320
+ for (const cell of line) {
321
+ if (cell.covered) continue;
322
+ const merged = cell.rowSpan > 1 || cell.colSpan > 1;
323
+ const numeric = target.kind === "chart" && !header && roles[cell.column] !== "category" && roles[cell.column] !== "label";
324
+ const classes = ["opf-grid-cell"];
325
+ if (header) classes.push("opf-grid-header");
326
+ if (numeric) classes.push("opf-grid-number");
327
+ if (merged) classes.push("opf-grid-merged");
328
+ if (cell.warning) classes.push("opf-grid-warn");
329
+ const gap = target.kind === "chart" && !header && cell.text === "" && numeric;
330
+ if (gap) classes.push("opf-grid-gap");
331
+ const td = h("td", {
332
+ role: "gridcell",
333
+ class: classes.join(" "),
334
+ tabindex: -1,
335
+ "data-line": u,
336
+ "data-column": cell.column,
337
+ "aria-colindex": cell.column + 2,
338
+ "aria-selected": "false",
339
+ "aria-readonly": "false",
340
+ colspan: cell.colSpan > 1 ? cell.colSpan : undefined,
341
+ rowspan: cell.rowSpan > 1 ? cell.rowSpan : undefined,
342
+ "aria-colspan": cell.colSpan > 1 ? cell.colSpan : undefined,
343
+ "aria-rowspan": cell.rowSpan > 1 ? cell.rowSpan : undefined,
344
+ "aria-label": gap ? "Empty, a gap in the chart" : undefined,
345
+ title: cell.warning,
346
+ });
347
+ if (cell.runs) renderRuns(td, cell.runs);
348
+ else td.textContent = cell.text;
349
+ tr.append(td);
350
+ }
351
+ tbody.append(tr);
352
+ });
353
+ for (const warning of grid.warnings.slice(0, 20)) notes.append(h("li", {}, warning.message));
354
+ if (grid.warnings.length > 20) notes.append(h("li", {}, `${grid.warnings.length - 20} more.`));
355
+ }
356
+
357
+ function paint() {
358
+ if (!grid) return;
359
+ const r = rect();
360
+ const active = activeCell();
361
+ for (const td of tbody.querySelectorAll("td")) {
362
+ const u = Number(td.dataset.line);
363
+ const c = Number(td.dataset.column);
364
+ const selected = u >= r.top && u <= r.bottom && c >= r.left && c <= r.right;
365
+ td.setAttribute("aria-selected", String(selected));
366
+ td.classList.toggle("opf-grid-active", Boolean(active) && active.line === u && active.column === c);
367
+ td.tabIndex = active && active.line === u && active.column === c ? 0 : -1;
368
+ if (invalid.has(`${u}:${c}`)) td.setAttribute("aria-invalid", "true");
369
+ else td.removeAttribute("aria-invalid");
370
+ }
371
+ updateToolbar(r);
372
+ }
373
+
374
+ function updateToolbar(r) {
375
+ const start = off();
376
+ const hasBody = r.bottom >= start;
377
+ const top = Math.max(r.top, start);
378
+ const count = hasBody ? r.bottom - top + 1 : 0;
379
+ const first = top - start;
380
+ const columns = r.right - r.left + 1;
381
+ const disabled = {
382
+ "insert-row-above": !hasBody,
383
+ "insert-row-below": false,
384
+ "delete-rows": !hasBody || count >= grid.rowCount,
385
+ "move-row-up": !hasBody || first <= 0,
386
+ "move-row-down": !hasBody || first + count >= grid.rowCount,
387
+ "insert-column-left": false,
388
+ "insert-column-right": false,
389
+ "delete-columns": columns >= grid.columnCount,
390
+ "move-column-left": r.left <= 0,
391
+ "move-column-right": r.right >= grid.columnCount - 1,
392
+ "sort-ascending": grid.rowCount < 2,
393
+ "sort-descending": grid.rowCount < 2,
394
+ transpose: target.kind !== "chart" || grid.columnCount < 2,
395
+ };
396
+ for (const [name, button] of buttons) button.setAttribute("aria-disabled", String(Boolean(disabled[name])));
397
+ buttons.get("transpose").hidden = target.kind !== "chart";
398
+ // The toolbar stays one tab stop even when the button that held it is hidden for this kind of content.
399
+ const visible = [...toolbar.querySelectorAll("button")].filter((button) => !button.hidden);
400
+ if (!visible.some((button) => button.tabIndex === 0) && visible.length) visible[0].tabIndex = 0;
401
+ headerLabel.hidden = target.kind !== "table";
402
+ headerBox.checked = grid.hasHeader;
403
+ const nameRows = count > 1 ? `${count} rows` : "row";
404
+ buttons.get("delete-rows").textContent = count > 1 ? `Delete ${count} rows` : "Delete rows";
405
+ buttons.get("insert-row-above").setAttribute("aria-label", `Insert ${nameRows === "row" ? "row" : nameRows} above`);
406
+ buttons.get("insert-row-below").setAttribute("aria-label", `Insert ${nameRows === "row" ? "row" : nameRows} below`);
407
+ buttons.get("delete-columns").textContent = columns > 1 ? `Delete ${columns} columns` : "Delete columns";
408
+ }
409
+
410
+ // --- model sync --------------------------------------------------------------------------------
411
+ function resolveTarget() {
412
+ const path = getPath();
413
+ if (!path) return undefined;
414
+ return resolveDataGridTarget(editor.document, path);
415
+ }
416
+
417
+ function rebuild() {
418
+ if (destroyed) return;
419
+ rebuilding = true;
420
+ try {
421
+ rebuildTable();
422
+ } finally {
423
+ rebuilding = false;
424
+ }
425
+ }
426
+
427
+ function rebuildTable() {
428
+ const next = target;
429
+ root.hidden = !next;
430
+ if (!next) {
431
+ grid = undefined;
432
+ return;
433
+ }
434
+ titleText.textContent = next.kind === "chart" ? "Chart data" : "Table data";
435
+ pathText.textContent = next.path;
436
+ unavailable.hidden = next.editable !== false ? true : false;
437
+ scroll.hidden = toolbar.hidden = notes.hidden = pasteBox.hidden = help.hidden = next.editable === false;
438
+ if (next.editable === false) {
439
+ unavailable.textContent = next.reason;
440
+ grid = undefined;
441
+ return;
442
+ }
443
+ grid = describeDataGrid(editor.document, next.path, opts());
444
+ buildTable();
445
+ const clamped = clampCell(selection.focus.u, selection.focus.c);
446
+ const anchor = clampCell(selection.anchor.u, selection.anchor.c);
447
+ const f = anchorOf(clamped.u, clamped.c);
448
+ const a = anchorOf(anchor.u, anchor.c);
449
+ selection.focus = { u: f.line, c: f.column };
450
+ selection.anchor = { u: a.line, c: a.column };
451
+ paint();
452
+ if (editing && cellAt(editing.u, editing.c) && !cellAt(editing.u, editing.c).covered) mountEditor(editing.u, editing.c, editing.text, editing.caret);
453
+ else editing = null;
454
+ if (hadFocus && !editing) cellNode(selection.focus.u, selection.focus.c)?.focus({ preventScroll: true });
455
+ }
456
+
457
+ function dataSignature() {
458
+ if (!target || target.editable === false) return "";
459
+ try {
460
+ return JSON.stringify([target.path, editor.get(target.path), numberFormat]);
461
+ } catch {
462
+ return "";
463
+ }
464
+ }
465
+
466
+ function sync({ force = false } = {}) {
467
+ if (destroyed) return;
468
+ hadFocus = root.contains(doc.activeElement) && doc.activeElement.closest?.("td,textarea") != null;
469
+ const next = resolveTarget();
470
+ const changedTarget = JSON.stringify(next) !== JSON.stringify(target);
471
+ const previousPath = target?.path;
472
+ target = next;
473
+ if (changedTarget) {
474
+ if (next?.path !== previousPath) {
475
+ selection.anchor = { u: 0, c: 0 };
476
+ selection.focus = { u: 0, c: 0 };
477
+ invalid = new Map();
478
+ editing = null;
479
+ statusEl.textContent = "";
480
+ errorEl.textContent = "";
481
+ }
482
+ options.onTargetChange?.(next);
483
+ }
484
+ const sig = dataSignature();
485
+ if (!force && !changedTarget && sig === signature && grid) return;
486
+ signature = sig;
487
+ rebuild();
488
+ }
489
+ const unsubscribe = editor.subscribe(() => sync());
490
+
491
+ // --- editing --------------------------------------------------------------------------------------
492
+ function mountEditor(u, c, text, caret) {
493
+ const node = cellNode(u, c);
494
+ const cell = cellAt(u, c);
495
+ if (!node || !cell) return;
496
+ editing = { u, c, text, caret };
497
+ const area = h("textarea", { rows: 1, spellcheck: "false", "aria-label": `Edit ${describeAddress(addr(u, c))}`, "data-role": "cell-editor" });
498
+ area.value = text;
499
+ node.replaceChildren(area);
500
+ node.classList.add("opf-grid-editing");
501
+ const fit = () => {
502
+ area.style.height = "auto";
503
+ area.style.height = `${area.scrollHeight}px`;
504
+ };
505
+ area.addEventListener("input", () => {
506
+ editing.text = area.value;
507
+ fit();
508
+ checkLive(area, u, c);
509
+ });
510
+ area.addEventListener("keydown", (event) => editorKeydown(event, area, u, c));
511
+ area.addEventListener("blur", () => {
512
+ // Focus left the cell (a click elsewhere): save a valid edit, otherwise put the old value back and say why.
513
+ if (rebuilding || !editing || editing.u !== u || editing.c !== c || !area.isConnected) return;
514
+ if (!commitEdit({ blur: true })) {
515
+ editing = null;
516
+ rebuild();
517
+ }
518
+ });
519
+ fit();
520
+ area.focus();
521
+ const end = caret ?? text.length;
522
+ area.setSelectionRange(end, end);
523
+ checkLive(area, u, c);
524
+ }
525
+
526
+ function checkLive(area, u, c) {
527
+ const issues = gridCellIssues(editor.document, target.path, [{ ...addr(u, c), text: area.value }], opts());
528
+ if (issues.length) {
529
+ area.setAttribute("aria-invalid", "true");
530
+ complain(issues[0].message);
531
+ } else {
532
+ area.removeAttribute("aria-invalid");
533
+ errorEl.textContent = "";
534
+ }
535
+ }
536
+
537
+ function startEdit(u, c, replacement) {
538
+ const cell = anchorOf(u, c);
539
+ if (!cell || editing) return;
540
+ invalid = new Map();
541
+ selection.anchor = selection.focus = { u: cell.line, c: cell.column };
542
+ paint();
543
+ mountEditor(cell.line, cell.column, replacement ?? cell.text, replacement === undefined ? undefined : replacement.length);
544
+ }
545
+
546
+ function endEdit() {
547
+ editing = null;
548
+ rebuild();
549
+ }
550
+
551
+ function commitEdit({ blur = false } = {}) {
552
+ if (!editing) return true;
553
+ const { u, c } = editing;
554
+ const area = cellNode(u, c)?.querySelector("textarea");
555
+ const text = area ? area.value : editing.text;
556
+ const cell = cellAt(u, c);
557
+ if (text === cell.text) {
558
+ editing = null;
559
+ rebuild();
560
+ return true;
561
+ }
562
+ const draft = editing;
563
+ editing = null;
564
+ try {
565
+ setGridCells(editor, target.path, [{ ...addr(u, c), text }], opts());
566
+ invalid = new Map();
567
+ say(`${describeAddress(addr(u, c))} changed. Undo restores the previous value.`);
568
+ options.onChange?.({ action: "set-cells" });
569
+ return true;
570
+ } catch (error) {
571
+ invalid = new Map([[`${u}:${c}`, messageOf(error)]]);
572
+ complain(blur ? `${messageOf(error)} The old value was kept.` : messageOf(error));
573
+ if (!blur) {
574
+ editing = draft;
575
+ rebuild();
576
+ cellNode(u, c)?.setAttribute("aria-invalid", "true");
577
+ }
578
+ return false;
579
+ }
580
+ }
581
+
582
+ function moveAfterEdit(du, dc) {
583
+ const cell = activeCell();
584
+ const next = stepFrom(cell, du, dc);
585
+ setSelection(next, next);
586
+ cellNode(next.u, next.c)?.focus({ preventScroll: true });
587
+ }
588
+
589
+ function editorKeydown(event, area, u, c) {
590
+ event.stopPropagation();
591
+ // Enter or Tab that confirms an input method's composition is not a command.
592
+ if (event.isComposing) return;
593
+ if (event.key === "Enter" && event.altKey) {
594
+ event.preventDefault();
595
+ const { selectionStart: start, selectionEnd: end } = area;
596
+ area.value = `${area.value.slice(0, start)}\n${area.value.slice(end)}`;
597
+ area.setSelectionRange(start + 1, start + 1);
598
+ area.dispatchEvent(new Event("input"));
599
+ } else if (event.key === "Enter" || event.key === "Tab") {
600
+ event.preventDefault();
601
+ const backwards = event.shiftKey;
602
+ selection.anchor = selection.focus = { u, c };
603
+ if (!commitEdit()) return;
604
+ if (event.key === "Enter") moveAfterEdit(backwards ? -1 : 1, 0);
605
+ else moveAfterEdit(0, backwards ? -1 : 1);
606
+ } else if (event.key === "Escape") {
607
+ event.preventDefault();
608
+ editing = null;
609
+ errorEl.textContent = "";
610
+ rebuild();
611
+ cellNode(u, c)?.focus({ preventScroll: true });
612
+ say("Edit cancelled.");
613
+ }
614
+ }
615
+
616
+ // --- operations ------------------------------------------------------------------------------------
617
+ function attempt(action, message, onFail) {
618
+ try {
619
+ const change = action();
620
+ invalid = new Map();
621
+ say(`${typeof message === "function" ? message(change) : message} Undo restores the previous data.`);
622
+ options.onChange?.(change);
623
+ return change;
624
+ } catch (error) {
625
+ complain(messageOf(error));
626
+ onFail?.(error);
627
+ return undefined;
628
+ }
629
+ }
630
+
631
+ const markIssues = (error) => {
632
+ invalid = new Map();
633
+ for (const issue of error?.issues ?? []) {
634
+ if (!issue || issue.column === undefined) continue;
635
+ const u = issue.section === "header" ? 0 : (issue.row ?? 0) + off();
636
+ invalid.set(`${u}:${issue.column}`, issue.message);
637
+ }
638
+ paint();
639
+ };
640
+
641
+ function bodyRange() {
642
+ const r = rect();
643
+ const start = off();
644
+ const top = Math.max(r.top, start);
645
+ return { r, first: top - start, count: Math.max(0, r.bottom - top + 1), hasBody: r.bottom >= start };
646
+ }
647
+
648
+ function rowOp(kind) {
649
+ const { r, first, count, hasBody } = bodyRange();
650
+ const n = Math.max(1, count);
651
+ let action;
652
+ let message;
653
+ let next;
654
+ if (kind === "insert-above") {
655
+ action = () => insertGridRows(editor, target.path, first, n, opts());
656
+ message = `Inserted ${plural(n, "row")} above.`;
657
+ next = { top: first + off(), bottom: first + off() + n - 1 };
658
+ } else if (kind === "insert-below") {
659
+ const at = hasBody ? first + count : 0;
660
+ action = () => insertGridRows(editor, target.path, at, n, opts());
661
+ message = `Inserted ${plural(n, "row")} below.`;
662
+ next = { top: at + off(), bottom: at + off() + n - 1 };
663
+ } else if (kind === "delete") {
664
+ const indices = Array.from({ length: count }, (_, i) => first + i);
665
+ action = () => deleteGridRows(editor, target.path, indices, opts());
666
+ message = `Deleted ${plural(count, "row")}.`;
667
+ const at = Math.min(first, grid.rowCount - count - 1);
668
+ next = { top: Math.max(0, at) + off(), bottom: Math.max(0, at) + off() };
669
+ } else {
670
+ const to = kind === "up" ? first - 1 : first + 1;
671
+ action = () => moveGridRows(editor, target.path, first, to, count, opts());
672
+ message = `Moved ${plural(count, "row")} ${kind === "up" ? "up" : "down"}.`;
673
+ next = { top: to + off(), bottom: to + off() + count - 1 };
674
+ }
675
+ // The toolbar keeps the focus; the selection follows the rows that were inserted, moved or are now where the deleted ones were.
676
+ if (attempt(action, message)) setSelection({ u: next.top, c: r.left }, { u: next.bottom, c: r.right }, { scroll: false });
677
+ }
678
+
679
+ function columnOp(kind) {
680
+ const r = rect();
681
+ const count = r.right - r.left + 1;
682
+ let action;
683
+ let message;
684
+ let next;
685
+ if (kind === "insert-left") {
686
+ action = () => insertGridColumns(editor, target.path, r.left, count, opts());
687
+ message = `Inserted ${plural(count, "column")} to the left.`;
688
+ next = { left: r.left, right: r.left + count - 1 };
689
+ } else if (kind === "insert-right") {
690
+ action = () => insertGridColumns(editor, target.path, r.right + 1, count, opts());
691
+ message = `Inserted ${plural(count, "column")} to the right.`;
692
+ next = { left: r.right + 1, right: r.right + count };
693
+ } else if (kind === "delete") {
694
+ const indices = Array.from({ length: count }, (_, i) => r.left + i);
695
+ action = () => deleteGridColumns(editor, target.path, indices, opts());
696
+ message = `Deleted ${plural(count, "column")}.`;
697
+ const at = Math.min(r.left, grid.columnCount - count - 1);
698
+ next = { left: Math.max(0, at), right: Math.max(0, at) };
699
+ } else {
700
+ const to = kind === "left" ? r.left - 1 : r.left + 1;
701
+ action = () => moveGridColumns(editor, target.path, r.left, to, count, opts());
702
+ message = `Moved ${plural(count, "column")} ${kind === "left" ? "left" : "right"}.`;
703
+ next = { left: to, right: to + count - 1 };
704
+ }
705
+ if (attempt(action, message)) {
706
+ setSelection({ u: r.top, c: next.left }, { u: r.bottom, c: next.right }, { scroll: false });
707
+ // A new chart series starts unnamed: put the cursor in its name cell so it can be named at once.
708
+ if ((kind === "insert-left" || kind === "insert-right") && target.kind === "chart") startEdit(0, next.left);
709
+ }
710
+ }
711
+
712
+ function sort(direction) {
713
+ const column = activeCell().column;
714
+ attempt(() => sortGridRows(editor, target.path, column, { direction, ...opts() }), (change) => (change.sorted === false ? `Column ${columnLabel(column)} is already in that order.` : `Sorted by column ${columnLabel(column)}, ${direction === "asc" ? "A to Z" : "Z to A"}.`));
715
+ }
716
+
717
+ function transpose() {
718
+ const change = attempt(() => transposeGridData(editor, target.path, {}), (done) => `Swapped categories and series.${done.relabelled ? ` ${plural(done.relabelled, "category label")} became text.` : ""}`);
719
+ if (change) setSelection({ u: 0, c: 0 }, { u: 0, c: 0 }, { scroll: false });
720
+ }
721
+
722
+ function clearSelection() {
723
+ const r = rect();
724
+ const edits = [];
725
+ for (let u = r.top; u <= r.bottom; u += 1)
726
+ for (let c = r.left; c <= r.right; c += 1) {
727
+ const cell = cellAt(u, c);
728
+ if (!cell || cell.covered) continue;
729
+ edits.push({ ...addr(u, c), text: "" });
730
+ }
731
+ if (!edits.length) return;
732
+ attempt(() => setGridCells(editor, target.path, edits, opts()), `${plural(edits.length, "cell")} cleared.`);
733
+ }
734
+
735
+ // --- clipboard -----------------------------------------------------------------------------------------
736
+ function selectionText() {
737
+ const r = rect();
738
+ const rows = [];
739
+ for (let u = r.top; u <= r.bottom; u += 1) rows.push(grid.lines[u].slice(r.left, r.right + 1).map((cell) => cell.text));
740
+ return { text: toDelimited(rows, { delimiter: "\t" }), rows: rows.length, columns: r.right - r.left + 1 };
741
+ }
742
+
743
+ // Copy the selection as TSV with the async clipboard (falling back to a hidden textarea where it is unavailable or refused); `then` runs after a copy that worked.
744
+ function copyToClipboard(then) {
745
+ const { text, rows, columns } = selectionText();
746
+ const done = () => {
747
+ say(`Copied ${plural(rows, "row")} by ${plural(columns, "column")} as tab-separated text.`);
748
+ then?.();
749
+ };
750
+ const fallback = () => {
751
+ const previous = doc.activeElement;
752
+ const area = h("textarea", { "aria-hidden": "true", tabindex: -1 });
753
+ area.style.cssText = "position:fixed;opacity:0;left:-9999px";
754
+ area.value = text;
755
+ doc.body.append(area);
756
+ area.select();
757
+ let ok = false;
758
+ try {
759
+ ok = doc.execCommand("copy");
760
+ } catch {
761
+ ok = false;
762
+ }
763
+ area.remove();
764
+ previous?.focus?.({ preventScroll: true });
765
+ if (ok) done();
766
+ else complain("The browser blocked copying. Select the cells and press Ctrl+C.");
767
+ };
768
+ if (doc.defaultView?.navigator?.clipboard?.writeText) doc.defaultView.navigator.clipboard.writeText(text).then(done, fallback);
769
+ else fallback();
770
+ }
771
+
772
+ function pasteText(text) {
773
+ let rows;
774
+ try {
775
+ rows = parseDelimited(text).rows;
776
+ } catch (error) {
777
+ complain(messageOf(error));
778
+ return false;
779
+ }
780
+ if (!rows.length) {
781
+ complain("There is nothing to paste.");
782
+ return false;
783
+ }
784
+ const r = rect();
785
+ invalid = new Map();
786
+ const single = rows.length === 1 && rows[0].length === 1;
787
+ const multi = r.top !== r.bottom || r.left !== r.right;
788
+ try {
789
+ let change;
790
+ if (single && multi) {
791
+ const edits = [];
792
+ for (let u = r.top; u <= r.bottom; u += 1)
793
+ for (let c = r.left; c <= r.right; c += 1) {
794
+ const cell = cellAt(u, c);
795
+ if (cell && !cell.covered) edits.push({ ...addr(u, c), text: rows[0][0] });
796
+ }
797
+ change = setGridCells(editor, target.path, edits, opts());
798
+ say(`Filled ${plural(edits.length, "cell")}. Undo restores the previous data.`);
799
+ } else {
800
+ change = pasteGridText(editor, target.path, addr(r.top, r.left), rows, opts());
801
+ const width = rows.reduce((most, row) => Math.max(most, row.length), 0);
802
+ const note = change.decimal !== decimal() ? ` Numbers were read as ${NUMBER_FORMATS[change.decimal].label}, because the current format did not fit.` : "";
803
+ say(`Pasted ${plural(rows.length, "row")} by ${plural(width, "column")}.${note} Undo restores the previous data.`);
804
+ setSelection({ u: r.top, c: r.left }, { u: Math.min(r.top + rows.length - 1, grid.lines.length - 1), c: Math.min(r.left + width - 1, grid.columnCount - 1) });
805
+ }
806
+ options.onChange?.(change);
807
+ return true;
808
+ } catch (error) {
809
+ complain(messageOf(error));
810
+ markIssues(error);
811
+ return false;
812
+ }
813
+ }
814
+
815
+ // --- navigation -----------------------------------------------------------------------------------------
816
+ function stepFrom(cell, du, dc) {
817
+ let u = cell.line;
818
+ let c = cell.column;
819
+ if (du > 0) u = cell.line + cell.rowSpan - 1 + du;
820
+ else if (du < 0) u = cell.line + du;
821
+ if (dc > 0) c = cell.column + cell.colSpan - 1 + dc;
822
+ else if (dc < 0) c = cell.column + dc;
823
+ return clampCell(u, c);
824
+ }
825
+
826
+ function navigate(event) {
827
+ const cell = activeCell();
828
+ const extend = event.shiftKey;
829
+ let next;
830
+ const ctrl = event.ctrlKey || event.metaKey;
831
+ switch (event.key) {
832
+ case "ArrowDown": next = stepFrom(cell, 1, 0); break;
833
+ case "ArrowUp": next = stepFrom(cell, -1, 0); break;
834
+ case "ArrowRight": next = stepFrom(cell, 0, 1); break;
835
+ case "ArrowLeft": next = stepFrom(cell, 0, -1); break;
836
+ case "Home": next = ctrl ? { u: 0, c: 0 } : { u: cell.line, c: 0 }; break;
837
+ case "End": next = ctrl ? { u: grid.lines.length - 1, c: grid.columnCount - 1 } : { u: cell.line, c: grid.columnCount - 1 }; break;
838
+ case "PageDown": next = clampCell(cell.line + 10, cell.column); break;
839
+ case "PageUp": next = clampCell(cell.line - 10, cell.column); break;
840
+ default: return false;
841
+ }
842
+ event.preventDefault();
843
+ // When extending, the moving corner is the focus and the anchor stays; otherwise both move.
844
+ if (extend) setSelection(selection.anchor, next);
845
+ else setSelection(next, next);
846
+ cellNode(next.u, next.c)?.focus({ preventScroll: true });
847
+ cellNode(next.u, next.c)?.scrollIntoView?.({ block: "nearest", inline: "nearest" });
848
+ return true;
849
+ }
850
+
851
+ table.addEventListener("keydown", (event) => {
852
+ if (editing || !grid) return;
853
+ const ctrl = event.ctrlKey || event.metaKey;
854
+ const lower = event.key.length === 1 ? event.key.toLowerCase() : event.key;
855
+ if (ctrl && !event.altKey) {
856
+ if (lower === "a") {
857
+ event.preventDefault();
858
+ setSelection({ u: 0, c: 0 }, { u: grid.lines.length - 1, c: grid.columnCount - 1 });
859
+ return;
860
+ }
861
+ if (lower === "z" || lower === "y") {
862
+ event.preventDefault();
863
+ event.stopPropagation();
864
+ const change = lower === "y" || event.shiftKey ? editor.redo() : editor.undo();
865
+ say(change ? (lower === "y" || event.shiftKey ? "Redone." : "Undone.") : "Nothing to undo.");
866
+ return;
867
+ }
868
+ if (event.key === " ") {
869
+ event.preventDefault();
870
+ const cell = activeCell();
871
+ setSelection({ u: 0, c: cell.column }, { u: grid.lines.length - 1, c: cell.column });
872
+ return;
873
+ }
874
+ if (lower === "c" || lower === "x") {
875
+ // Browsers differ in firing copy for a focused cell that holds no selection, so the keys do the copy themselves.
876
+ event.preventDefault();
877
+ event.stopPropagation();
878
+ copyToClipboard(lower === "x" ? clearSelection : undefined);
879
+ return;
880
+ }
881
+ }
882
+ if (event.key === " " && event.shiftKey) {
883
+ event.preventDefault();
884
+ const cell = activeCell();
885
+ setSelection({ u: cell.line, c: 0 }, { u: cell.line, c: grid.columnCount - 1 });
886
+ return;
887
+ }
888
+ if (event.key === "Enter" || event.key === "F2") {
889
+ event.preventDefault();
890
+ const cell = activeCell();
891
+ startEdit(cell.line, cell.column);
892
+ return;
893
+ }
894
+ if (event.key === "Delete" || event.key === "Backspace") {
895
+ event.preventDefault();
896
+ clearSelection();
897
+ return;
898
+ }
899
+ if (event.key === "Escape") return;
900
+ if (navigate(event)) return;
901
+ if (KEYS_PRINTABLE(event)) {
902
+ event.preventDefault();
903
+ const cell = activeCell();
904
+ startEdit(cell.line, cell.column, event.key);
905
+ }
906
+ });
907
+
908
+ // The grid owns the clipboard while focus is in it: hosts that copy and paste OPF at page level must not also act.
909
+ table.addEventListener("copy", (event) => {
910
+ if (editing || !grid) return;
911
+ event.preventDefault();
912
+ event.stopPropagation();
913
+ const { text, rows, columns } = selectionText();
914
+ event.clipboardData?.setData("text/plain", text);
915
+ say(`Copied ${plural(rows, "row")} by ${plural(columns, "column")} as tab-separated text.`);
916
+ });
917
+ table.addEventListener("cut", (event) => {
918
+ if (editing || !grid) return;
919
+ event.preventDefault();
920
+ event.stopPropagation();
921
+ const { text } = selectionText();
922
+ event.clipboardData?.setData("text/plain", text);
923
+ clearSelection();
924
+ });
925
+ table.addEventListener("paste", (event) => {
926
+ if (editing || !grid) return;
927
+ event.preventDefault();
928
+ event.stopPropagation();
929
+ const text = event.clipboardData?.getData("text/plain") ?? "";
930
+ pasteText(text);
931
+ });
932
+
933
+ // --- pointer ------------------------------------------------------------------------------------------------
934
+ const cellFromEvent = (event) => {
935
+ const td = event.target.closest?.("td[data-line]");
936
+ return td ? { u: Number(td.dataset.line), c: Number(td.dataset.column) } : undefined;
937
+ };
938
+ table.addEventListener("mousedown", (event) => {
939
+ if (!grid || event.button !== 0) return;
940
+ const ruler = event.target.closest?.("th[data-ruler]");
941
+ if (ruler) {
942
+ event.preventDefault();
943
+ if (editing) commitEdit();
944
+ const kind = ruler.dataset.ruler;
945
+ const last = { u: grid.lines.length - 1, c: grid.columnCount - 1 };
946
+ if (kind === "corner") setSelection({ u: 0, c: 0 }, last);
947
+ else if (kind === "column") {
948
+ const c = Number(ruler.dataset.column);
949
+ setSelection(event.shiftKey ? selection.anchor : { u: 0, c }, { u: last.u, c });
950
+ if (event.shiftKey) selection.anchor = { u: 0, c: selection.anchor.c };
951
+ } else {
952
+ const u = Number(ruler.dataset.line);
953
+ setSelection(event.shiftKey ? selection.anchor : { u, c: 0 }, { u, c: last.c });
954
+ if (event.shiftKey) selection.anchor = { u: selection.anchor.u, c: 0 };
955
+ }
956
+ paint();
957
+ (cellNode(selection.focus.u, selection.focus.c) ?? table).focus?.({ preventScroll: true });
958
+ return;
959
+ }
960
+ const hit = cellFromEvent(event);
961
+ if (!hit) return;
962
+ if (editing) {
963
+ if (editing.u === hit.u && editing.c === hit.c) return;
964
+ if (!commitEdit()) {
965
+ event.preventDefault();
966
+ return;
967
+ }
968
+ }
969
+ dragging = true;
970
+ invalid = new Map();
971
+ setSelection(event.shiftKey ? selection.anchor : hit, hit, { scroll: false });
972
+ // A host that was editing text elsewhere (the slide canvas) saves it when this click takes the focus, and may move the focus back as it does.
973
+ const wanted = { ...selection.focus };
974
+ setTimeout(() => {
975
+ if (destroyed || editing || !grid || root.contains(doc.activeElement)) return;
976
+ cellNode(wanted.u, wanted.c)?.focus({ preventScroll: true });
977
+ }, 0);
978
+ });
979
+ table.addEventListener("mouseover", (event) => {
980
+ if (!dragging) return;
981
+ const hit = cellFromEvent(event);
982
+ if (hit) setSelection(selection.anchor, hit, { scroll: false });
983
+ });
984
+ const endDrag = () => {
985
+ dragging = false;
986
+ };
987
+ doc.addEventListener("mouseup", endDrag);
988
+ table.addEventListener("dblclick", (event) => {
989
+ const hit = cellFromEvent(event);
990
+ if (hit && grid) startEdit(hit.u, hit.c);
991
+ });
992
+ table.addEventListener("focusin", (event) => {
993
+ const node = event.target.closest?.("td[data-line]");
994
+ if (!node || editing) return;
995
+ const u = Number(node.dataset.line);
996
+ const c = Number(node.dataset.column);
997
+ const active = activeCell();
998
+ if (!active || active.line !== u || active.column !== c) setSelection({ u, c }, { u, c }, { scroll: false });
999
+ });
1000
+
1001
+ // --- public ---------------------------------------------------------------------------------------------------
1002
+ sync({ force: true });
1003
+
1004
+ return {
1005
+ element: root,
1006
+ /** Re-read the host's selection (and the document). Call it when the selection or slide changes. */
1007
+ refresh: () => sync(),
1008
+ /** Focus the active cell. */
1009
+ focus() {
1010
+ if (grid) cellNode(selection.focus.u, selection.focus.c)?.focus();
1011
+ },
1012
+ /** The chart or table the grid is bound to, `{ kind, path }`, or undefined. */
1013
+ get target() {
1014
+ return target ? { ...target } : undefined;
1015
+ },
1016
+ get numberFormat() {
1017
+ return numberFormat;
1018
+ },
1019
+ /** "auto", "." or ",": how chart numbers are read and shown. */
1020
+ setNumberFormat(format) {
1021
+ if (!["auto", ".", ","].includes(format)) throw new TypeError('The number format is "auto", "." or ",".');
1022
+ numberFormat = format;
1023
+ formatSelect.value = format;
1024
+ sync({ force: true });
1025
+ },
1026
+ /** Paste TSV or CSV text at the selected cell, as the clipboard paste does. Returns whether it applied. */
1027
+ paste: (text) => (grid ? pasteText(String(text)) : false),
1028
+ destroy() {
1029
+ destroyed = true;
1030
+ unsubscribe();
1031
+ doc.removeEventListener("mouseup", endDrag);
1032
+ root.remove();
1033
+ },
1034
+ };
1035
+ }