@sheetpane/grid 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (92) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +429 -2
  3. package/custom-elements.json +1246 -0
  4. package/dist/errors/element.d.ts +108 -0
  5. package/dist/errors/element.d.ts.map +1 -0
  6. package/dist/errors/element.js +456 -0
  7. package/dist/errors/element.js.map +1 -0
  8. package/dist/errors/index.d.ts +2 -0
  9. package/dist/errors/index.d.ts.map +1 -0
  10. package/dist/errors/index.js +3 -0
  11. package/dist/errors/index.js.map +1 -0
  12. package/dist/excel/element.d.ts +434 -0
  13. package/dist/excel/element.d.ts.map +1 -0
  14. package/dist/excel/element.js +3382 -0
  15. package/dist/excel/element.js.map +1 -0
  16. package/dist/excel/geometry.d.ts +81 -0
  17. package/dist/excel/geometry.d.ts.map +1 -0
  18. package/dist/excel/geometry.js +137 -0
  19. package/dist/excel/geometry.js.map +1 -0
  20. package/dist/excel/index.d.ts +2 -0
  21. package/dist/excel/index.d.ts.map +1 -0
  22. package/dist/excel/index.js +3 -0
  23. package/dist/excel/index.js.map +1 -0
  24. package/dist/excel/selection.d.ts +103 -0
  25. package/dist/excel/selection.d.ts.map +1 -0
  26. package/dist/excel/selection.js +189 -0
  27. package/dist/excel/selection.js.map +1 -0
  28. package/dist/excel/styles.d.ts +3 -0
  29. package/dist/excel/styles.d.ts.map +1 -0
  30. package/dist/excel/styles.js +206 -0
  31. package/dist/excel/styles.js.map +1 -0
  32. package/dist/frameworks/elements.d.ts +136 -0
  33. package/dist/frameworks/jsx.d.ts +64 -0
  34. package/dist/frameworks/jsx.js +2 -0
  35. package/dist/frameworks/solid.d.ts +133 -0
  36. package/dist/frameworks/solid.js +2 -0
  37. package/dist/frameworks/svelte.d.ts +102 -0
  38. package/dist/frameworks/svelte.js +2 -0
  39. package/dist/frameworks/vue.d.ts +69 -0
  40. package/dist/frameworks/vue.js +2 -0
  41. package/dist/index.d.ts +23 -0
  42. package/dist/index.d.ts.map +1 -0
  43. package/dist/index.js +25 -0
  44. package/dist/index.js.map +1 -0
  45. package/dist/llms-full.txt +8028 -0
  46. package/dist/llms.txt +111 -0
  47. package/dist/pane/index.d.ts +2 -0
  48. package/dist/pane/index.d.ts.map +1 -0
  49. package/dist/pane/index.js +3 -0
  50. package/dist/pane/index.js.map +1 -0
  51. package/dist/pane/registry.d.ts +33 -0
  52. package/dist/pane/registry.d.ts.map +1 -0
  53. package/dist/pane/registry.js +73 -0
  54. package/dist/pane/registry.js.map +1 -0
  55. package/dist/pane/rules.d.ts +231 -0
  56. package/dist/pane/rules.d.ts.map +1 -0
  57. package/dist/pane/rules.js +792 -0
  58. package/dist/pane/rules.js.map +1 -0
  59. package/dist/pane/store.d.ts +385 -0
  60. package/dist/pane/store.d.ts.map +1 -0
  61. package/dist/pane/store.js +1028 -0
  62. package/dist/pane/store.js.map +1 -0
  63. package/dist/pane/types.d.ts +130 -0
  64. package/dist/pane/types.d.ts.map +1 -0
  65. package/dist/pane/types.js +22 -0
  66. package/dist/pane/types.js.map +1 -0
  67. package/dist/plugins/host.d.ts +56 -0
  68. package/dist/plugins/host.d.ts.map +1 -0
  69. package/dist/plugins/host.js +124 -0
  70. package/dist/plugins/host.js.map +1 -0
  71. package/dist/plugins/types.d.ts +159 -0
  72. package/dist/plugins/types.d.ts.map +1 -0
  73. package/dist/plugins/types.js +12 -0
  74. package/dist/plugins/types.js.map +1 -0
  75. package/dist/rules.schema.json +52 -0
  76. package/dist/shared/dom.d.ts +23 -0
  77. package/dist/shared/dom.d.ts.map +1 -0
  78. package/dist/shared/dom.js +32 -0
  79. package/dist/shared/dom.js.map +1 -0
  80. package/dist/skills-bin.d.ts +3 -0
  81. package/dist/skills-bin.d.ts.map +1 -0
  82. package/dist/skills-bin.js +9 -0
  83. package/dist/skills-bin.js.map +1 -0
  84. package/dist/style.css +3 -0
  85. package/dist/tokens.d.ts +49 -0
  86. package/dist/tokens.d.ts.map +1 -0
  87. package/dist/tokens.js +43 -0
  88. package/dist/tokens.js.map +1 -0
  89. package/dist/vscode.css-custom-data.json +365 -0
  90. package/dist/vscode.html-custom-data.json +218 -0
  91. package/dist/web-types.json +914 -0
  92. package/package.json +72 -3
@@ -0,0 +1,3382 @@
1
+ // <sheetpane-excel> — the sheet: the view and edit components as two modes of one
2
+ // element (W-01). Plain DOM in an open shadow root (grid verdict decisions 2, 6);
3
+ // every byte of state lives in the pane's store (L7); the contract is the DOM's (L4).
4
+ import { cellRef, codeOf, colName, detailOf, download, parseRange, parseRef, spError, spMessage } from '@sheetpane/export';
5
+ import { controlsFor } from '../plugins/host.js';
6
+ import { acquirePane, attach, detach, isPrimary, releasePane } from '../pane/registry.js';
7
+ import { createWorkbook, parseInput, splitKey } from '../pane/store.js';
8
+ import { BUILTIN_TYPE_NAMES, DEFAULT_ERROR_TYPES, SEVERITY, machineAuthored } from '../pane/types.js';
9
+ import { ElementBase, ICON, esc, fire } from '../shared/dom.js';
10
+ import { pluginsFromIds } from '../plugins/host.js';
11
+ import { COL_MAX, COL_MIN, COL_VIRTUAL_AFTER, DEFAULT_COL_PX, HEAD_H, ROWHEAD_W, ROW_H, ROW_MAX, ROW_MIN, colAt, colWidth, hiddenSpans, isHiddenAt, mergeAt, rowAt, rowHeight, rowTop, sheetGeometry, visibleWindow } from './geometry.js';
12
+ import { cellCount, clipOf, dataEdge, growForMerges, rangeText, refLabel, spanOf, splitByPanes, totalsOf, usedRange } from './selection.js';
13
+ import { EXCEL_CSS } from './styles.js';
14
+ const ZOOMS = [0.75, 1, 1.25, 1.5];
15
+ const MAX_BYTES = 50 * 2 ** 20;
16
+ /** A size kept inside the limits (W-47 DoD 1): columns 16–1,200 px, rows 12–600 px, at 100 %. */
17
+ const clampSize = (axis, px) => Math.round(axis === 'col' ? Math.min(COL_MAX, Math.max(COL_MIN, px)) : Math.min(ROW_MAX, Math.max(ROW_MIN, px)));
18
+ /** Turns a `src` attribute value into bytes: a data URL, raw base64, or a URL to fetch (a read; nothing is sent). */
19
+ export async function bytesFromSrc(src) {
20
+ const b64 = (s) => Uint8Array.from(atob(s.replace(/\s+/g, '')), (c) => c.charCodeAt(0));
21
+ const guessName = (b) => (b[0] === 0x50 && b[1] === 0x4b ? 'workbook.xlsx' : 'data.csv');
22
+ if (src.startsWith('data:')) {
23
+ const comma = src.indexOf(',');
24
+ const meta = src.slice(5, comma), body = src.slice(comma + 1);
25
+ const bytes = /;base64$/.test(meta) ? b64(body) : new TextEncoder().encode(decodeURIComponent(body));
26
+ return { bytes, name: /csv/.test(meta) ? 'data.csv' : /sheet/.test(meta) ? 'workbook.xlsx' : guessName(bytes) };
27
+ }
28
+ if (src.length > 64 && /^[A-Za-z0-9+/=\s]+$/.test(src)) {
29
+ const bytes = b64(src);
30
+ return { bytes, name: guessName(bytes) };
31
+ }
32
+ const res = await fetch(src, { credentials: 'same-origin' });
33
+ if (!res.ok)
34
+ throw spError('SP-3015', `could not read ${src} (HTTP ${res.status})`);
35
+ const name = decodeURIComponent(new URL(src, location.href).pathname.split('/').pop() || 'workbook.xlsx');
36
+ return { bytes: new Uint8Array(await res.arrayBuffer()), name };
37
+ }
38
+ /** The text the cell bar shows for a value: raw, as Excel's formula bar does. */
39
+ const rawText = (v) => (v === null || v === undefined ? '' : typeof v === 'boolean' ? (v ? 'TRUE' : 'FALSE') : String(v));
40
+ const sevOrder = { error: 3, warning: 2, note: 1 };
41
+ const errNum = (e) => Number(/(\d+)$/.exec(e.id)?.[1] ?? 0);
42
+ /**
43
+ * `<sheetpane-excel>` — the sheet: views and edits a `.xlsx` or `.csv` file held in the pane's
44
+ * in-memory workbook (SR-GRID decisions 9–22e). Every change stays in memory and `export()`
45
+ * writes a copy of the upload with it (SR-LAW-6); a `<sheetpane-errors>` on the same `pane`
46
+ * reads the same store, so the two are always in step (SR-LAW-7). It registers on import.
47
+ *
48
+ * Attributes:
49
+ * - `pane` — the pane id shared with other elements; none gives the element its own store.
50
+ * - `src` — a URL (read with a GET; nothing is sent), a data URL or base64.
51
+ * - `mode` — `view` (the default) or `edit`.
52
+ * - `display` — `tab` (the default) or `popup` (a modal dialog, shown by `open`).
53
+ * - `open` — shows the pop-up.
54
+ * - `author` — written into the errors this element's user flags.
55
+ * - `plugins` — space-separated registered plugin ids; fixed per pane.
56
+ * - `theme` — `light` (the default), `dark` or `auto`.
57
+ * - `header-row` — the 1-based row holding column titles (default 1).
58
+ * - `on-invalid` — `flag` (the default) or `block`: what a failing column rule does.
59
+ * - `rules-src` — a URL to a rules file (read with a GET).
60
+ * - `flagging` — `on` (the default) or `off`: no Flag issue anywhere.
61
+ *
62
+ * Properties: `workbook`, `errors`, `errorTypes`, `file`, `mode`, `theme`, `open`, `src`, `sizes`,
63
+ * `rules`, `flagging`, `columns`, `validate`, `onInvalid`.
64
+ *
65
+ * Methods: `load()`, `export()`, `select()`, `getErrors()`, `setErrors()`, `undo()`, `redo()`,
66
+ * `shortcuts()`, `column()`, `renderNow()`, `geometryFor()`, `reveal()`, `unreveal()`.
67
+ *
68
+ * Every event bubbles and is composed; an action's event fires once, on the element where it
69
+ * happened. Cell payloads also carry `column: { index, letter, header }`.
70
+ *
71
+ * @fires sheetpane-load `{ status: 'ready', name, kind, sheets, readMs }` when a file is read, or the refusal `{ status: 'error', code, spCode, message, name, detail? }`.
72
+ * @fires sheetpane-change `{ sheet, ref, kind, was, now }` for each change, undo and redo included.
73
+ * @fires sheetpane-errors `{ errors, plugins }` when the pane's errors change.
74
+ * @fires sheetpane-select `{ sheet, ref, range, column, value }` when the selection settles — `range` (`B3:D8`) when more than one cell is selected; a drag fires once, on release.
75
+ * @fires sheetpane-visibility `{ sheet, axis, indexes, hidden, via }` when rows, columns or a sheet are hidden, unhidden or shown for viewing (`via: 'reveal'`) (W-49).
76
+ * @fires sheetpane-resize `{ sheet, axis, indexes, px, via }` when a column width or row height is set by a drag, a fit, the header menu or a reset — on release, never during a drag (W-47).
77
+ * @fires sheetpane-sheet `{ sheet }` when the selection moves to another sheet.
78
+ * @fires sheetpane-mode `{ mode }` when the pane switches between View and Edit.
79
+ * @fires sheetpane-history `{ reason, canUndo, canRedo }` on undo, redo, or the stack cleared by `setErrors()`.
80
+ * @fires sheetpane-editstart `{ sheet, ref, column, value }` before a cell edit starts; cancellable — the cell stays read-only.
81
+ * @fires sheetpane-input `{ sheet, ref, column, draft, was }` as the cell-bar draft is typed.
82
+ * @fires sheetpane-beforechange `{ sheet, ref, column, was, now }` before a value is stored; cancellable — the value is refused.
83
+ * @fires sheetpane-editcancel `{ sheet, ref }` when Esc restores a draft, or `{ sheet, ref, reason: 'blocked', code: 'SP-6003', message }` when a `block` rule refuses it.
84
+ * @fires sheetpane-export `{ blob, kind, name, guarded, format, converted, original? }` on `export()` and Download; cancellable — the Download control then saves nothing.
85
+ * @fires sheetpane-close `{}` when the pop-up closes.
86
+ *
87
+ * @slot format-row - Host controls for the format row (Edit only); the row shows when a plugin or this slot puts something in it.
88
+ * @csspart root - The sheet's frame: toolbar, cell bar, format row, grid, sheet tabs and status line.
89
+ * @csspart toolbar - The top bar: file, View/Edit, counts, find, zoom, undo, redo, freeze, Download.
90
+ * @csspart cellbar - The cell bar: the reference, the value being edited and the docked selection actions.
91
+ * @csspart format-row - The format row (Edit): plugin controls and the `format-row` slot.
92
+ * @csspart status - The status line under the sheet tabs.
93
+ * @csspart dialog - The modal `<dialog>` the root moves into when `display="popup"`.
94
+ *
95
+ * @example
96
+ * ```ts
97
+ * import '@sheetpane/grid';
98
+ * const sheet = document.createElement('sheetpane-excel');
99
+ * sheet.setAttribute('pane', 'q3');
100
+ * sheet.setAttribute('mode', 'edit');
101
+ * document.body.append(sheet);
102
+ * sheet.addEventListener('sheetpane-change', (e) => console.log((e as CustomEvent).detail));
103
+ * await sheet.load(file);
104
+ * const blob = await sheet.export();
105
+ * ```
106
+ * @see https://github.com/arpitarya/sheetpane/blob/main/records/0105_GRID.md
107
+ * @see https://github.com/arpitarya/sheetpane/blob/main/packages/grid/README.md#sheetpane-excel
108
+ */
109
+ export class SheetpaneExcel extends ElementBase {
110
+ /** The attributes the element reacts to. */
111
+ static observedAttributes = ['pane', 'src', 'mode', 'display', 'open', 'author', 'plugins', 'header-row', 'on-invalid', 'rules-src', 'flagging'];
112
+ _store;
113
+ _explicitStore;
114
+ _paneId;
115
+ _errorTypes = DEFAULT_ERROR_TYPES;
116
+ unsub = [];
117
+ shadow;
118
+ q;
119
+ built = false;
120
+ raf = 0;
121
+ zoom = 1;
122
+ findText = '';
123
+ findHits = [];
124
+ findIdx = -1;
125
+ findVersion = -1;
126
+ scrollToSel = false;
127
+ anchor;
128
+ /** A drag in progress (W-46): drawn by the selection layer each frame; the store hears of it on release. */
129
+ drag;
130
+ dragRaf = 0;
131
+ /** The last pointer was a finger: a range shows its two handles. */
132
+ touchUi = false;
133
+ /** The range Ctrl/⌘+C copied, outlined until Esc, an edit or the next copy. */
134
+ copied;
135
+ selKey = '';
136
+ ariaOn = new Set();
137
+ sayTimer;
138
+ totalsKey = [];
139
+ /** Column widths and row heights the user set, per sheet, px at 100 % — a view setting, never a change (W-47). */
140
+ _sizes = {};
141
+ sizesVer = 0;
142
+ /** A header-edge drag in progress (W-47). */
143
+ rz;
144
+ rzRaf = 0;
145
+ /** What this element shows of what the file hides, per sheet — a view setting, never a change (W-49). */
146
+ revealed = {};
147
+ /** Hidden sheets opened for viewing from the hidden-sheets list (W-49). */
148
+ openedSheets = new Set();
149
+ /** The note after an automatic reveal: "Shown for ERR-0004 · Hide again". */
150
+ revealNote;
151
+ hiddenMenuOpen = false;
152
+ /** The tab menu (W-49): right-click a tab or Shift+F10 on it; only plugin items, so it opens only when there are some. */
153
+ tabMenu;
154
+ revealVer = 0;
155
+ /** The header menu (W-47 DoD 4): which axis and indexes it acts on, and the exact-size popover's state. */
156
+ hdrMenu;
157
+ totalsText = '';
158
+ composerKey = '';
159
+ draft = { type: 'wrong-value', message: '', expected: '', severity: 'error', change: false };
160
+ geom;
161
+ lastBody = '';
162
+ cellFocusStart;
163
+ overlayCache = new Map();
164
+ onUnload = (e) => { if (this._store && this._store.changeCount > 0) {
165
+ e.preventDefault();
166
+ e.returnValue = '';
167
+ } };
168
+ // ── the DOM contract: properties ─────────────────────────────────────────
169
+ /** The pane's store; assigning one replaces the `pane` lookup and applies this element's `mode` to it. */
170
+ get workbook() { return this._store; }
171
+ set workbook(s) {
172
+ this._explicitStore = s;
173
+ if (!this.isConnected)
174
+ return;
175
+ this.unbind();
176
+ this.bind();
177
+ const m = this.getAttribute('mode'); // the element's mode applies to the store it is given, as on connect
178
+ if (m === 'edit' || m === 'view')
179
+ this._store.setMode(m, this);
180
+ this.schedule();
181
+ }
182
+ /** A copy of the pane's errors; assigning restores a saved review, like `setErrors()`. */
183
+ get errors() { return this._store?.getErrors() ?? []; }
184
+ set errors(list) { this.setErrors(list); }
185
+ /** The types offered in the Flag issue form; an empty list falls back to `DEFAULT_ERROR_TYPES`. */
186
+ get errorTypes() { return this._errorTypes; }
187
+ set errorTypes(t) { this._errorTypes = t.length ? t : DEFAULT_ERROR_TYPES; this.schedule(); }
188
+ /** Loads a file, like `load()`; `null` does nothing. */
189
+ set file(f) { if (f)
190
+ void this.load(f); }
191
+ /** The pane's mode, `view` or `edit`; setting it sets the `mode` attribute. */
192
+ get mode() { return this._store?.getSnapshot().mode ?? (this.getAttribute('mode') || 'view'); }
193
+ set mode(m) { this.setAttribute('mode', m); }
194
+ /** `light` (the default), `dark`, or `auto` (follows `prefers-color-scheme`) — colour only; the file's own colours never change (W-29). */
195
+ get theme() { const t = this.getAttribute('theme'); return t === 'dark' || t === 'auto' ? t : 'light'; }
196
+ set theme(t) { this.setAttribute('theme', t); }
197
+ // ── column rules (W-27) and the rules file (W-33): the pane's, set from here ──
198
+ rulesCfg;
199
+ /** A rules file (W-33) as an object: `{ version: 1, sheets: { "*": …, "Accounts": { headerRow, columns, cells } } }`. Also `rules-src`. */
200
+ get rules() { return this.rulesCfg?.document; }
201
+ set rules(doc) {
202
+ if (doc === undefined || doc === null) {
203
+ if (this.rulesCfg) {
204
+ const { document: _drop, ...rest } = this.rulesCfg;
205
+ this.rulesCfg = rest;
206
+ }
207
+ }
208
+ else
209
+ this.rulesCfg = { ...this.rulesCfg, document: doc };
210
+ this.applyRules();
211
+ }
212
+ /** `rules-src`: a URL read with a GET, like `src` (nothing is sent); a failure is a note in the errors, never a throw. */
213
+ async loadRules(url) {
214
+ let doc;
215
+ try {
216
+ const res = await fetch(url, { credentials: 'same-origin' });
217
+ doc = res.ok ? JSON.parse(await res.text()) : { __error: `${url}: HTTP ${res.status}` };
218
+ }
219
+ catch (e) {
220
+ doc = { __error: `${url}: ${e.message}` };
221
+ }
222
+ if (this.getAttribute('rules-src') === url)
223
+ this.rules = doc;
224
+ }
225
+ /** `flagging="off"`: no Flag issue anywhere, no composer, no callout card — the errors panel is the one place errors are read. */
226
+ get flagging() { return this.getAttribute('flagging') === 'off' ? 'off' : 'on'; }
227
+ set flagging(v) { this.setAttribute('flagging', v); }
228
+ /** Rules by column title, as plain JSON (`required`, `type`, `min`, `max`, `minLength`, `maxLength`, `pattern`, `oneOf`, `unique`, `message`, `severity`, `onInvalid`). */
229
+ get columns() { return this.rulesCfg?.columns; }
230
+ set columns(c) { this.rulesCfg = { ...this.rulesCfg, columns: c ?? {} }; this.applyRules(); }
231
+ /** `validate(ctx, signal)`, sync or async: `null`, or `{ severity, message, expected? }`. */
232
+ get validate() { return this.rulesCfg?.validate; }
233
+ set validate(f) { this.rulesCfg = { ...this.rulesCfg, validate: f }; this.applyRules(); }
234
+ /** The default for every rule: `flag` (store and add an error) or `block` (refuse the value). Also the `on-invalid` attribute. */
235
+ get onInvalid() { return this.getAttribute('on-invalid') === 'block' ? 'block' : 'flag'; }
236
+ set onInvalid(v) { this.setAttribute('on-invalid', v); }
237
+ /** Rules belong to the pane: only an element that was given rules sets them. */
238
+ applyRules() {
239
+ if (!this.rulesCfg || !this._store)
240
+ return;
241
+ const c = { columns: this.rulesCfg.columns ?? {}, validate: this.rulesCfg.validate, onInvalid: this.onInvalid, headerRow: Math.max(1, Number(this.getAttribute('header-row') ?? 1) || 1) };
242
+ if (this.rulesCfg.document !== undefined)
243
+ c.document = this.rulesCfg.document;
244
+ this._store.setColumnRules(c);
245
+ }
246
+ /** Whether the pop-up is shown (the `open` attribute). */
247
+ get open() { return this.hasAttribute('open'); }
248
+ set open(v) { this.toggleAttribute('open', v); }
249
+ /**
250
+ * The column widths and row heights a user set, px at 100 % zoom: `{ [sheet]: { cols: { B: 120 }, rows: { 4: 40 } } }`
251
+ * (columns by letter, rows by number). A view setting like zoom — not a change, not undone, not in the
252
+ * download; a new file clears it. Set it to restore what a host saved from `sheetpane-resize` (W-47).
253
+ */
254
+ get sizes() {
255
+ const out = {};
256
+ for (const [sheet, z] of Object.entries(this._sizes)) {
257
+ const cols = Object.fromEntries(Object.entries(z.cols).map(([c, px]) => [colName(Number(c)), px]));
258
+ const rows = Object.fromEntries(Object.entries(z.rows).map(([r, px]) => [String(Number(r) + 1), px]));
259
+ if (Object.keys(cols).length || Object.keys(rows).length)
260
+ out[sheet] = { cols, rows };
261
+ }
262
+ return out;
263
+ }
264
+ set sizes(v) {
265
+ const next = {};
266
+ for (const [sheet, z] of Object.entries(v ?? {})) {
267
+ const cols = {}, rows = {};
268
+ for (const [k, px] of Object.entries(z.cols ?? {})) {
269
+ const c = parseRef(`${k}1`)?.col;
270
+ if (c !== undefined && Number.isFinite(px))
271
+ cols[c] = clampSize('col', px);
272
+ }
273
+ for (const [k, px] of Object.entries(z.rows ?? {})) {
274
+ const r = Number(k) - 1;
275
+ if (Number.isInteger(r) && r >= 0 && Number.isFinite(px))
276
+ rows[r] = clampSize('row', px);
277
+ }
278
+ next[sheet] = { cols, rows };
279
+ }
280
+ this._sizes = next;
281
+ this.sizesVer++;
282
+ this.schedule();
283
+ }
284
+ /** The `src` attribute; `null`, `undefined` or `''` removes it. */
285
+ get src() { return this.getAttribute('src'); }
286
+ set src(v) { if (v === null || v === undefined || v === '')
287
+ this.removeAttribute('src');
288
+ else
289
+ this.setAttribute('src', v); }
290
+ // ── methods ──────────────────────────────────────────────────────────────
291
+ /** Reads a file into the pane: a File, Blob, bytes, a URL, or base64. */
292
+ async load(src, name) {
293
+ const store = this.ensureStore();
294
+ let data, nm = name;
295
+ if (typeof src === 'string') {
296
+ try {
297
+ const r = await bytesFromSrc(src);
298
+ data = r.bytes;
299
+ nm ??= r.name;
300
+ }
301
+ catch (e) {
302
+ this.showEmpty = false;
303
+ store.failLoad(name ?? src, detailOf(e), this, codeOf(e));
304
+ return;
305
+ }
306
+ }
307
+ else {
308
+ data = src;
309
+ if (!nm && typeof File !== 'undefined' && src instanceof File)
310
+ nm = src.name;
311
+ if (!nm) {
312
+ const head = src instanceof Blob ? new Uint8Array(await src.slice(0, 2).arrayBuffer()) : new Uint8Array(src instanceof ArrayBuffer ? src : src.buffer, src instanceof ArrayBuffer ? 0 : src.byteOffset, 2);
313
+ nm = head[0] === 0x50 && head[1] === 0x4b ? 'workbook.xlsx' : 'data.csv';
314
+ }
315
+ }
316
+ this.geom = undefined;
317
+ this.showEmpty = false;
318
+ await store.load(data, nm, this);
319
+ }
320
+ /** Every change applied; the upload untouched. Also dispatches `sheetpane-export`. */
321
+ async export(options = {}, o = {}) {
322
+ const store = this.ensureStore();
323
+ const r = await store.exportFile({ options, ...o, ...(o.format === 'csv' && !o.sheet ? { sheet: store.getSnapshot().selection.sheet } : {}) });
324
+ this.lastExport = { ok: fire(this, 'sheetpane-export', { blob: r.blob, kind: r.kind, name: r.name, guarded: r.guarded, format: r.kind, converted: r.converted }, true), name: r.name };
325
+ return r.blob;
326
+ }
327
+ lastExport;
328
+ lastSheet = '';
329
+ /** Selects a cell and scrolls to it. */
330
+ select(sheet, ref) { this.ensureStore().select({ sheet, ref }, this); this.scrollToSel = true; }
331
+ /** A copy of the pane's errors, to save a review. */
332
+ getErrors() { return this.errors; }
333
+ /** Restores a saved review; not a user action, so the undo stack is cleared. */
334
+ setErrors(list) { this.ensureStore().setErrors(list, this); }
335
+ /** Undoes the pane's last step. */
336
+ undo() { this._store?.undo(this); }
337
+ /** Redoes the last undone step. */
338
+ redo() { this._store?.redo(this); }
339
+ // ── lifecycle ────────────────────────────────────────────────────────────
340
+ /** Binds to the pane's store, applies `mode`, and reads `src` and `rules-src`. */
341
+ connectedCallback() {
342
+ if (!this.built)
343
+ this.build();
344
+ this.bind();
345
+ const m = this.getAttribute('mode');
346
+ if (m === 'edit' || m === 'view')
347
+ this._store.setMode(m, this);
348
+ const src = this.getAttribute('src');
349
+ if (src && this._store.getSnapshot().load.status === 'empty')
350
+ void this.load(src);
351
+ const rsrc = this.getAttribute('rules-src');
352
+ if (rsrc && this.rules === undefined)
353
+ void this.loadRules(rsrc);
354
+ addEventListener('beforeunload', this.onUnload);
355
+ this.place();
356
+ this.schedule();
357
+ }
358
+ /** Releases the pane's store. */
359
+ disconnectedCallback() {
360
+ removeEventListener('beforeunload', this.onUnload);
361
+ this.unbind();
362
+ cancelAnimationFrame(this.raf);
363
+ }
364
+ /** Reacts to an observed attribute. */
365
+ attributeChangedCallback(name, old, val) {
366
+ if (!this.isConnected || old === val)
367
+ return;
368
+ if (name === 'pane' || name === 'plugins') {
369
+ this.unbind();
370
+ this.bind();
371
+ }
372
+ else if (name === 'src' && val)
373
+ void this.load(val);
374
+ else if (name === 'mode' && (val === 'view' || val === 'edit'))
375
+ this._store?.setMode(val, this);
376
+ else if (name === 'display' || name === 'open')
377
+ this.place();
378
+ else if (name === 'header-row' || name === 'on-invalid')
379
+ this.applyRules();
380
+ else if (name === 'rules-src') {
381
+ if (val)
382
+ void this.loadRules(val);
383
+ else
384
+ this.rules = undefined;
385
+ }
386
+ else if (name === 'flagging')
387
+ this._store?.setFlagging(val !== 'off');
388
+ this.schedule();
389
+ }
390
+ ensureStore() {
391
+ if (!this._store)
392
+ this.bind();
393
+ return this._store;
394
+ }
395
+ bind() {
396
+ if (this._store)
397
+ return;
398
+ const pane = this.getAttribute('pane');
399
+ const plugins = (this.getAttribute('plugins') ?? '').split(/\s+/).filter(Boolean);
400
+ if (this._explicitStore)
401
+ this._store = this._explicitStore;
402
+ else if (pane) {
403
+ this._store = acquirePane(pane, this, plugins);
404
+ this._paneId = pane;
405
+ }
406
+ else
407
+ this._store = createWorkbook({ plugins: pluginsFromIds(plugins) });
408
+ const s = this._store;
409
+ attach(s, this);
410
+ this.unsub.push(s.subscribe(() => this.schedule()), s.on((e) => this.onStoreEvent(e)), () => detach(s, this));
411
+ this.applyRules();
412
+ if (this.hasAttribute('flagging'))
413
+ s.setFlagging(this.getAttribute('flagging') !== 'off');
414
+ }
415
+ unbind() {
416
+ for (const u of this.unsub)
417
+ u();
418
+ this.unsub = [];
419
+ if (this._paneId)
420
+ releasePane(this._paneId, this);
421
+ this._paneId = undefined;
422
+ this._store = undefined;
423
+ this.geom = undefined;
424
+ }
425
+ /** One DOM event per action, on the element where it happened (or the pane's first sheet when nobody owns it). */
426
+ onStoreEvent(e) {
427
+ if (e.type === 'select' && e.origin !== this)
428
+ this.scrollToSel = true;
429
+ if (e.type === 'select')
430
+ this.revealFor(e.sheet, e.ref);
431
+ if ((e.type === 'change' || e.type === 'load') && this.copied) {
432
+ this.copied = undefined;
433
+ this.selKey = '';
434
+ } // an edit ends the copied outline
435
+ if (e.type === 'select' || e.type === 'load') {
436
+ this.ctxOpen = false;
437
+ this.moreOpen = false;
438
+ this.hdrMenu = undefined;
439
+ this.tabMenu = undefined;
440
+ if (e.type === 'load')
441
+ this.dlOpen = false;
442
+ } // a menu belongs to the cell it opened on
443
+ const sheetBefore = this.lastSheet;
444
+ if (e.type === 'load')
445
+ this.lastSheet = this._store.getSnapshot().selection.sheet;
446
+ if (e.type === 'select')
447
+ this.lastSheet = e.sheet;
448
+ if (e.type === 'load') {
449
+ this.revealed = {};
450
+ this.openedSheets.clear();
451
+ this.revealNote = undefined;
452
+ this.revealVer++;
453
+ this.hiddenMenuOpen = false;
454
+ // a workbook opens on a visible sheet, even when the file's active tab is hidden
455
+ const st = this._store, snap0 = st.getSnapshot();
456
+ if (snap0.book && st.hidden(snap0.selection.sheet).state !== 'visible') {
457
+ const first = snap0.book.sheets.find((x) => st.hidden(x.name).state === 'visible');
458
+ if (first)
459
+ queueMicrotask(() => st.select({ sheet: first.name, ref: 'A1' }));
460
+ }
461
+ }
462
+ if (e.type === 'load') {
463
+ this._sizes = {};
464
+ this.sizesVer++;
465
+ this.rz = undefined;
466
+ this.hdrMenu = undefined;
467
+ this.geom = undefined;
468
+ this.findHits = [];
469
+ this.findVersion = -1;
470
+ for (const el of this.overlayCache.values())
471
+ el.remove();
472
+ this.overlayCache.clear();
473
+ }
474
+ const mine = e.origin === this || (e.origin === undefined && isPrimary(this._store, this));
475
+ if (!mine)
476
+ return;
477
+ const s = this._store;
478
+ switch (e.type) {
479
+ case 'load': {
480
+ const snap = s.getSnapshot();
481
+ fire(this, 'sheetpane-load', snap.load.status === 'ready'
482
+ ? { status: 'ready', name: snap.source.name, kind: snap.source.kind, sheets: snap.book.sheets.map((x) => x.name), readMs: snap.source.readMs }
483
+ : { ...snap.load });
484
+ break;
485
+ }
486
+ case 'change':
487
+ fire(this, 'sheetpane-change', { sheet: e.sheet, ref: e.ref, kind: e.kind, was: e.was, now: e.now });
488
+ if (e.kind === 'hidden' || e.kind === 'sheetState')
489
+ this.visibilityEvent(e.sheet, e.ref, e.kind, e.was, e.now);
490
+ break;
491
+ case 'errors':
492
+ fire(this, 'sheetpane-errors', { errors: s.getErrors(), plugins: s.pluginIds });
493
+ break;
494
+ case 'select':
495
+ if (e.sheet !== sheetBefore)
496
+ fire(this, 'sheetpane-sheet', { sheet: e.sheet });
497
+ fire(this, 'sheetpane-select', { sheet: e.sheet, ref: e.ref, range: s.getSnapshot().selection.range, column: this.column(e.sheet, e.ref), value: s.value(e.sheet, e.ref) });
498
+ break;
499
+ case 'mode':
500
+ fire(this, 'sheetpane-mode', { mode: e.mode });
501
+ break;
502
+ case 'history':
503
+ fire(this, 'sheetpane-history', { reason: e.reason, canUndo: s.getSnapshot().canUndo, canRedo: s.getSnapshot().canRedo });
504
+ break;
505
+ }
506
+ }
507
+ // ── the skeleton, built once ─────────────────────────────────────────────
508
+ build() {
509
+ this.built = true;
510
+ this.shadow = this.attachShadow({ mode: 'open' });
511
+ const zoomOpts = ZOOMS.map((z) => `<option value="${z}"${z === 1 ? ' selected' : ''}>${z * 100} %</option>`).join('');
512
+ this.shadow.innerHTML = `<style>${EXCEL_CSS}</style><dialog part="dialog" aria-label="Spreadsheet"></dialog>
513
+ <div class="sp" part="root">
514
+ <div class="bar" part="toolbar">
515
+ <span class="ico">${ICON.sheet}</span><span class="fname"></span><span class="chip meta"></span>
516
+ <div class="seg modeseg" role="group" aria-label="Mode"><button type="button" data-mode="view" aria-pressed="true">${ICON.view}View</button><button type="button" data-mode="edit" aria-pressed="false">${ICON.edit}Edit</button></div>
517
+ <span class="counts"></span>
518
+ <span class="spacer"></span>
519
+ <label class="find"><span class="vh">Find in sheet</span>${ICON.find}<input class="input findin" type="search" placeholder="Find" autocomplete="off"><span class="cnt"></span></label>
520
+ <select class="input zoom" aria-label="Zoom">${zoomOpts}</select>
521
+ <span class="editonly" style="display:contents">
522
+ <button type="button" class="btn btn-g btn-i undo" data-sp="undo" aria-label="Undo (Ctrl+Z)" title="Undo">${ICON.undo}</button>
523
+ <button type="button" class="btn btn-g btn-i redo" data-sp="redo" aria-label="Redo (Ctrl+Shift+Z)" title="Redo">${ICON.redo}</button>
524
+ <button type="button" class="btn btn-g btn-i freeze" aria-label="Freeze panes at the selected cell" title="Freeze rows above and columns left of the selected cell">${ICON.freeze}</button>
525
+ <span class="xs faint changes"></span>
526
+ </span>
527
+ <span class="split dl"><button type="button" class="btn export" data-sp="download">${ICON.download}<span class="lbl">Download</span><span class="badge-n" hidden></span></button><button type="button" class="btn btn-i dlmore" data-sp="download-menu" aria-label="Choose download format" aria-haspopup="menu" aria-expanded="false">▾</button><span class="dlslot"></span></span>
528
+ <button type="button" class="btn btn-g btn-i help" aria-label="Keyboard shortcuts" aria-expanded="false" title="Keyboard shortcuts">?</button>
529
+ <button type="button" class="btn btn-g btn-i close" aria-label="Close (Esc)" title="Close">${ICON.close}</button>
530
+ </div>
531
+ <div class="fx" part="cellbar">
532
+ <span class="ref"></span>
533
+ <label class="cellwrap" style="display:contents"><span class="vh cellname">Cell value</span><input class="input cell" data-sp="cell-input" placeholder="(empty)" autocomplete="off" spellcheck="false"></label>
534
+ <span class="val"></span>
535
+ <span class="tag merge"></span>
536
+ <span class="chip chip-info changed"></span><button type="button" class="btn btn-g revert">Revert</button>
537
+ <span class="chip issue"></span>
538
+ <span class="chip chip-info hint">Describe it in the Errors panel</span>
539
+ <span class="chip rulemsg" role="status"></span>
540
+ <span class="plug-cellbar" style="display:contents"></span>
541
+ <span class="dock" role="toolbar" aria-label="Selection"></span>
542
+ </div>
543
+ <div class="frow" part="format-row" role="toolbar" aria-label="Format"><span class="plug-frow" style="display:contents"></span><slot name="format-row"></slot></div>
544
+ <div class="hidbar" role="status" hidden></div>
545
+ <div class="body">
546
+ <div class="scroller">
547
+ <div class="sheet"><div class="grid" role="grid" tabindex="0" aria-label="Sheet" aria-multiselectable="true"><div class="hrow" role="row" aria-rowindex="1"></div><div class="frozenrows" style="display:contents"></div><div class="rows"></div><div class="sellayer" aria-hidden="true"></div></div><div class="hidlayer"></div><div class="overlay"></div></div>
548
+ </div>
549
+ <div class="state" hidden></div>
550
+ </div>
551
+ <div class="hidmenuslot"></div>
552
+ <div class="tabs"></div>
553
+ <div class="status" part="status" role="status"></div>
554
+ <span class="vh selsay" aria-live="polite"></span>
555
+ <input type="file" class="picker" accept=".xlsx,.csv" hidden>
556
+ </div>`;
557
+ const $ = (sel) => this.shadow.querySelector(sel);
558
+ this.q = {
559
+ root: $('.sp'), dialog: $('dialog'), fname: $('.fname'), meta: $('.meta'), counts: $('.counts'), findin: $('.findin'), findcnt: $('.find .cnt'), zoom: $('.zoom'),
560
+ editonly: $('.editonly'), undo: $('.undo'), redo: $('.redo'), freeze: $('.freeze'), changes: $('.changes'), export: $('.export'), dlmore: $('.dlmore'), dlslot: $('.dlslot'), close: $('.close'), dock: $('.dock'),
561
+ help: $('.help'), ref: $('.ref'), cell: $('.cell'), cellname: $('.cellname'), val: $('.val'), merge: $('.merge'), changed: $('.changed'), revert: $('.revert'), issue: $('.issue'), hint: $('.hint'), rulemsg: $('.rulemsg'),
562
+ plugCellbar: $('.plug-cellbar'), frow: $('.frow'), plugFrow: $('.plug-frow'), body: $('.body'), scroller: $('.scroller'), sheet: $('.sheet'), hrow: $('.hrow'),
563
+ grid: $('.grid'), frozen: $('.frozenrows'), rows: $('.rows'), sellayer: $('.sellayer'), hidlayer: $('.hidlayer'), hidbar: $('.hidbar'), hidmenuslot: $('.hidmenuslot'), selsay: $('.selsay'), overlay: $('.overlay'), state: $('.state'), tabs: $('.tabs'), status: $('.status'), picker: $('.picker'), modeseg: $('.modeseg'),
564
+ };
565
+ this.wire();
566
+ }
567
+ /** Tab or pop-up (W-01 DoD 5): the root moves into a modal <dialog> for `display="popup"`. */
568
+ place() {
569
+ if (!this.built)
570
+ return;
571
+ const popup = this.getAttribute('display') === 'popup';
572
+ const dialog = this.q.dialog, root = this.q.root;
573
+ if (popup) {
574
+ if (root.parentNode !== dialog)
575
+ dialog.append(root);
576
+ if (this.hasAttribute('open') && !dialog.open && this.isConnected) {
577
+ this.returnFocus = document.activeElement ?? undefined;
578
+ try {
579
+ dialog.showModal();
580
+ }
581
+ catch {
582
+ dialog.setAttribute('open', '');
583
+ }
584
+ requestAnimationFrame(() => this.q.grid.focus({ preventScroll: true }));
585
+ }
586
+ else if (!this.hasAttribute('open') && dialog.open)
587
+ this.closeDialog(false);
588
+ }
589
+ else {
590
+ if (dialog.open)
591
+ dialog.close();
592
+ if (root.parentNode !== this.shadow)
593
+ this.shadow.append(root);
594
+ }
595
+ this.q.close.hidden = !popup;
596
+ }
597
+ returnFocus;
598
+ closeDialog(fireEvent) {
599
+ const d = this.q.dialog;
600
+ if (d.open)
601
+ d.close();
602
+ if (this.hasAttribute('open'))
603
+ this.removeAttribute('open');
604
+ this.returnFocus?.focus?.();
605
+ if (fireEvent)
606
+ fire(this, 'sheetpane-close', {});
607
+ }
608
+ // ── events ───────────────────────────────────────────────────────────────
609
+ wire() {
610
+ const q = this.q;
611
+ const s = () => this._store;
612
+ q.dialog.addEventListener('cancel', (e) => { e.preventDefault(); if (!this.escape())
613
+ this.closeDialog(true); });
614
+ q.close.addEventListener('click', () => this.closeDialog(true));
615
+ q.modeseg.addEventListener('click', (e) => { const b = e.target.closest('button'); if (b)
616
+ s().setMode(b.dataset.mode, this); });
617
+ q.zoom.addEventListener('change', () => { this.zoom = Number(q.zoom.value) || 1; this.scrollToSel = true; this.schedule(); });
618
+ q.findin.addEventListener('input', () => { this.findText = q.findin.value; this.findVersion = -1; this.findIdx = -1; this.schedule(); });
619
+ q.findin.addEventListener('keydown', (e) => {
620
+ if (e.key === 'Enter') {
621
+ e.preventDefault();
622
+ this.findNext(e.shiftKey ? -1 : 1);
623
+ }
624
+ if (e.key === 'Escape') {
625
+ q.findin.value = '';
626
+ this.findText = '';
627
+ this.findHits = [];
628
+ this.schedule();
629
+ q.grid.focus({ preventScroll: true });
630
+ e.stopPropagation();
631
+ }
632
+ });
633
+ q.undo.addEventListener('click', () => s().undo(this));
634
+ q.redo.addEventListener('click', () => s().redo(this));
635
+ q.freeze.addEventListener('click', () => this.toggleFreeze());
636
+ q.export.addEventListener('click', () => void this.downloadFile());
637
+ q.dlmore.addEventListener('click', () => { this.dlOpen = !this.dlOpen; this.schedule(); if (this.dlOpen)
638
+ requestAnimationFrame(() => this.q.dlslot.querySelector('[role^=menuitem]')?.focus()); });
639
+ q.dlslot.addEventListener('click', (e) => {
640
+ const t = e.target.closest('[data-fmt], [data-opt]');
641
+ if (!t)
642
+ return;
643
+ if (t.dataset.opt) {
644
+ this.exportChoice[t.dataset.opt] = !this.exportChoices()[t.dataset.opt];
645
+ this.schedule();
646
+ return;
647
+ } // a checkbox keeps the menu open
648
+ this.dlOpen = false;
649
+ this.schedule();
650
+ void this.downloadFile(t.dataset.fmt);
651
+ this.q.dlmore.focus();
652
+ });
653
+ q.dlslot.addEventListener('keydown', (e) => this.onMenuKey(e, '.dlmenu'));
654
+ q.root.addEventListener('pointerdown', (e) => { if (this.dlOpen && !e.target.closest('.split')) {
655
+ this.dlOpen = false;
656
+ this.schedule();
657
+ } });
658
+ q.dock.addEventListener('click', (e) => this.onDockClick(e));
659
+ q.dock.addEventListener('keydown', (e) => this.onMenuKey(e, '.moremenu'));
660
+ q.help.addEventListener('click', () => { this.helpOpen = !this.helpOpen; q.help.setAttribute('aria-expanded', String(this.helpOpen)); this.schedule(); });
661
+ q.revert.addEventListener('click', () => { const sel = s().getSnapshot().selection; s().revert(sel.sheet, sel.ref, this); });
662
+ // The cell bar edits a draft; Enter, Tab and blur commit it (W-26, events verdict decisions 1–3).
663
+ const cell = q.cell;
664
+ cell.addEventListener('focus', () => {
665
+ const sel = s().getSnapshot().selection;
666
+ const allowed = fire(this, 'sheetpane-editstart', { sheet: sel.sheet, ref: sel.ref, column: this.column(sel.sheet, sel.ref), value: s().value(sel.sheet, sel.ref) }, true);
667
+ if (!allowed) {
668
+ cell.readOnly = true;
669
+ this.editing = undefined;
670
+ queueMicrotask(() => q.grid.focus({ preventScroll: true }));
671
+ return;
672
+ }
673
+ cell.readOnly = false;
674
+ this.editing = { sheet: sel.sheet, ref: sel.ref, start: cell.value };
675
+ this.schedule(); // the docked actions step aside while the draft is edited
676
+ });
677
+ cell.addEventListener('input', () => {
678
+ const ed = this.editing;
679
+ if (!ed)
680
+ return;
681
+ fire(this, 'sheetpane-input', { sheet: ed.sheet, ref: ed.ref, column: this.column(ed.sheet, ed.ref), draft: cell.value, was: s().value(ed.sheet, ed.ref) });
682
+ this.checkDraft(ed.sheet, ed.ref, cell.value);
683
+ });
684
+ cell.addEventListener('keydown', (e) => {
685
+ if (e.key === 'Enter' || e.key === 'Tab') {
686
+ e.preventDefault();
687
+ this.commitDraft();
688
+ this.move(e.key === 'Tab' ? 0 : e.shiftKey ? -1 : 1, e.key === 'Tab' ? (e.shiftKey ? -1 : 1) : 0, false);
689
+ q.grid.focus({ preventScroll: true });
690
+ }
691
+ else if (e.key === 'Escape') {
692
+ e.preventDefault();
693
+ e.stopPropagation();
694
+ const ed = this.editing;
695
+ if (ed) {
696
+ cell.value = ed.start;
697
+ this.editing = undefined;
698
+ this.clearRuleMsg();
699
+ fire(this, 'sheetpane-editcancel', { sheet: ed.sheet, ref: ed.ref });
700
+ }
701
+ q.grid.focus({ preventScroll: true });
702
+ }
703
+ });
704
+ cell.addEventListener('blur', () => { this.commitDraft(); this.schedule(); });
705
+ q.grid.addEventListener('paste', (e) => this.onPaste(e));
706
+ q.tabs.addEventListener('click', (e) => {
707
+ const t = e.target;
708
+ if (t.closest('[data-act=hidden-sheets]')) {
709
+ this.hiddenMenuOpen = !this.hiddenMenuOpen;
710
+ this.schedule();
711
+ if (this.hiddenMenuOpen)
712
+ requestAnimationFrame(() => this.q.hidmenuslot.querySelector('.hidmenu [role=menuitem]')?.focus());
713
+ return;
714
+ }
715
+ const b = t.closest('[data-sheet]');
716
+ if (b) {
717
+ s().select({ sheet: b.dataset.sheet, ref: 'A1' }, this);
718
+ this.q.scroller.scrollTo(0, 0);
719
+ }
720
+ });
721
+ const openTabMenu = (tab) => {
722
+ const sheet = tab.dataset.sheet;
723
+ if (!this.pluginMenuItems('tab', { sheet, ref: 'A1' }))
724
+ return false; // no plugin items: the browser's menu stays
725
+ this.tabMenu = { sheet, x: tab.offsetLeft };
726
+ this.hiddenMenuOpen = false;
727
+ this.schedule();
728
+ requestAnimationFrame(() => this.q.hidmenuslot.querySelector('.tabmenu [role=menuitem]:not([disabled])')?.focus());
729
+ return true;
730
+ };
731
+ q.tabs.addEventListener('contextmenu', (e) => { const tab = e.target.closest('[data-sheet]'); if (tab && openTabMenu(tab))
732
+ e.preventDefault(); });
733
+ q.tabs.addEventListener('keydown', (e) => { const tab = e.target.closest('[data-sheet]'); if (tab && ((e.key === 'F10' && e.shiftKey) || e.key === 'ContextMenu') && openTabMenu(tab))
734
+ e.preventDefault(); });
735
+ q.hidmenuslot.addEventListener('click', (e) => {
736
+ const ctl = e.target.closest('[data-control][data-menu=tab]');
737
+ if (ctl && this.tabMenu) {
738
+ const st = s(), c = st.plugins.flatMap((p) => p.controls ?? []).find((x) => x.id === ctl.dataset.control), sel = { sheet: this.tabMenu.sheet, ref: 'A1' };
739
+ this.tabMenu = undefined;
740
+ if (c && !c.disabled?.(st.getSnapshot(), sel))
741
+ c.run?.(st, sel);
742
+ this.schedule();
743
+ this.q.grid.focus({ preventScroll: true });
744
+ return;
745
+ }
746
+ const open = e.target.closest('[data-open-sheet]');
747
+ if (!open)
748
+ return;
749
+ this.hiddenMenuOpen = false;
750
+ this.openedSheets.add(open.dataset.openSheet);
751
+ s().select({ sheet: open.dataset.openSheet, ref: 'A1' }, this);
752
+ this.q.scroller.scrollTo(0, 0);
753
+ this.q.grid.focus({ preventScroll: true });
754
+ });
755
+ q.hidmenuslot.addEventListener('keydown', (e) => { if (this.onMenuKey(e, '.tabmenu')) {
756
+ this.tabMenu = undefined;
757
+ this.schedule();
758
+ return;
759
+ } if (this.onMenuKey(e, '.hidmenu')) {
760
+ this.hiddenMenuOpen = false;
761
+ this.schedule();
762
+ this.q.tabs.querySelector('[data-act=hidden-sheets]')?.focus();
763
+ } });
764
+ q.scroller.addEventListener('scroll', () => this.schedule(), { passive: true });
765
+ // W-46: mouse and pen drag with Pointer Events (captured, so the drag outlives the grid); a finger
766
+ // pans, and its tap arrives as the compatibility mousedown — as do synthetic clicks
767
+ q.scroller.addEventListener('pointerdown', (e) => this.onPointerDown(e));
768
+ q.scroller.addEventListener('pointermove', (e) => { if (this.rz) {
769
+ this.moveResize(e);
770
+ return;
771
+ } if (this.drag && e.pointerId === this.drag.id) {
772
+ this.drag.x = e.clientX;
773
+ this.drag.y = e.clientY;
774
+ this.queueDrag();
775
+ } });
776
+ q.scroller.addEventListener('pointerup', (e) => { if (this.rz && e.pointerId === this.rz.id) {
777
+ this.endResize(true);
778
+ return;
779
+ } if (this.drag && e.pointerId === this.drag.id)
780
+ this.endDrag(true); });
781
+ q.scroller.addEventListener('pointercancel', (e) => { if (this.rz && e.pointerId === this.rz.id) {
782
+ this.endResize(false);
783
+ return;
784
+ } if (this.drag && e.pointerId === this.drag.id)
785
+ this.endDrag(false); });
786
+ q.scroller.addEventListener('lostpointercapture', (e) => { if (this.rz && e.pointerId === this.rz.id) {
787
+ this.endResize(true);
788
+ return;
789
+ } if (this.drag && e.pointerId === this.drag.id)
790
+ this.endDrag(true); });
791
+ q.scroller.addEventListener('mousedown', (e) => this.onPointer(e));
792
+ q.scroller.addEventListener('click', (e) => {
793
+ const t = e.target;
794
+ const m = t.closest('[data-hid]');
795
+ if (m) {
796
+ e.preventDefault();
797
+ this.onHiddenMarker(m);
798
+ return;
799
+ }
800
+ const og = t.closest('[data-og]');
801
+ if (og) {
802
+ const [axis, span, open] = og.dataset.og.split(':');
803
+ const [a, b] = span.split('-').map(Number);
804
+ this.toggleGroup(axis, a, b, open === '0');
805
+ return;
806
+ }
807
+ const lv = t.closest('[data-olvl]');
808
+ if (lv) {
809
+ const [axis, n] = lv.dataset.olvl.split(':');
810
+ this.showLevel(axis, Number(n));
811
+ }
812
+ });
813
+ q.hidbar.addEventListener('click', (e) => this.onHidbar(e));
814
+ q.status.addEventListener('click', (e) => {
815
+ if (!e.target.closest('[data-act=show-hidden]'))
816
+ return;
817
+ const sheet = this._store.getSnapshot().selection.sheet, rev = this.revealed[sheet];
818
+ if (rev?.all)
819
+ this.unreveal(sheet);
820
+ else {
821
+ this.revealed[sheet] = { rows: rev?.rows ?? new Set(), cols: rev?.cols ?? new Set(), all: true };
822
+ this.revealVer++;
823
+ this.schedule();
824
+ this.q.selsay.textContent = 'Hidden rows and columns shown';
825
+ }
826
+ });
827
+ // right-click on a cell: select it and open the selection actions under it (the browser's menu stays everywhere else)
828
+ q.scroller.addEventListener('contextmenu', (e) => {
829
+ const head = this.headerHit(e.target);
830
+ if (head && head.kind !== 'all') {
831
+ // a header: select it unless it is already part of a whole selection, then its size menu
832
+ e.preventDefault();
833
+ const targets = this.sizeTargets(head.kind, head.index);
834
+ if (targets.length === 1)
835
+ this.selectHeader(head, false);
836
+ this.openHeaderMenu(head.kind, this.sizeTargets(head.kind, head.index));
837
+ return;
838
+ }
839
+ const c = e.target.closest('.c');
840
+ if (!c || c.dataset.r === undefined)
841
+ return;
842
+ e.preventDefault();
843
+ this.selectCell(Number(c.dataset.r), Number(c.dataset.c), false);
844
+ this.openContextMenu();
845
+ });
846
+ q.scroller.addEventListener('dblclick', (e) => {
847
+ const edge = e.target.closest('[data-rz-c], [data-rz-r]');
848
+ if (edge) {
849
+ const axis = edge.dataset.rzC !== undefined ? 'col' : 'row';
850
+ this.fit(axis, this.sizeTargets(axis, Number(axis === 'col' ? edge.dataset.rzC : edge.dataset.rzR)));
851
+ return;
852
+ }
853
+ if (e.target.closest('.c') && this.mode === 'edit')
854
+ this.editCell();
855
+ });
856
+ q.grid.addEventListener('keydown', (e) => this.onKey(e));
857
+ q.overlay.addEventListener('click', (e) => this.onOverlayClick(e));
858
+ q.overlay.addEventListener('keydown', (e) => {
859
+ if (this.onSizeKey(e))
860
+ return;
861
+ if (this.onMenuKey(e, '.ctxmenu'))
862
+ return;
863
+ const opt = e.target.closest?.('.menu [data-act=pick-option]');
864
+ if (opt && (e.key === 'ArrowDown' || e.key === 'ArrowUp')) {
865
+ e.preventDefault();
866
+ (e.key === 'ArrowDown' ? opt.nextElementSibling : opt.previousElementSibling)?.focus();
867
+ return;
868
+ }
869
+ if (opt && e.key === 'Escape') {
870
+ e.stopPropagation();
871
+ this.menu = undefined;
872
+ this.schedule();
873
+ this.q.grid.focus({ preventScroll: true });
874
+ return;
875
+ }
876
+ if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') {
877
+ e.preventDefault();
878
+ this.addFromComposer();
879
+ }
880
+ if (e.key === 'Escape') {
881
+ e.stopPropagation();
882
+ s().compose(null);
883
+ this.q.grid.focus({ preventScroll: true });
884
+ }
885
+ });
886
+ q.overlay.addEventListener('input', (e) => this.onComposerInput(e));
887
+ q.overlay.addEventListener('change', (e) => this.onComposerInput(e));
888
+ q.plugFrow.addEventListener('click', (e) => this.runControl(e));
889
+ q.plugCellbar.addEventListener('click', (e) => this.runControl(e));
890
+ for (const box of [q.plugFrow, q.plugCellbar, q.overlay])
891
+ box.addEventListener('change', (e) => this.chooseControl(e));
892
+ q.frow.querySelector('slot').addEventListener('slotchange', () => this.schedule());
893
+ // the empty state: drop a file, browse, open a link, paste base64
894
+ q.state.addEventListener('click', (e) => {
895
+ const t = e.target;
896
+ if (t.closest('.browse'))
897
+ q.picker.click();
898
+ if (t.closest('.openlink')) {
899
+ const v = q.state.querySelector('.linkin').value.trim();
900
+ if (v)
901
+ void this.load(v);
902
+ }
903
+ if (t.closest('.openb64')) {
904
+ const v = q.state.querySelector('.b64in').value.trim();
905
+ if (v)
906
+ void this.load(v);
907
+ }
908
+ if (t.closest('.again')) {
909
+ this.showEmpty = true;
910
+ this.schedule();
911
+ }
912
+ });
913
+ q.picker.addEventListener('change', () => { const f = q.picker.files?.[0]; if (f) {
914
+ this.showEmpty = false;
915
+ void this.load(f);
916
+ } q.picker.value = ''; });
917
+ q.state.addEventListener('dragover', (e) => { e.preventDefault(); q.state.querySelector('.drop')?.classList.add('over'); });
918
+ q.state.addEventListener('dragleave', () => q.state.querySelector('.drop')?.classList.remove('over'));
919
+ q.state.addEventListener('drop', (e) => { e.preventDefault(); const f = e.dataTransfer?.files?.[0]; if (f) {
920
+ this.showEmpty = false;
921
+ void this.load(f);
922
+ } });
923
+ }
924
+ showEmpty = false;
925
+ helpOpen = false;
926
+ editing;
927
+ /** The column a payload names: index, letter and the title from the header row (`header-row`, default 1). */
928
+ column(sheet, ref) {
929
+ const p = parseRef(ref);
930
+ const hr = Math.max(1, Number(this.getAttribute('header-row') ?? 1) || 1);
931
+ return { index: p.col, letter: colName(p.col), header: this._store?.text(sheet, cellRef(hr - 1, p.col)).trim() ?? '' };
932
+ }
933
+ /** `sheetpane-beforechange` (cancellable) for one cell; the value it would store is `now`. Then a column rule set to `block` (W-27). */
934
+ allowChange(sheet, ref, now, text = now === null || now === undefined ? '' : String(now)) {
935
+ const s = this._store;
936
+ if (!fire(this, 'sheetpane-beforechange', { sheet, ref, column: this.column(sheet, ref), was: s.value(sheet, ref), now }, true))
937
+ return false;
938
+ const b = s.ruleBlocks(sheet, ref, now, text);
939
+ if (!b)
940
+ return true;
941
+ // refused: the value is never stored (L6); the message stays at the cell until the selection moves
942
+ this.ruleMsg = { sheet, ref, outcome: b, blocked: true };
943
+ fire(this, 'sheetpane-editcancel', { sheet, ref, reason: 'blocked', code: 'SP-6003', message: b.message });
944
+ this.schedule();
945
+ return false;
946
+ }
947
+ // ── column rules: the input phase (an outline and a tooltip, nothing stored) ──
948
+ ruleMsg;
949
+ ruleTimer;
950
+ ruleAbort;
951
+ checkDraft(sheet, ref, draft) {
952
+ clearTimeout(this.ruleTimer);
953
+ this.ruleAbort?.abort();
954
+ const s = this._store;
955
+ if (!s?.columnRules.columns || (!Object.keys(s.columnRules.columns).length && !s.columnRules.validate))
956
+ return;
957
+ this.ruleTimer = setTimeout(() => {
958
+ const ac = (this.ruleAbort = new AbortController());
959
+ void s.checkInput(sheet, ref, draft, ac.signal).then((o) => {
960
+ if (ac.signal.aborted)
961
+ return;
962
+ this.ruleMsg = o ? { sheet, ref, outcome: o } : undefined;
963
+ this.schedule();
964
+ });
965
+ }, 150);
966
+ }
967
+ clearRuleMsg() { clearTimeout(this.ruleTimer); this.ruleAbort?.abort(); if (this.ruleMsg) {
968
+ this.ruleMsg = undefined;
969
+ this.schedule();
970
+ } }
971
+ /** Commits the cell bar's draft: beforechange, then the store (change map → undo → derive → validate → sheetpane-change). */
972
+ commitDraft() {
973
+ const ed = this.editing, s = this._store;
974
+ this.editing = undefined;
975
+ if (!ed || !s)
976
+ return;
977
+ const cell = this.q.cell;
978
+ const draft = cell.value;
979
+ if (draft === ed.start)
980
+ return;
981
+ const book = s.book;
982
+ const now = book ? parseInput(draft, s.original(ed.sheet, ed.ref).value, book.source) : draft;
983
+ clearTimeout(this.ruleTimer);
984
+ this.ruleAbort?.abort();
985
+ if (!this.allowChange(ed.sheet, ed.ref, now, draft)) {
986
+ cell.value = ed.start;
987
+ return;
988
+ }
989
+ if (this.ruleMsg && !this.ruleMsg.blocked)
990
+ this.ruleMsg = undefined;
991
+ s.setCell(ed.sheet, ed.ref, draft, { origin: this });
992
+ }
993
+ /** Paste (Edit): tab- and line-separated text from the active cell, beforechange per cell, one undo step. */
994
+ onPaste(e) {
995
+ const s = this._store, snap = s.getSnapshot();
996
+ if (snap.mode !== 'edit' || snap.load.status !== 'ready')
997
+ return;
998
+ const text = e.clipboardData?.getData('text/plain');
999
+ if (text === undefined)
1000
+ return;
1001
+ e.preventDefault();
1002
+ const rows = text.replace(/\r\n?/g, '\n').replace(/\n$/, '').split('\n').map((r) => r.split('\t'));
1003
+ const p = parseRef(snap.selection.ref);
1004
+ const book = s.book;
1005
+ const cells = [];
1006
+ const over = snap.selection.range ? parseRange(snap.selection.range) : undefined;
1007
+ if (over && rows.length === 1 && rows[0].length === 1) {
1008
+ // one value pasted over a range fills every cell of it (W-46 DoD 7)
1009
+ if (cellCount(over) > 1_000_000) {
1010
+ this.say0('Nothing pasted — the range is over 1,000,000 cells');
1011
+ return;
1012
+ }
1013
+ const v = rows[0][0];
1014
+ const gq = this.geometry().g;
1015
+ for (let row = over.top; row <= over.bottom; row++)
1016
+ for (let col = over.left; col <= over.right; col++) {
1017
+ if (!this.shownRow(gq, row) || !this.shownCol(gq, col))
1018
+ continue; // hidden cells are left alone (W-49)
1019
+ const ref = cellRef(row, col);
1020
+ if (this.allowChange(snap.selection.sheet, ref, parseInput(v, s.original(snap.selection.sheet, ref).value, book.source), v))
1021
+ cells.push({ sheet: snap.selection.sheet, ref, input: v });
1022
+ }
1023
+ s.setCells(cells, { origin: this });
1024
+ return;
1025
+ }
1026
+ rows.forEach((row, dr) => row.forEach((v, dc) => {
1027
+ const ref = cellRef(p.row + dr, p.col + dc);
1028
+ if (this.allowChange(snap.selection.sheet, ref, parseInput(v, s.original(snap.selection.sheet, ref).value, book.source), v))
1029
+ cells.push({ sheet: snap.selection.sheet, ref, input: v });
1030
+ }));
1031
+ s.setCells(cells, { origin: this });
1032
+ }
1033
+ // ── selection actions (W-31): docked in the cell bar, and on right-click ──
1034
+ ctxOpen = false;
1035
+ moreOpen = false;
1036
+ notice = '';
1037
+ noticeTimer;
1038
+ /** Copies to the clipboard; without a secure context it says so in the status line. */
1039
+ copy(text) {
1040
+ const fail = () => { this.notice = 'Copy needs a secure page (https or localhost)'; clearTimeout(this.noticeTimer); this.noticeTimer = setTimeout(() => { this.notice = ''; this.schedule(); }, 4000); this.schedule(); };
1041
+ if (!navigator.clipboard?.writeText) {
1042
+ fail();
1043
+ return;
1044
+ }
1045
+ navigator.clipboard.writeText(text).catch(fail);
1046
+ }
1047
+ /** Copy ref: `B3`, `B3:D8`, `B:D`, `4:12`, or the sheet's name (W-46 DoD 4). */
1048
+ refOf(sel) {
1049
+ const r = sel.range ? parseRange(sel.range) : undefined, g = this.geometry();
1050
+ return r && g ? refLabel(r, { rows: g.g.rows, cols: g.g.cols }, sel.sheet) : sel.ref;
1051
+ }
1052
+ openContextMenu() {
1053
+ const s = this._store;
1054
+ if (!s || s.getSnapshot().load.status !== 'ready' || s.getSnapshot().composing)
1055
+ return;
1056
+ this.menu = undefined;
1057
+ this.moreOpen = false;
1058
+ this.ctxOpen = true;
1059
+ this.schedule();
1060
+ requestAnimationFrame(() => this.q.overlay.querySelector('.ctxmenu [role=menuitem]:not([disabled])')?.focus({ preventScroll: true }));
1061
+ }
1062
+ /** The actions as menu items: Flag issue (Edit), Copy ref, Copy value, then the `selbar` plugin controls. */
1063
+ actionItems(edit, err, o) {
1064
+ const s = this._store, snap = s.getSnapshot();
1065
+ const item = (act, label, kbd = '') => `<button type="button" role="menuitem" class="mi" data-act="${act}">${esc(label)}${kbd ? `<span class="kbd">${esc(kbd)}</span>` : ''}</button>`;
1066
+ const out = [o.flag && edit && !err && s.flagging ? item('flag', 'Flag issue', 'F') : '', item('copy-ref', 'Copy ref'), item('copy-value', snap.selection.range ? 'Copy values' : 'Copy value', 'Ctrl/⌘+C')];
1067
+ if (o.plugins && edit)
1068
+ for (const c of controlsFor(s.plugins, 'selbar', 'edit').filter((x) => !x.menus || x.menus.includes('cell'))) {
1069
+ const label = c.labelFor?.(snap, snap.selection) ?? c.label;
1070
+ const off = c.disabled?.(snap, snap.selection) ?? false;
1071
+ out.push(`<button type="button" role="menuitem" class="mi fold" data-control="${esc(c.id)}"${off ? ' disabled' : ''}>${esc(label)}${c.shortcut ? `<span class="kbd">${esc(c.shortcut)}</span>` : ''}</button>`);
1072
+ }
1073
+ return out.join('');
1074
+ }
1075
+ contextMenuHtml(x, y, edit, err) {
1076
+ return `<div class="card ctxmenu" role="menu" aria-label="Selection actions" data-sp="menu" style="top:${y}px;left:${x}px">${this.actionItems(edit, err, { flag: true, plugins: true })}</div>`;
1077
+ }
1078
+ /** Arrow keys move through a menu's items; Esc closes it and returns focus. True when handled. */
1079
+ onMenuKey(e, sel) {
1080
+ const menu = e.target.closest?.(sel);
1081
+ if (!menu)
1082
+ return false;
1083
+ const items = [...menu.querySelectorAll('[role=menuitem]:not([disabled])')].filter((x) => x.offsetParent !== null);
1084
+ const i = items.indexOf(e.target);
1085
+ if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
1086
+ e.preventDefault();
1087
+ items[(i + (e.key === 'ArrowDown' ? 1 : -1) + items.length) % items.length]?.focus();
1088
+ return true;
1089
+ }
1090
+ if (e.key === 'Home' || e.key === 'End') {
1091
+ e.preventDefault();
1092
+ items[e.key === 'Home' ? 0 : items.length - 1]?.focus();
1093
+ return true;
1094
+ }
1095
+ if (e.key === 'Escape' || e.key === 'Tab') {
1096
+ e.preventDefault();
1097
+ e.stopPropagation();
1098
+ this.ctxOpen = false;
1099
+ this.moreOpen = false;
1100
+ this.dlOpen = false;
1101
+ this.hdrMenu = undefined;
1102
+ this.schedule();
1103
+ if (sel === '.moremenu')
1104
+ this.q.dock.querySelector('[data-act=more]')?.focus();
1105
+ else if (sel === '.dlmenu')
1106
+ this.q.dlmore.focus();
1107
+ else
1108
+ this.q.grid.focus({ preventScroll: true });
1109
+ return true;
1110
+ }
1111
+ return false;
1112
+ }
1113
+ onDockClick(e) {
1114
+ const t = e.target, s = this._store, sel = s.getSnapshot().selection;
1115
+ const act = t.closest('[data-act]')?.dataset.act;
1116
+ if (act === 'more') {
1117
+ this.moreOpen = !this.moreOpen;
1118
+ this.schedule();
1119
+ if (this.moreOpen)
1120
+ requestAnimationFrame(() => this.q.dock.querySelector('.moremenu [role=menuitem]')?.focus());
1121
+ return;
1122
+ }
1123
+ if (t.closest('.moremenu'))
1124
+ this.moreOpen = false;
1125
+ if (act === 'flag')
1126
+ this.flag();
1127
+ else if (act === 'copy-ref')
1128
+ this.copy(this.refOf(sel));
1129
+ else if (act === 'copy-value')
1130
+ this.copySelection();
1131
+ else if (t.closest('[data-control]'))
1132
+ this.runControl(e);
1133
+ this.schedule();
1134
+ }
1135
+ /** Untouched: no change, and nothing a plugin would write (a review exported as comments). */
1136
+ untouched() {
1137
+ const s = this._store;
1138
+ return s.changeCount === 0 && !(s.getSnapshot().errors.length && s.plugins.some((p) => p.provides.includes('exportPatch')));
1139
+ }
1140
+ // ── Download (W-31, W-34): the main part in the upload's format; ▾ for either format and plugin options ──
1141
+ dlOpen = false;
1142
+ exportChoice = {};
1143
+ /** Each plugin export option, its default unless the reviewer flipped it in the menu. */
1144
+ exportChoices() {
1145
+ const out = {};
1146
+ for (const o of (this._store?.plugins ?? []).flatMap((p) => p.exportOptions ?? []))
1147
+ out[o.key] = this.exportChoice[o.key] ?? o.default;
1148
+ return out;
1149
+ }
1150
+ /** The upload's format: the original bytes when untouched, else `export()` with every change. Another format converts. All fire `sheetpane-export`. */
1151
+ async downloadFile(format) {
1152
+ const s = this._store, src = s?.getSnapshot().source;
1153
+ if (!s || !src)
1154
+ return;
1155
+ const fmt = format ?? src.kind;
1156
+ if (fmt === src.kind && this.untouched()) {
1157
+ const blob = new Blob([src.bytes], { type: src.kind === 'csv' ? 'text/csv' : 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' });
1158
+ if (fire(this, 'sheetpane-export', { blob, kind: src.kind, name: src.name, guarded: false, original: true, format: fmt, converted: false }, true))
1159
+ download(src.bytes, src.name);
1160
+ return;
1161
+ }
1162
+ const blob = await this.export(this.exportChoices(), { format: fmt });
1163
+ if (this.lastExport?.ok)
1164
+ download(blob, this.lastExport.name);
1165
+ }
1166
+ downloadMenuHtml(kind) {
1167
+ const order = kind === 'xlsx' ? ['xlsx', 'csv'] : ['csv', 'xlsx'];
1168
+ const note = (f) => (f === kind ? '' : f === 'csv' ? 'The current sheet only — values as shown now, no formatting' : 'One sheet with the latest values');
1169
+ const items = order.map((f) => `<button type="button" role="menuitem" class="mi" data-fmt="${f}"><span>Download as .${f}</span>${f === kind ? '<span class="kbd">default</span>' : ''}</button>${note(f) ? `<span class="mnote">${note(f)}</span>` : ''}`).join('');
1170
+ const choices = this.exportChoices();
1171
+ const opts = (this._store?.plugins ?? []).flatMap((p) => p.exportOptions ?? []);
1172
+ const optHtml = opts.length ? `<div role="separator" class="msep"></div>${opts.map((o) => `<button type="button" role="menuitemcheckbox" class="mi" data-opt="${esc(o.key)}" aria-checked="${choices[o.key]}"><span>${choices[o.key] ? '✓ ' : ''}${esc(o.label)}</span></button>`).join('')}` : '';
1173
+ return `<div class="card dlmenu" role="menu" aria-label="Download format">${items}${optHtml}</div>`;
1174
+ }
1175
+ /** The grid's keys, then every plugin control's shortcut (W-14 DoD 8). */
1176
+ shortcuts() {
1177
+ const own = [
1178
+ ['Arrows', 'Move'], ['Shift + arrows', 'Select a range'], ['Ctrl/⌘ + arrows', 'Jump to the edge of the data'], ['Ctrl/⌘ + Shift + arrows', 'Select to the edge of the data'],
1179
+ ['Ctrl + Space', 'Select the column'], ['Shift + Space', 'Select the row'], ['Ctrl/⌘ + A', 'Select the data, then the sheet'], ['Shift + Backspace', 'Back to the active cell'],
1180
+ ['Enter / Tab in a range', 'Next cell of the range (Shift: back)'], ['Drag, Shift + click', 'Select a range'], ['Click a header', 'Select a column, a row or the sheet'],
1181
+ ['Drag a header edge', 'Resize the column or row'], ['Double-click a header edge', 'Fit it to the text'], ['Shift + F10 on a whole column or row', 'Fit · width… · reset to the file’s size'],
1182
+ ['Home / End', 'Row start / end'], ['Ctrl + Home / End', 'First / last cell'],
1183
+ ['Page Up / Down', 'Move a screen'], ['Enter or F2', 'Edit the cell (Edit)'], ['Type', 'Replace the cell (Edit)'], ['Delete', 'Clear the cell or range (Edit)'],
1184
+ ['F', 'Flag issue (Edit)'], ['Esc', 'Cancel a drag or copy · close the pop-up'], ['Ctrl/⌘ + Z', 'Undo'], ['Ctrl + Y, ⌘ + Shift + Z', 'Redo'], ['Ctrl/⌘ + C', 'Copy the value or the range'], ['Shift + F10 or Menu', 'Selection actions'],
1185
+ ].map(([keys, what]) => ({ keys: keys, what: what }));
1186
+ const plug = (this._store?.plugins ?? []).flatMap((p) => p.controls ?? []).filter((c) => c.shortcut).map((c) => ({ keys: c.shortcut, what: c.label }));
1187
+ return [...(this._store && !this._store.flagging ? own.filter((x) => x.keys !== 'F') : own), ...plug];
1188
+ }
1189
+ /** Does a key event match a control's shortcut, e.g. `Mod+B`, `Ctrl+Shift+5`, `B`? */
1190
+ matchesShortcut(e, shortcut) {
1191
+ const parts = shortcut.split('+').map((x) => x.trim().toLowerCase());
1192
+ const key = parts.pop();
1193
+ const want = { mod: parts.includes('mod'), ctrl: parts.includes('ctrl'), meta: parts.includes('meta') || parts.includes('cmd'), shift: parts.includes('shift'), alt: parts.includes('alt') };
1194
+ const mod = want.mod ? e.ctrlKey || e.metaKey : (want.ctrl ? e.ctrlKey : !e.ctrlKey) && (want.meta ? e.metaKey : !e.metaKey);
1195
+ // a digit is matched on e.code: ⌥ and AltGr change e.key (⌘⌥9 is "ª" on a Mac) — W-49
1196
+ const keyOk = /^[0-9]$/.test(key) ? e.code === `Digit${key}` : key.startsWith('arrow') ? e.key.toLowerCase() === key : e.key.toLowerCase() === key;
1197
+ return mod && e.shiftKey === want.shift && e.altKey === want.alt && keyOk;
1198
+ }
1199
+ /** A click without a pointer sequence before it: a finger's tap, or a synthetic event. Selects; never drags. */
1200
+ onPointer(e) {
1201
+ const t = e.target;
1202
+ if (e.button !== 0)
1203
+ return;
1204
+ if (t.closest('[data-rz-c], [data-rz-r], [data-hid], [data-og], [data-olvl]'))
1205
+ return;
1206
+ const mk = t.closest('.mk');
1207
+ if (mk) {
1208
+ e.preventDefault();
1209
+ this.runCellControl(mk);
1210
+ return;
1211
+ }
1212
+ const head = this.headerHit(t);
1213
+ if (head) {
1214
+ e.preventDefault();
1215
+ this.q.grid.focus({ preventScroll: true });
1216
+ this.selectHeader(head, e.shiftKey);
1217
+ return;
1218
+ }
1219
+ const c = t.closest('.c');
1220
+ if (!c || c.dataset.r === undefined)
1221
+ return;
1222
+ e.preventDefault();
1223
+ this.q.grid.focus({ preventScroll: true });
1224
+ this.selectCell(Number(c.dataset.r), Number(c.dataset.c), e.shiftKey);
1225
+ }
1226
+ /** A column header, a row header or the corner under the pointer. */
1227
+ headerHit(t) {
1228
+ const h = t.closest('[data-hc], [data-hr], [data-hall]');
1229
+ if (!h)
1230
+ return undefined;
1231
+ if (h.dataset.hall !== undefined)
1232
+ return { kind: 'all', index: 0 };
1233
+ return h.dataset.hc !== undefined ? { kind: 'col', index: Number(h.dataset.hc) } : { kind: 'row', index: Number(h.dataset.hr) };
1234
+ }
1235
+ /** Mouse or pen: select, then follow the pointer until release (W-46 DoD 3). */
1236
+ onPointerDown(e) {
1237
+ const s = this._store;
1238
+ if (!s || s.getSnapshot().load.status !== 'ready')
1239
+ return;
1240
+ const t = e.target;
1241
+ const edge = t.closest('[data-rz-c], [data-rz-r]');
1242
+ if (edge) {
1243
+ if (e.button === 0)
1244
+ this.startResize(e, edge);
1245
+ return;
1246
+ } // W-47: a header edge resizes, never selects
1247
+ if (t.closest('[data-hid], [data-og], [data-olvl]'))
1248
+ return; // W-49: markers and outline buttons act on click
1249
+ const handle = t.closest('.sh');
1250
+ if (handle && e.button === 0) {
1251
+ this.startHandleDrag(e, handle.dataset.end === 'start');
1252
+ return;
1253
+ }
1254
+ if (e.pointerType === 'touch') {
1255
+ this.touchUi = true;
1256
+ return;
1257
+ } // a pan scrolls; the tap selects through mousedown
1258
+ this.touchUi = false;
1259
+ if (e.button !== 0 || t.closest('.overlay'))
1260
+ return;
1261
+ const mk = t.closest('.mk');
1262
+ if (mk) {
1263
+ e.preventDefault();
1264
+ this.runCellControl(mk);
1265
+ return;
1266
+ }
1267
+ const head = this.headerHit(t);
1268
+ const c = head ? undefined : t.closest('.c');
1269
+ if (!head && (!c || c.dataset.r === undefined))
1270
+ return;
1271
+ e.preventDefault(); // no text selection, and no compatibility mousedown after this
1272
+ this.q.grid.focus({ preventScroll: true });
1273
+ if (head?.kind === 'all') {
1274
+ this.selectHeader(head, false);
1275
+ return;
1276
+ }
1277
+ if (e.shiftKey) {
1278
+ if (head)
1279
+ this.selectHeader(head, true);
1280
+ else
1281
+ this.selectCell(Number(c.dataset.r), Number(c.dataset.c), true);
1282
+ return;
1283
+ }
1284
+ const snap = s.getSnapshot(), before = { ...snap.selection };
1285
+ if (head)
1286
+ this.selectHeader(head, false);
1287
+ else
1288
+ this.selectCell(Number(c.dataset.r), Number(c.dataset.c), false);
1289
+ const at = head ? (head.kind === 'col' ? { row: 0, col: head.index } : { row: head.index, col: 0 }) : { row: Number(c.dataset.r), col: Number(c.dataset.c) };
1290
+ this.drag = { id: e.pointerId, anchor: at, focus: at, kind: head ? head.kind : 'cell', before, x: e.clientX, y: e.clientY };
1291
+ try {
1292
+ this.q.scroller.setPointerCapture(e.pointerId);
1293
+ }
1294
+ catch { /* a synthetic pointer has nothing to capture */ }
1295
+ }
1296
+ /** A touch handle: extend the range from its other corner. */
1297
+ startHandleDrag(e, start) {
1298
+ const snap = this._store.getSnapshot(), sel = snap.selection;
1299
+ const r = sel.range ? parseRange(sel.range) : undefined;
1300
+ if (!r)
1301
+ return;
1302
+ e.preventDefault();
1303
+ e.stopPropagation();
1304
+ const fixed = start ? { row: r.bottom, col: r.right } : { row: r.top, col: r.left };
1305
+ const moving = start ? { row: r.top, col: r.left } : { row: r.bottom, col: r.right };
1306
+ this.anchor = fixed;
1307
+ this.drag = { id: e.pointerId, anchor: fixed, focus: moving, kind: 'cell', before: { ...sel }, x: e.clientX, y: e.clientY };
1308
+ try {
1309
+ this.q.scroller.setPointerCapture(e.pointerId);
1310
+ }
1311
+ catch { /* synthetic */ }
1312
+ }
1313
+ queueDrag() {
1314
+ if (!this.dragRaf)
1315
+ this.dragRaf = requestAnimationFrame(() => { this.dragRaf = 0; this.dragStep(); });
1316
+ }
1317
+ /** One frame of a drag: autoscroll at an edge, the cell under the pointer, the layer redrawn. */
1318
+ dragStep() {
1319
+ const d = this.drag, g = this.geometry();
1320
+ if (!d || !g)
1321
+ return;
1322
+ const sc = this.q.scroller, z = this.zoom, HH = this.headH() * z, RW = this.rowHeadW() * z;
1323
+ const fz = this._store.freeze(g.sheet.name), fr = Math.min(fz.rows, g.g.rows), fc = Math.min(fz.cols, g.g.cols);
1324
+ const rect = sc.getBoundingClientRect();
1325
+ const vx = d.x - rect.left - sc.clientLeft, vy = d.y - rect.top - sc.clientTop;
1326
+ const paneTop = HH + g.g.rowY[fr] * z, paneLeft = RW + g.g.colX[fc] * z;
1327
+ // autoscroll: within 24 px of the scrolling pane's edge, or past it — faster the further out
1328
+ const speed = (into) => Math.min(80, 4 + into * 0.8);
1329
+ let dy = 0, dx = 0;
1330
+ if (d.kind !== 'col') {
1331
+ if (vy > sc.clientHeight - 24)
1332
+ dy = speed(vy - (sc.clientHeight - 24));
1333
+ else if (vy < paneTop + 24 && sc.scrollTop > 0)
1334
+ dy = -speed(paneTop + 24 - vy);
1335
+ }
1336
+ if (d.kind !== 'row') {
1337
+ if (vx > sc.clientWidth - 24)
1338
+ dx = speed(vx - (sc.clientWidth - 24));
1339
+ else if (vx < paneLeft + 24 && sc.scrollLeft > 0)
1340
+ dx = -speed(paneLeft + 24 - vx);
1341
+ }
1342
+ if (dy)
1343
+ sc.scrollTop += dy;
1344
+ if (dx)
1345
+ sc.scrollLeft += dx;
1346
+ // the cell under the pointer: frozen panes do not scroll
1347
+ const sy = vy < paneTop && fr > 0 ? vy : vy + sc.scrollTop, sx = vx < paneLeft && fc > 0 ? vx : vx + sc.scrollLeft;
1348
+ const row = sy < HH ? 0 : rowAt(g.g, (sy - HH) / z);
1349
+ const col = Math.max(0, Math.min(g.g.cols - 1, colAt(g.g, Math.max(0, (sx - RW) / z))));
1350
+ d.focus = d.kind === 'col' ? { row: d.focus.row, col } : d.kind === 'row' ? { row, col: d.focus.col } : { row, col };
1351
+ this.drawSelection();
1352
+ if (dy || dx)
1353
+ this.queueDrag();
1354
+ }
1355
+ /** Release commits the range to the store once; a cancel (Esc, pointercancel) restores the selection from before. */
1356
+ endDrag(commit) {
1357
+ const d = this.drag;
1358
+ if (!d)
1359
+ return;
1360
+ if (this.dragRaf) {
1361
+ cancelAnimationFrame(this.dragRaf);
1362
+ this.dragRaf = 0;
1363
+ }
1364
+ if (commit)
1365
+ this.dragStep();
1366
+ this.drag = undefined;
1367
+ try {
1368
+ if (this.q.scroller.hasPointerCapture(d.id))
1369
+ this.q.scroller.releasePointerCapture(d.id);
1370
+ }
1371
+ catch { /* synthetic */ }
1372
+ const s = this._store;
1373
+ if (!commit) {
1374
+ s.select(d.before, this);
1375
+ this.drawSelection();
1376
+ return;
1377
+ }
1378
+ const sheet = s.getSnapshot().selection.sheet;
1379
+ const r = this.dragRange(d);
1380
+ const active = d.kind === 'cell' ? d.anchor : s.getSnapshot().selection.ref ? parseRef(s.getSnapshot().selection.ref) : d.anchor;
1381
+ this.anchor = d.anchor;
1382
+ const one = r.top === r.bottom && r.left === r.right;
1383
+ s.select(one ? { sheet, ref: cellRef(active.row, active.col) } : { sheet, ref: cellRef(active.row, active.col), range: rangeText(r) }, this);
1384
+ if (!one)
1385
+ this.say(r);
1386
+ this.drawSelection();
1387
+ }
1388
+ /** The range a drag spans: corners for cells (grown over merges), whole columns or rows for headers. */
1389
+ dragRange(d) {
1390
+ const g = this.geometry().g;
1391
+ if (d.kind === 'col')
1392
+ return { top: 0, bottom: g.rows - 1, left: Math.min(d.anchor.col, d.focus.col), right: Math.max(d.anchor.col, d.focus.col) };
1393
+ if (d.kind === 'row')
1394
+ return { left: 0, right: g.cols - 1, top: Math.min(d.anchor.row, d.focus.row), bottom: Math.max(d.anchor.row, d.focus.row) };
1395
+ return growForMerges(spanOf(d.anchor, d.focus), g.merges);
1396
+ }
1397
+ // ── hidden rows, columns and sheets (W-49): honoured, marked, revealed for viewing ──
1398
+ // ── outline groups (W-49 DoD 9): a gutter of brackets, +/− and level buttons; expand / collapse is a view setting ──
1399
+ /** Groups collapsed in this element only (view), per sheet. */
1400
+ viewHidden = {};
1401
+ gutter(axis) {
1402
+ const s = this._store, sheet = s?.getSnapshot().selection.sheet;
1403
+ if (!s || !sheet)
1404
+ return 0;
1405
+ const o = s.outline(sheet), n = axis === 'rows' ? o.levelRow : o.levelCol;
1406
+ return n ? 18 * (n + 1) : 0;
1407
+ }
1408
+ /** The row-header column's width at 100 %: the header, plus the row outline gutter. */
1409
+ rowHeadW() { return ROWHEAD_W + this.gutter('rows'); }
1410
+ /** The header row's height at 100 %: the header, plus the column outline gutter. */
1411
+ headH() { return HEAD_H + this.gutter('cols'); }
1412
+ /** The groups of an outline axis: runs of a level or deeper, `[level, first, last]`, around a window. */
1413
+ groupsOf(levels, deepest, from, to, count) {
1414
+ const out = [];
1415
+ for (let L = 1; L <= deepest; L++) {
1416
+ for (let i = Math.max(0, from); i <= Math.min(count - 1, to); i++) {
1417
+ if ((levels[i] ?? 0) < L)
1418
+ continue;
1419
+ let a = i, b = i;
1420
+ while (a > 0 && (levels[a - 1] ?? 0) >= L)
1421
+ a--;
1422
+ while (b < count - 1 && (levels[b + 1] ?? 0) >= L)
1423
+ b++;
1424
+ if (!out.some(([l, x]) => l === L && x === a))
1425
+ out.push([L, a, b]);
1426
+ i = b;
1427
+ }
1428
+ }
1429
+ return out;
1430
+ }
1431
+ /** Expands (shows) or collapses (hides, in view) a group's rows or columns. */
1432
+ toggleGroup(axis, a, b, expand) {
1433
+ const s = this._store, sheet = s.getSnapshot().selection.sheet;
1434
+ const v = (this.viewHidden[sheet] ??= { rows: new Set(), cols: new Set() });
1435
+ const rev = (this.revealed[sheet] ??= { rows: new Set(), cols: new Set(), all: false });
1436
+ const file = new Set(s.hidden(sheet)[axis]);
1437
+ for (let i = a; i <= b; i++) {
1438
+ if (expand) {
1439
+ v[axis].delete(i);
1440
+ if (file.has(i))
1441
+ rev[axis].add(i);
1442
+ }
1443
+ else {
1444
+ rev[axis].delete(i);
1445
+ if (!file.has(i))
1446
+ v[axis].add(i);
1447
+ }
1448
+ }
1449
+ this.revealVer++;
1450
+ this.schedule();
1451
+ const idx = Array.from({ length: b - a + 1 }, (_, i) => a + i);
1452
+ fire(this, 'sheetpane-visibility', { sheet, axis: axis === 'rows' ? 'row' : 'col', indexes: idx, hidden: !expand, via: 'reveal' });
1453
+ this.q.selsay.textContent = `${this.spanName(axis, idx)} ${expand ? 'shown' : 'hidden'}`;
1454
+ }
1455
+ /** Level n: groups deeper than n collapse, the others expand. */
1456
+ showLevel(axis, n) {
1457
+ const s = this._store, sheet = s.getSnapshot().selection.sheet, o = s.outline(sheet), g = this.geometry().g;
1458
+ const levels = axis === 'rows' ? o.rows : o.cols, deepest = axis === 'rows' ? o.levelRow : o.levelCol, count = axis === 'rows' ? g.rows : g.cols;
1459
+ for (const [L, a, b] of this.groupsOf(levels, deepest, 0, count - 1, count).sort((x, y) => x[0] - y[0]))
1460
+ this.toggleGroup(axis, a, b, L < n);
1461
+ }
1462
+ /** The outline gutter's brackets, +/− buttons and level buttons, for the rendered window. */
1463
+ outlineMarks(sheet, g, win) {
1464
+ const s = this._store, o = s.outline(sheet), z = this.zoom, HH = this.headH() * z, RW = this.rowHeadW() * z;
1465
+ if (!o.levelRow && !o.levelCol)
1466
+ return '';
1467
+ const X = (c) => g.colX[Math.min(c, g.cols)] * z, Y = (r) => rowTop(g, r) * z;
1468
+ let out = '';
1469
+ const shown = (edges, a, b) => { for (let i = a; i <= b; i++)
1470
+ if (!isHiddenAt(edges, i))
1471
+ return true; return false; };
1472
+ if (o.levelRow) {
1473
+ for (const [L, a, b] of this.groupsOf(o.rows, o.levelRow, win.r0 - 1, win.r1 + 1, g.rows)) {
1474
+ const open = shown(g.rowY, a, b), sum = o.summaryBelow ? Math.min(g.rows - 1, b + 1) : Math.max(0, a - 1), x = (L - 1) * 18 * z + 4;
1475
+ if (open)
1476
+ out += `<span class="obr obr-r" style="position:sticky;left:${x + 7}px;margin:${HH + Y(a)}px 0 0 ${x + 7}px;height:${Math.max(0, Y(b + 1) - Y(a))}px"></span>`;
1477
+ const label = `${open ? 'Collapse' : 'Expand'} rows ${a + 1}–${b + 1} (level ${L})`;
1478
+ out += `<button type="button" class="obtn" data-og="rows:${a}-${b}:${open ? 1 : 0}" style="position:sticky;left:${x}px;margin:${HH + Y(sum) + 4}px 0 0 ${x}px" aria-label="${label}" title="${label}">${open ? '−' : '+'}</button>`;
1479
+ }
1480
+ for (let n = 1; n <= o.levelRow + 1; n++)
1481
+ out += `<button type="button" class="obtn olvl" data-olvl="rows:${n}" style="position:sticky;top:2px;left:${(n - 1) * 18 * z + 4}px;margin:2px 0 0 ${(n - 1) * 18 * z + 4}px;z-index:7" aria-label="Show row outline level ${n}" title="Row level ${n}">${n}</button>`;
1482
+ }
1483
+ if (o.levelCol) {
1484
+ for (const [L, a, b] of this.groupsOf(o.cols, o.levelCol, win.c0 - 1, win.c1 + 1, g.cols)) {
1485
+ const open = shown(g.colX, a, b), sum = o.summaryRight ? Math.min(g.cols - 1, b + 1) : Math.max(0, a - 1), y = (L - 1) * 18 * z + 4;
1486
+ if (open)
1487
+ out += `<span class="obr obr-c" style="position:sticky;top:${y + 7}px;margin:${y + 7}px 0 0 ${RW + X(a)}px;width:${Math.max(0, X(b + 1) - X(a))}px"></span>`;
1488
+ const label = `${open ? 'Collapse' : 'Expand'} columns ${colName(a)}–${colName(b)} (level ${L})`;
1489
+ out += `<button type="button" class="obtn" data-og="cols:${a}-${b}:${open ? 1 : 0}" style="position:sticky;top:${y}px;margin:${y}px 0 0 ${RW + X(sum) + 4}px" aria-label="${label}" title="${label}">${open ? '−' : '+'}</button>`;
1490
+ }
1491
+ for (let n = 1; n <= o.levelCol + 1; n++)
1492
+ out += `<button type="button" class="obtn olvl" data-olvl="cols:${n}" style="position:sticky;top:${(n - 1) * 18 * z + 4}px;left:2px;margin:${(n - 1) * 18 * z + 4}px 0 0 2px;z-index:7" aria-label="Show column outline level ${n}" title="Column level ${n}">${n}</button>`;
1493
+ }
1494
+ return out;
1495
+ }
1496
+ /** Is a row (column) drawn — not hidden, or revealed? */
1497
+ shownRow = (g, r) => !isHiddenAt(g.rowY, r);
1498
+ shownCol = (g, c) => !isHiddenAt(g.colX, c);
1499
+ /** The nearest drawn row (column) from `i`, stepping `dir` first, then back (W-49 DoD 7). */
1500
+ nearestShown(edges, count, i, dir) {
1501
+ const d = dir < 0 ? -1 : 1;
1502
+ for (let j = i; j >= 0 && j < count; j += d)
1503
+ if (!isHiddenAt(edges, j))
1504
+ return j;
1505
+ for (let j = i; j >= 0 && j < count; j -= d)
1506
+ if (!isHiddenAt(edges, j))
1507
+ return j;
1508
+ return i;
1509
+ }
1510
+ /** How many cells of a range are hidden. */
1511
+ hiddenIn(g, r) {
1512
+ let hr = 0, hc = 0;
1513
+ for (let i = r.top; i <= Math.min(r.bottom, g.rows - 1); i++)
1514
+ if (isHiddenAt(g.rowY, i))
1515
+ hr++;
1516
+ for (let i = r.left; i <= Math.min(r.right, g.cols - 1); i++)
1517
+ if (isHiddenAt(g.colX, i))
1518
+ hc++;
1519
+ const rows = r.bottom - r.top + 1, cols = r.right - r.left + 1;
1520
+ return rows * cols - (rows - hr) * (cols - hc);
1521
+ }
1522
+ isRevealed(sheet, axis, i) {
1523
+ const rev = this.revealed[sheet];
1524
+ if (!rev || !this._store)
1525
+ return false;
1526
+ if (!(rev.all || rev[axis].has(i)))
1527
+ return false;
1528
+ return this._store.hidden(sheet)[axis].includes(i);
1529
+ }
1530
+ /** Whether a `visibility` plugin can change hiding here (Edit). */
1531
+ canUnhide() {
1532
+ const s = this._store;
1533
+ return s.getSnapshot().mode === 'edit' && s.plugins.some((p) => p.id === 'visibility') && s.getSnapshot().source?.kind === 'xlsx';
1534
+ }
1535
+ /**
1536
+ * Shows hidden rows or columns for viewing, in this element only — never a change (W-49).
1537
+ * @param sheet - The sheet's name.
1538
+ * @param axis - `rows` or `cols`.
1539
+ * @param indexes - The 0-based rows or columns.
1540
+ */
1541
+ reveal(sheet, axis, indexes) {
1542
+ const rev = (this.revealed[sheet] ??= { rows: new Set(), cols: new Set(), all: false });
1543
+ for (const i of indexes)
1544
+ rev[axis].add(i);
1545
+ this.revealVer++;
1546
+ this.schedule();
1547
+ fire(this, 'sheetpane-visibility', { sheet, axis: axis === 'rows' ? 'row' : 'col', indexes: [...indexes], hidden: false, via: 'reveal' });
1548
+ this.q.selsay.textContent = `${this.spanName(axis, indexes)} shown`;
1549
+ }
1550
+ /**
1551
+ * Hides again what this element revealed for viewing on a sheet (W-49).
1552
+ * @param sheet - The sheet's name.
1553
+ */
1554
+ unreveal(sheet) {
1555
+ delete this.revealed[sheet];
1556
+ if (this.revealNote?.sheet === sheet)
1557
+ this.revealNote = undefined;
1558
+ this.revealVer++;
1559
+ this.schedule();
1560
+ }
1561
+ spanName(axis, indexes) {
1562
+ let a = Infinity, b = -Infinity; // a loop, not Math.min(...): a list can hold 125,000 rows
1563
+ for (const i of indexes) {
1564
+ if (i < a)
1565
+ a = i;
1566
+ if (i > b)
1567
+ b = i;
1568
+ }
1569
+ const n = (i) => (axis === 'rows' ? String(i + 1) : colName(i));
1570
+ return a === b ? `${axis === 'rows' ? 'Row' : 'Column'} ${n(a)}` : `${axis === 'rows' ? 'Rows' : 'Columns'} ${n(a)} to ${n(b)}`;
1571
+ }
1572
+ /** The ◂▸ / ▴▾ markers and the double lines where hidden rows and columns sit (W-49 DoD 4). */
1573
+ hiddenMarks(sheet, g, win, fr, fc) {
1574
+ const z = this.zoom, HH = this.headH() * z, RW = this.rowHeadW() * z, s = this._store;
1575
+ const X = (c) => g.colX[Math.min(c, g.cols)] * z, Y = (r) => rowTop(g, r) * z;
1576
+ const errs = s.getSnapshot().errors.filter((e) => e.sheet === sheet && e.status === 'open');
1577
+ const inSpan = (axis, a, b) => {
1578
+ let worst, n = 0;
1579
+ for (const e of errs) {
1580
+ const r = parseRange(e.range);
1581
+ if (!r || (axis === 'rows' ? r.top > b || r.bottom < a : r.left > b || r.right < a))
1582
+ continue;
1583
+ n++;
1584
+ if (!worst || sevOrder[e.severity] > sevOrder[worst])
1585
+ worst = e.severity;
1586
+ }
1587
+ return worst ? { worst, n } : undefined;
1588
+ };
1589
+ const filter = s.sheet(sheet)?.autoFilter;
1590
+ const fr2 = filter ? parseRange(filter) : undefined;
1591
+ const label = (axis, a, b) => {
1592
+ const n = (i) => (axis === 'rows' ? String(i + 1) : colName(i));
1593
+ const what = a === b ? `${axis === 'rows' ? 'Row' : 'Column'} ${n(a)}` : `${axis === 'rows' ? 'Rows' : 'Columns'} ${n(a)}–${n(b)}`;
1594
+ const filtered = axis === 'rows' && fr2 && a >= fr2.top && b <= fr2.bottom ? ' filtered' : ' hidden';
1595
+ return `${what}${filtered}${a === b ? '' : ` (${b - a + 1})`} — click to show`;
1596
+ };
1597
+ const mark = (axis, a, b, style) => {
1598
+ const e = inSpan(axis, a, b), l = label(axis, a, b) + (e ? `; ${e.n} open error${e.n === 1 ? '' : 's'} inside` : '');
1599
+ return `<button type="button" class="hmk ${axis === 'rows' ? 'hmr' : 'hmc'}" data-hid="${axis}:${a}-${b}" style="${style}" aria-label="${esc(l)}" title="${esc(l)}">${axis === 'rows' ? '▴▾' : '◂▸'}${e ? `<span class="g g-${e.worst}" aria-hidden="true">${SEVERITY[e.worst].glyph}${e.n}</span>` : ''}</button>`;
1600
+ };
1601
+ let cols = '', layer = '';
1602
+ for (const [a, b] of hiddenSpans(g.colX, g.cols)) {
1603
+ if (b < win.c0 - 1 && a >= fc)
1604
+ continue;
1605
+ if (a > win.c1 + 1)
1606
+ continue;
1607
+ const x = RW + X(a);
1608
+ // in the layer beside the grid (a grid row owns only cells): sticky at the top with the headers
1609
+ layer += mark('cols', a, b, `position:sticky;top:3px;margin:3px 0 0 ${x - 11}px`);
1610
+ layer += `<span class="hline hv" style="margin:${HH}px 0 0 ${x - 2}px;height:${Y(g.rows)}px"></span>`;
1611
+ }
1612
+ // rows: only around the rendered window (every other row hidden must not cost every row)
1613
+ const rowSpans = [];
1614
+ const scan = (from, to) => {
1615
+ for (let r = Math.max(0, from); r <= Math.min(g.rows - 1, to); r++) {
1616
+ if (!isHiddenAt(g.rowY, r))
1617
+ continue;
1618
+ let a = r, b = r;
1619
+ while (a > 0 && isHiddenAt(g.rowY, a - 1))
1620
+ a--;
1621
+ while (b < g.rows - 1 && isHiddenAt(g.rowY, b + 1))
1622
+ b++;
1623
+ if (!rowSpans.some(([x]) => x === a))
1624
+ rowSpans.push([a, b]);
1625
+ r = b;
1626
+ }
1627
+ };
1628
+ scan(0, fr - 1);
1629
+ scan(win.r0 - 1, win.r1 + 1);
1630
+ for (const [a, b] of rowSpans) {
1631
+ const y = HH + Y(a);
1632
+ layer += `<span class="hline hh" style="margin:${y - 2}px 0 0 0;width:${RW + X(g.cols)}px"></span>`;
1633
+ layer += mark('rows', a, b, `position:sticky;left:${RW - 24}px;margin:${y - 9}px 0 0 ${RW - 24}px`);
1634
+ }
1635
+ return { cols, layer };
1636
+ }
1637
+ onHidbar(e) {
1638
+ const act = e.target.closest('[data-act]')?.dataset.act;
1639
+ const s = this._store, sheet = s.getSnapshot().selection.sheet;
1640
+ if (act === 'hide-sheet-again') {
1641
+ this.openedSheets.delete(sheet);
1642
+ const first = s.getSnapshot().book.sheets.find((x) => s.hidden(x.name).state === 'visible');
1643
+ if (first)
1644
+ s.select({ sheet: first.name, ref: 'A1' }, this);
1645
+ }
1646
+ else if (act === 'unhide-sheet') {
1647
+ s.setSheetState(sheet, 'visible', this);
1648
+ }
1649
+ else if (act === 'hide-again') {
1650
+ const n = this.revealNote, rev = this.revealed[sheet];
1651
+ if (n && rev) {
1652
+ for (const r of n.rows)
1653
+ rev.rows.delete(r);
1654
+ for (const c of n.cols)
1655
+ rev.cols.delete(c);
1656
+ }
1657
+ this.revealNote = undefined;
1658
+ this.revealVer++;
1659
+ }
1660
+ else if (act === 'note-close')
1661
+ this.revealNote = undefined;
1662
+ this.schedule();
1663
+ }
1664
+ /** How the last hiding change was asked for: a shortcut, a marker, else a menu or a host call. */
1665
+ visVia = 'menu';
1666
+ /** `sheetpane-visibility` for a hiding change, with what changed, and said politely (W-49 DoD 11, 12). */
1667
+ visibilityEvent(sheet, ref, kind, was, now) {
1668
+ const via = this.visVia;
1669
+ this.visVia = 'menu';
1670
+ if (kind === 'sheetState') {
1671
+ const hidden = now !== 'visible';
1672
+ fire(this, 'sheetpane-visibility', { sheet, axis: 'sheet', indexes: [], hidden, via });
1673
+ this.q.selsay.textContent = `Sheet ${sheet} ${hidden ? 'hidden' : 'unhidden'}`;
1674
+ return;
1675
+ }
1676
+ const a = new Set(was ?? []), b = new Set(now ?? []);
1677
+ const added = [...b].filter((i) => !a.has(i)), removed = [...a].filter((i) => !b.has(i));
1678
+ const axis = ref === '#hiddenRows' ? 'rows' : 'cols';
1679
+ for (const [list, hidden] of [[added, true], [removed, false]]) {
1680
+ if (!list.length)
1681
+ continue;
1682
+ fire(this, 'sheetpane-visibility', { sheet, axis: axis === 'rows' ? 'row' : 'col', indexes: list, hidden, via });
1683
+ this.q.selsay.textContent = `${this.spanName(axis, list)} ${hidden ? 'hidden' : 'shown'}`;
1684
+ }
1685
+ }
1686
+ /** A marker: unhide (Edit, with the visibility plugin — a change) or reveal for viewing. */
1687
+ onHiddenMarker(el) {
1688
+ const [axis, span] = el.dataset.hid.split(':');
1689
+ const [a, b] = span.split('-').map(Number);
1690
+ const idx = Array.from({ length: b - a + 1 }, (_, i) => a + i);
1691
+ const s = this._store, sheet = s.getSnapshot().selection.sheet;
1692
+ if (this.canUnhide()) {
1693
+ this.visVia = 'marker';
1694
+ s.setHidden(sheet, axis, idx, false, this);
1695
+ }
1696
+ else
1697
+ this.reveal(sheet, axis, idx);
1698
+ }
1699
+ /** A selection landing somewhere hidden shows it — nothing can hide an error from a reviewer (W-49 DoD 5). */
1700
+ revealFor(sheet, ref) {
1701
+ const s = this._store, h = s.hidden(sheet), p = parseRef(ref);
1702
+ if (!p)
1703
+ return;
1704
+ let what = '';
1705
+ if (h.state !== 'visible' && !this.openedSheets.has(sheet)) {
1706
+ this.openedSheets.add(sheet);
1707
+ what = 'hidden sheet';
1708
+ }
1709
+ const rev = this.revealed[sheet];
1710
+ const rowHidden = h.rows.includes(p.row) && !(rev?.all || rev?.rows.has(p.row));
1711
+ const colHidden = h.cols.includes(p.col) && !(rev?.all || rev?.cols.has(p.col));
1712
+ if (rowHidden || colHidden) {
1713
+ const r = (this.revealed[sheet] ??= { rows: new Set(), cols: new Set(), all: false });
1714
+ if (rowHidden)
1715
+ r.rows.add(p.row);
1716
+ if (colHidden)
1717
+ r.cols.add(p.col);
1718
+ this.revealVer++;
1719
+ what = rowHidden && colHidden ? 'hidden row and column' : rowHidden ? 'hidden row' : 'hidden column';
1720
+ }
1721
+ if (!what)
1722
+ return;
1723
+ const err = this.errorAt(sheet, ref);
1724
+ this.revealNote = { sheet, text: `Shown for ${err ? err.id : ref} (${what})`, rows: rowHidden ? [p.row] : [], cols: colHidden ? [p.col] : [] };
1725
+ this.schedule();
1726
+ }
1727
+ /** The hidden-sheets button and its menu, at the end of the tabs (W-49 DoD 6). */
1728
+ hiddenSheetsHtml() {
1729
+ const s = this._store, snap = s.getSnapshot(), book = snap.book;
1730
+ const hidden = book.sheets.filter((x) => s.hidden(x.name).state !== 'visible');
1731
+ if (!hidden.length)
1732
+ return '';
1733
+ const errs = snap.mode === 'edit' ? snap.errors.filter((e) => e.status === 'open' && hidden.some((h) => h.name === e.sheet)).length : 0;
1734
+ const btn = `<button type="button" class="tab hidbtn" data-act="hidden-sheets" aria-haspopup="menu" aria-expanded="${this.hiddenMenuOpen}" title="Sheets hidden in the file">⊘ ${hidden.length} hidden ▾${errs ? `<span class="cnt" aria-label="${errs} open">${errs}</span>` : ''}</button>`;
1735
+ return btn;
1736
+ }
1737
+ /** The hidden-sheets menu, or the tab menu, above the tabs. */
1738
+ hiddenMenuHtml() {
1739
+ const s = this._store, book = s.getSnapshot().book;
1740
+ if (this.tabMenu && book) {
1741
+ const items = this.pluginMenuItems('tab', { sheet: this.tabMenu.sheet, ref: 'A1' });
1742
+ return items ? `<div class="card ctxmenu tabmenu" role="menu" aria-label="Sheet ${esc(this.tabMenu.sheet)}" data-sp="tab-menu" style="left:${this.tabMenu.x}px">${items.replace('<div role="separator" class="msep"></div>', '')}</div>` : '';
1743
+ }
1744
+ if (!this.hiddenMenuOpen || !book)
1745
+ return '';
1746
+ const hidden = book.sheets.filter((x) => s.hidden(x.name).state !== 'visible');
1747
+ const items = hidden.map((h) => {
1748
+ const very = s.hidden(h.name).state === 'veryHidden';
1749
+ return `<button type="button" role="menuitem" class="mi" data-open-sheet="${esc(h.name)}"><span>${esc(h.name)}</span>${very ? '<span class="mnote">very hidden — Excel’s own menu can’t show this one</span>' : ''}</button>`;
1750
+ }).join('');
1751
+ return hidden.length ? `<div class="card hidmenu" role="menu" aria-label="Hidden sheets">${items}</div>` : '';
1752
+ }
1753
+ // ── sizes (W-47): drag a header edge, double-click to fit, the header menu ──
1754
+ sheetSizes(sheet) {
1755
+ return (this._sizes[sheet] ??= { cols: {}, rows: {} });
1756
+ }
1757
+ /** The indexes an edge acts on: every selected whole column (row) when the edge's own is one of them. */
1758
+ sizeTargets(axis, index) {
1759
+ const s = this._store, sel = s.getSnapshot().selection, g = this.geometry();
1760
+ const r = sel.range ? parseRange(sel.range) : undefined;
1761
+ if (!r || !g)
1762
+ return [index];
1763
+ const whole = axis === 'col' ? r.top === 0 && r.bottom >= g.g.rows - 1 && index >= r.left && index <= r.right : r.left === 0 && r.right >= g.g.cols - 1 && index >= r.top && index <= r.bottom;
1764
+ if (!whole)
1765
+ return [index];
1766
+ const out = [];
1767
+ for (let i = axis === 'col' ? r.left : r.top; i <= (axis === 'col' ? r.right : r.bottom); i++)
1768
+ out.push(i);
1769
+ return out;
1770
+ }
1771
+ /** Sets sizes (px at 100 %, or `undefined` for the file's) and redraws; with `via`, fires `sheetpane-resize` and says it. */
1772
+ setSizes(axis, indexes, px, via, each) {
1773
+ const sheet = this._store.getSnapshot().selection.sheet, z = this.sheetSizes(sheet), map = axis === 'col' ? z.cols : z.rows;
1774
+ indexes.forEach((i, k) => { const v = each ? each[k] : px; if (v === undefined)
1775
+ delete map[i];
1776
+ else
1777
+ map[i] = clampSize(axis, v); });
1778
+ this.sizesVer++;
1779
+ this.schedule();
1780
+ if (!via)
1781
+ return;
1782
+ const g = this.geometry().g, now = indexes.map((i) => Math.round(axis === 'col' ? colWidth(g, i) : rowHeight(g, i)));
1783
+ fire(this, 'sheetpane-resize', { sheet, axis, indexes, px: indexes.length === 1 ? now[0] : now, via });
1784
+ const name = (i) => (axis === 'col' ? `Column ${colName(i)}` : `Row ${i + 1}`);
1785
+ this.q.selsay.textContent = indexes.length === 1 ? `${name(indexes[0])}, ${now[0]} pixels` : `${indexes.length} ${axis === 'col' ? 'columns' : 'rows'}, ${now[0]} pixels`;
1786
+ }
1787
+ /** Pointer down on a header edge: follow it until release; Esc puts the sizes back. */
1788
+ startResize(e, el) {
1789
+ const g = this.geometry();
1790
+ if (!g)
1791
+ return;
1792
+ e.preventDefault();
1793
+ e.stopPropagation();
1794
+ const axis = el.dataset.rzC !== undefined ? 'col' : 'row', index = Number(axis === 'col' ? el.dataset.rzC : el.dataset.rzR);
1795
+ const sheet = this._store.getSnapshot().selection.sheet, cur = this._sizes[sheet];
1796
+ const start = axis === 'col' ? colWidth(g.g, index) : rowHeight(g.g, index);
1797
+ this.rz = { id: e.pointerId, axis, indexes: this.sizeTargets(axis, index), start, from: axis === 'col' ? e.clientX : e.clientY, px: start, moved: false, before: cur ? { cols: { ...cur.cols }, rows: { ...cur.rows } } : undefined };
1798
+ try {
1799
+ this.q.scroller.setPointerCapture(e.pointerId);
1800
+ }
1801
+ catch { /* synthetic */ }
1802
+ }
1803
+ moveResize(e) {
1804
+ const r = this.rz;
1805
+ if (!r || e.pointerId !== r.id)
1806
+ return;
1807
+ const delta = ((r.axis === 'col' ? e.clientX : e.clientY) - r.from) / this.zoom;
1808
+ if (!r.moved && Math.abs(delta) < 2)
1809
+ return; // a click on the edge with no movement changes nothing
1810
+ r.moved = true;
1811
+ r.px = clampSize(r.axis, r.start + delta);
1812
+ if (!this.rzRaf)
1813
+ this.rzRaf = requestAnimationFrame(() => { this.rzRaf = 0; if (this.rz) {
1814
+ this.setSizes(this.rz.axis, this.rz.indexes, this.rz.px);
1815
+ this.renderNow();
1816
+ } });
1817
+ }
1818
+ endResize(commit) {
1819
+ const r = this.rz;
1820
+ if (!r)
1821
+ return;
1822
+ this.rz = undefined;
1823
+ if (this.rzRaf) {
1824
+ cancelAnimationFrame(this.rzRaf);
1825
+ this.rzRaf = 0;
1826
+ }
1827
+ try {
1828
+ if (this.q.scroller.hasPointerCapture(r.id))
1829
+ this.q.scroller.releasePointerCapture(r.id);
1830
+ }
1831
+ catch { /* synthetic */ }
1832
+ const sheet = this._store.getSnapshot().selection.sheet;
1833
+ if (!commit || !r.moved) {
1834
+ if (r.before)
1835
+ this._sizes[sheet] = r.before;
1836
+ else
1837
+ delete this._sizes[sheet];
1838
+ this.sizesVer++;
1839
+ this.schedule();
1840
+ return;
1841
+ }
1842
+ this.setSizes(r.axis, r.indexes, r.px, 'drag');
1843
+ }
1844
+ /** Double-click on an edge, or Fit to data: each column to its widest text, each row to its tallest wrapped text (W-47 DoD 2). */
1845
+ fit(axis, indexes, via = 'fit') {
1846
+ const g = this.geometry();
1847
+ if (!g)
1848
+ return;
1849
+ const px = indexes.map((i) => (axis === 'col' ? this.fitWidth(g, i) : this.fitHeight(g, i)));
1850
+ this.setSizes(axis, indexes, undefined, via, px);
1851
+ }
1852
+ measureCtx;
1853
+ /** The widest display text in a column, in each cell's own size and weight, padding included; merges ignored. */
1854
+ fitWidth(g, c) {
1855
+ const s = this._store, sheet = g.sheet.name, z = this.zoom;
1856
+ const ctx = (this.measureCtx ??= document.createElement('canvas').getContext('2d'));
1857
+ const sample = this.q.grid.querySelector('.c') ?? this.q.grid;
1858
+ const cs = getComputedStyle(sample), base = parseFloat(cs.fontSize) || 14 * z, family = cs.fontFamily;
1859
+ const pad = base * 1.2 + 4; // .6em each side, the border, a little air
1860
+ const rows = new Set();
1861
+ for (let r = 0; r < Math.min(g.g.rows, 20_000); r++)
1862
+ rows.add(r);
1863
+ for (const el of Array.from(this.q.grid.querySelectorAll(`.c[data-c="${c}"]`)))
1864
+ rows.add(Number(el.dataset.r));
1865
+ let widest = 0;
1866
+ for (const r of rows) {
1867
+ const m = mergeAt(g.g, r, c);
1868
+ if (m && (m.left !== m.right || m.top !== m.bottom))
1869
+ continue; // merged cells do not size a column (Excel)
1870
+ const ref = cellRef(r, c), text = s.text(sheet, ref);
1871
+ if (!text)
1872
+ continue;
1873
+ let weight = cs.fontWeight, size = base;
1874
+ if (s.plugins.some((p) => p.provides.includes('decorate'))) {
1875
+ const d = s.decorate(sheet, ref);
1876
+ if (d['font-weight'])
1877
+ weight = d['font-weight'];
1878
+ if (d['font-size']) {
1879
+ const v = parseFloat(d['font-size']);
1880
+ if (v)
1881
+ size = d['font-size'].endsWith('em') ? v * base : v * z;
1882
+ }
1883
+ }
1884
+ const longest = text.split('\n').reduce((a, b) => (b.length > a.length ? b : a), '');
1885
+ let w = longest.length * size * 0.6;
1886
+ if (ctx) {
1887
+ ctx.font = `${weight} ${size}px ${family}`;
1888
+ w = ctx.measureText(longest).width;
1889
+ }
1890
+ widest = Math.max(widest, w);
1891
+ }
1892
+ return Math.min(480, Math.max(DEFAULT_COL_PX, Math.ceil((widest + pad) / z)));
1893
+ }
1894
+ /** The tallest wrapped text in a row at the current column widths, at least 28 px. */
1895
+ fitHeight(g, r) {
1896
+ const s = this._store, sheet = g.sheet.name, z = this.zoom;
1897
+ const probe = document.createElement('div');
1898
+ probe.style.cssText = 'position:absolute;visibility:hidden;left:-9999px;top:0;box-sizing:border-box;padding:4px .6em;font-size:var(--sp-cell-font-size,.93em)';
1899
+ this.q.sheet.append(probe);
1900
+ let tallest = ROW_H * z;
1901
+ try {
1902
+ for (let c = 0; c < g.g.cols; c++) {
1903
+ const ref = cellRef(r, c), text = s.text(sheet, ref);
1904
+ if (!text)
1905
+ continue;
1906
+ const d = s.plugins.some((p) => p.provides.includes('decorate')) ? s.decorate(sheet, ref) : {};
1907
+ const wraps = (d['white-space'] ?? '').startsWith('pre-wrap') || (d['white-space'] ?? '') === 'normal' || text.includes('\n');
1908
+ if (!wraps)
1909
+ continue;
1910
+ probe.style.width = `${colWidth(g.g, c) * z}px`;
1911
+ probe.style.whiteSpace = 'pre-wrap';
1912
+ probe.style.fontWeight = d['font-weight'] ?? '';
1913
+ probe.textContent = text;
1914
+ tallest = Math.max(tallest, probe.offsetHeight);
1915
+ }
1916
+ }
1917
+ finally {
1918
+ probe.remove();
1919
+ }
1920
+ return Math.max(ROW_H, Math.ceil(tallest / z));
1921
+ }
1922
+ /** The exact-size field: arrows ±1, Shift+arrows ±10, Enter applies, Esc cancels. True when handled. */
1923
+ onSizeKey(e) {
1924
+ const f = e.target.closest?.('.szpop input');
1925
+ if (!f)
1926
+ return false;
1927
+ if (e.key === 'ArrowUp' || e.key === 'ArrowDown') {
1928
+ e.preventDefault();
1929
+ f.value = String((Number(f.value) || 0) + (e.key === 'ArrowUp' ? 1 : -1) * (e.shiftKey ? 10 : 1));
1930
+ return true;
1931
+ }
1932
+ if (e.key === 'Enter') {
1933
+ e.preventDefault();
1934
+ this.applySizeField();
1935
+ return true;
1936
+ }
1937
+ if (e.key === 'Escape') {
1938
+ e.preventDefault();
1939
+ e.stopPropagation();
1940
+ this.hdrMenu = undefined;
1941
+ this.schedule();
1942
+ this.q.grid.focus({ preventScroll: true });
1943
+ return true;
1944
+ }
1945
+ return false;
1946
+ }
1947
+ applySizeField() {
1948
+ const m = this.hdrMenu, f = this.q.overlay.querySelector('.szpop input');
1949
+ if (!m || !f)
1950
+ return;
1951
+ const v = Number(f.value);
1952
+ this.hdrMenu = undefined;
1953
+ if (Number.isFinite(v) && v > 0)
1954
+ this.setSizes(m.axis, m.indexes, v, 'menu');
1955
+ else
1956
+ this.schedule();
1957
+ this.q.grid.focus({ preventScroll: true });
1958
+ }
1959
+ /** The `selbar` controls that join a menu (W-49 `menus`), as menu items under a separator. */
1960
+ pluginMenuItems(menu, sel) {
1961
+ const s = this._store, snap = s.getSnapshot();
1962
+ if (snap.mode !== 'edit')
1963
+ return '';
1964
+ const at = sel ?? snap.selection;
1965
+ const list = controlsFor(s.plugins, 'selbar', 'edit').filter((c) => c.menus?.includes(menu));
1966
+ if (!list.length)
1967
+ return '';
1968
+ return `<div role="separator" class="msep"></div>${list.map((c) => {
1969
+ const label = c.labelFor?.(snap, at) ?? c.label, off = c.disabled?.(snap, at) ?? false, why = off ? c.disabledReason?.(snap, at) : undefined;
1970
+ return `<button type="button" role="menuitem" class="mi" data-control="${esc(c.id)}" data-menu="${menu}"${off ? ' disabled' : ''}${why ? ` title="${esc(why)}"` : ''}>${esc(label)}${c.shortcut ? `<span class="kbd">${esc(c.shortcut)}</span>` : ''}</button>`;
1971
+ }).join('')}`;
1972
+ }
1973
+ /** The header menu (right-click a header; Shift+F10 on a whole column or row): Fit · Width… · Reset (W-47 DoD 4). */
1974
+ openHeaderMenu(axis, indexes) {
1975
+ this.ctxOpen = false;
1976
+ this.moreOpen = false;
1977
+ this.menu = undefined;
1978
+ this.hdrMenu = { axis, indexes, sizing: false };
1979
+ this.schedule();
1980
+ requestAnimationFrame(() => this.q.overlay.querySelector('.hdrmenu [role=menuitem]')?.focus({ preventScroll: true }));
1981
+ }
1982
+ headerMenuHtml(g, z) {
1983
+ const m = this.hdrMenu, sc = this.q.scroller, HH = this.headH() * z, RW = this.rowHeadW() * z;
1984
+ const first = m.indexes[0];
1985
+ const x = m.axis === 'col' ? RW + g.colX[Math.min(first, g.cols)] * z : sc.scrollLeft + RW + 2;
1986
+ const y = m.axis === 'col' ? sc.scrollTop + HH + 2 : HH + rowTop(g, first + 1) * z + 2;
1987
+ const what = m.axis === 'col' ? 'Column width' : 'Row height';
1988
+ const cur = Math.round(m.axis === 'col' ? colWidth(g, first) : rowHeight(g, first));
1989
+ if (m.sizing) {
1990
+ return `<div class="card szpop" role="dialog" aria-label="${what}" style="top:${y}px;left:${x}px;width:220px"><div class="bd"><label class="field"><span>${what} (px)</span><input class="input" type="number" data-field="size" min="${m.axis === 'col' ? COL_MIN : ROW_MIN}" max="${m.axis === 'col' ? COL_MAX : ROW_MAX}" value="${cur}" aria-label="${what} in pixels"></label><span class="xs faint">Arrows ±1 · Shift ±10 · Enter applies${m.indexes.length > 1 ? ` to ${m.indexes.length} ${m.axis === 'col' ? 'columns' : 'rows'}` : ''}</span></div><div class="ft"><span class="spacer"></span><button type="button" class="btn btn-g" data-act="hcancel">Cancel</button><button type="button" class="btn btn-p" data-act="happly">Apply</button></div></div>`;
1991
+ }
1992
+ const item = (act, label) => `<button type="button" role="menuitem" class="mi" data-act="${act}">${esc(label)}</button>`;
1993
+ return `<div class="card ctxmenu hdrmenu" role="menu" aria-label="${m.axis === 'col' ? 'Column' : 'Row'} menu" data-sp="header-menu" style="top:${y}px;left:${x}px">${item('hfit', 'Fit to data')}${item('hsize', `${what}…`)}${item('hreset', 'Reset to the file’s size')}${this.pluginMenuItems('header')}</div>`;
1994
+ }
1995
+ /** A header click: the whole column, row or sheet; with Shift, out to it from the anchor. */
1996
+ selectHeader(h, extend) {
1997
+ const s = this._store, g = this.geometry();
1998
+ if (!g)
1999
+ return;
2000
+ const sheet = s.getSnapshot().selection.sheet;
2001
+ const fz = s.freeze(sheet), sc = this.q.scroller;
2002
+ // the active cell stays at the first visible cell of the range
2003
+ const firstRow = fz.rows > 0 ? 0 : rowAt(g.g, sc.scrollTop / this.zoom);
2004
+ const firstCol = fz.cols > 0 ? 0 : Math.min(g.g.cols - 1, colAt(g.g, sc.scrollLeft / this.zoom));
2005
+ let r, active;
2006
+ if (h.kind === 'all') {
2007
+ r = { top: 0, left: 0, bottom: g.g.rows - 1, right: g.g.cols - 1 };
2008
+ active = { row: firstRow, col: firstCol };
2009
+ this.anchor = { row: 0, col: 0 };
2010
+ }
2011
+ else if (h.kind === 'col') {
2012
+ const from = extend && this.anchor ? this.anchor.col : h.index;
2013
+ r = { top: 0, bottom: g.g.rows - 1, left: Math.min(from, h.index), right: Math.max(from, h.index) };
2014
+ active = { row: firstRow, col: extend && this.anchor ? this.anchor.col : h.index };
2015
+ if (!extend)
2016
+ this.anchor = { row: 0, col: h.index };
2017
+ }
2018
+ else {
2019
+ const from = extend && this.anchor ? this.anchor.row : h.index;
2020
+ r = { left: 0, right: g.g.cols - 1, top: Math.min(from, h.index), bottom: Math.max(from, h.index) };
2021
+ active = { row: extend && this.anchor ? this.anchor.row : h.index, col: firstCol };
2022
+ if (!extend)
2023
+ this.anchor = { row: h.index, col: 0 };
2024
+ }
2025
+ s.select({ sheet, ref: cellRef(active.row, active.col), range: rangeText(r) }, this);
2026
+ this.say(r);
2027
+ }
2028
+ /** Says the settled range once, politely (W-46 DoD 9). */
2029
+ say(r) {
2030
+ clearTimeout(this.sayTimer);
2031
+ this.sayTimer = setTimeout(() => { if (this.built)
2032
+ this.q.selsay.textContent = `${cellRef(r.top, r.left)} to ${cellRef(r.bottom, r.right)}, ${cellCount(r)} cells`; }, 250);
2033
+ }
2034
+ selectCell(row, col, extend, dir = { dr: 1, dc: 1 }) {
2035
+ const s = this._store, snap = s.getSnapshot();
2036
+ const gq = this.geometry();
2037
+ if (gq) {
2038
+ row = this.nearestShown(gq.g.rowY, gq.g.rows, row, dir.dr);
2039
+ col = this.nearestShown(gq.g.colX, gq.g.cols, col, dir.dc);
2040
+ } // keys skip hidden rows and columns (W-49)
2041
+ const ref = cellRef(row, col);
2042
+ if (extend && this.anchor) {
2043
+ const g = this.geometry();
2044
+ const r = growForMerges(spanOf(this.anchor, { row, col }), g?.g.merges ?? []);
2045
+ const one = r.top === r.bottom && r.left === r.right;
2046
+ const am = g && mergeAt(g.g, this.anchor.row, this.anchor.col);
2047
+ const range = one || (am && am.top === r.top && am.left === r.left && am.bottom === r.bottom && am.right === r.right) ? undefined : rangeText(r);
2048
+ s.select(range ? { sheet: snap.selection.sheet, ref: cellRef(this.anchor.row, this.anchor.col), range } : { sheet: snap.selection.sheet, ref: cellRef(this.anchor.row, this.anchor.col) }, this);
2049
+ if (range)
2050
+ this.say(r);
2051
+ }
2052
+ else {
2053
+ this.anchor = { row, col };
2054
+ s.select({ sheet: snap.selection.sheet, ref }, this);
2055
+ }
2056
+ this.scrollToSel = true;
2057
+ }
2058
+ /** The APG grid keyboard model (grid verdict decision 4). */
2059
+ onKey(e) {
2060
+ const s = this._store, snap = s.getSnapshot();
2061
+ if (snap.load.status !== 'ready')
2062
+ return;
2063
+ const mod = e.metaKey || e.ctrlKey;
2064
+ const edit = snap.mode === 'edit';
2065
+ const g = this.geometry();
2066
+ if (!g)
2067
+ return;
2068
+ const page = Math.max(1, Math.floor(this.q.scroller.clientHeight / (ROW_H * this.zoom)) - 2);
2069
+ const k = e.key;
2070
+ let handled = true;
2071
+ const ctl = s.plugins.flatMap((p) => p.controls ?? []).find((c) => c.shortcut && c.run && (c.mode === 'both' || c.mode === snap.mode) && this.matchesShortcut(e, c.shortcut));
2072
+ if (ctl && !ctl.disabled?.(snap, snap.selection)) {
2073
+ this.visVia = 'key';
2074
+ ctl.run(s, snap.selection);
2075
+ this.visVia = 'menu';
2076
+ e.preventDefault();
2077
+ return;
2078
+ }
2079
+ if (e.altKey && e.shiftKey && (k === 'ArrowDown' || k === 'ArrowUp') && !mod) {
2080
+ // Alt+Shift+↓ / ↑: expand or collapse the innermost row group at the active cell (W-49, view only)
2081
+ const o = s.outline(snap.selection.sheet), p0 = parseRef(snap.selection.ref);
2082
+ const mine = this.groupsOf(o.rows, o.levelRow, p0.row, p0.row, g.g.rows).filter(([, a, b]) => p0.row >= a && p0.row <= b).sort((x, y) => y[0] - x[0])[0];
2083
+ if (mine) {
2084
+ this.toggleGroup('rows', mine[1], mine[2], k === 'ArrowDown');
2085
+ e.preventDefault();
2086
+ return;
2087
+ }
2088
+ }
2089
+ if (e.altKey && !e.shiftKey && k === 'ArrowDown' && edit) {
2090
+ const ctx = s.cellContext(snap.selection.sheet, snap.selection.ref);
2091
+ const mc = ctx && controlsFor(s.plugins, 'cell', 'edit').find((c) => c.cellOptions && c.choose && c.appliesTo?.(ctx));
2092
+ if (mc && ctx) {
2093
+ this.menu = { control: mc.id, sheet: snap.selection.sheet, ref: snap.selection.ref, options: mc.cellOptions(ctx) };
2094
+ this.focusMenu = true;
2095
+ this.schedule();
2096
+ e.preventDefault();
2097
+ return;
2098
+ }
2099
+ }
2100
+ if (this.drag && k === 'Escape') {
2101
+ this.endDrag(false);
2102
+ e.preventDefault();
2103
+ e.stopPropagation();
2104
+ return;
2105
+ }
2106
+ if (this.rz && k === 'Escape') {
2107
+ this.endResize(false);
2108
+ e.preventDefault();
2109
+ e.stopPropagation();
2110
+ return;
2111
+ }
2112
+ const range = snap.selection.range ? parseRange(snap.selection.range) : undefined;
2113
+ const arrow = k === 'ArrowDown' ? [1, 0] : k === 'ArrowUp' ? [-1, 0] : k === 'ArrowRight' ? [0, 1] : k === 'ArrowLeft' ? [0, -1] : undefined;
2114
+ if (mod && arrow && !e.altKey) {
2115
+ // Ctrl/⌘+arrow: the edge of the data; with Shift, extend there (W-46 DoD 5)
2116
+ const from = e.shiftKey && this.anchor && range ? this.rangeEnd(snap.selection) : parseRef(snap.selection.ref);
2117
+ const to = dataEdge(from, arrow[0], arrow[1], (r, c) => s.value(snap.selection.sheet, cellRef(r, c)), { rows: g.g.rows, cols: g.g.cols });
2118
+ if (e.shiftKey && !this.anchor)
2119
+ this.anchor = parseRef(snap.selection.ref);
2120
+ this.selectCell(to.row, to.col, e.shiftKey, { dr: -(arrow[0] || -1), dc: -(arrow[1] || -1) });
2121
+ }
2122
+ else if (k === ' ' && (e.ctrlKey || e.shiftKey) && !e.metaKey && !e.altKey && !(e.ctrlKey && e.shiftKey)) {
2123
+ // Ctrl+Space: the columns; Shift+Space: the rows — of the active cell or the range
2124
+ const r = range ?? { top: parseRef(snap.selection.ref).row, bottom: parseRef(snap.selection.ref).row, left: parseRef(snap.selection.ref).col, right: parseRef(snap.selection.ref).col };
2125
+ const whole = e.ctrlKey ? { top: 0, bottom: g.g.rows - 1, left: r.left, right: r.right } : { left: 0, right: g.g.cols - 1, top: r.top, bottom: r.bottom };
2126
+ s.select({ sheet: snap.selection.sheet, ref: snap.selection.ref, range: rangeText(whole) }, this);
2127
+ this.say(whole);
2128
+ }
2129
+ else if (mod && (k === 'a' || k === 'A') && !e.shiftKey && !e.altKey) {
2130
+ // Ctrl/⌘+A: the data region; again: the whole sheet
2131
+ const used = usedRange(g.sheet.rows), all = { top: 0, left: 0, bottom: g.g.rows - 1, right: g.g.cols - 1 };
2132
+ const next = range && rangeText(range) === rangeText(used) ? all : used;
2133
+ const one = next.top === next.bottom && next.left === next.right;
2134
+ s.select(one ? { sheet: snap.selection.sheet, ref: cellRef(next.top, next.left) } : { sheet: snap.selection.sheet, ref: snap.selection.ref, range: rangeText(next) }, this);
2135
+ this.anchor = { row: next.top, col: next.left };
2136
+ if (!one)
2137
+ this.say(next);
2138
+ }
2139
+ else if (k === 'Backspace' && e.shiftKey && !mod) {
2140
+ this.anchor = parseRef(snap.selection.ref);
2141
+ s.select({ sheet: snap.selection.sheet, ref: snap.selection.ref }, this);
2142
+ }
2143
+ else if (range && (k === 'Enter' || k === 'Tab') && !mod && !e.altKey)
2144
+ this.stepInRange(range, k === 'Tab', e.shiftKey);
2145
+ else if (mod && (k === 'z' || k === 'Z')) {
2146
+ if (e.shiftKey)
2147
+ s.redo(this);
2148
+ else
2149
+ s.undo(this);
2150
+ }
2151
+ else if (mod && (k === 'y' || k === 'Y'))
2152
+ s.redo(this);
2153
+ else if (mod && (k === 'c' || k === 'C'))
2154
+ this.copySelection();
2155
+ else if ((k === 'F10' && e.shiftKey) || k === 'ContextMenu') {
2156
+ // a whole column or row selected: its size menu (W-47); otherwise the selection actions
2157
+ const whole = range && (range.top === 0 && range.bottom >= g.g.rows - 1 ? 'col' : range.left === 0 && range.right >= g.g.cols - 1 ? 'row' : undefined);
2158
+ if (whole && range)
2159
+ this.openHeaderMenu(whole, this.sizeTargets(whole, whole === 'col' ? range.left : range.top));
2160
+ else
2161
+ this.openContextMenu();
2162
+ }
2163
+ else if (k === 'ArrowDown')
2164
+ this.move(1, 0, e.shiftKey);
2165
+ else if (k === 'ArrowUp')
2166
+ this.move(-1, 0, e.shiftKey);
2167
+ else if (k === 'ArrowRight')
2168
+ this.move(0, 1, e.shiftKey);
2169
+ else if (k === 'ArrowLeft')
2170
+ this.move(0, -1, e.shiftKey);
2171
+ else if (k === 'PageDown')
2172
+ this.move(page, 0, e.shiftKey);
2173
+ else if (k === 'PageUp')
2174
+ this.move(-page, 0, e.shiftKey);
2175
+ else if (k === 'Home') {
2176
+ const p = parseRef(snap.selection.ref);
2177
+ this.selectCell(mod ? 0 : p.row, 0, e.shiftKey);
2178
+ }
2179
+ else if (k === 'End') {
2180
+ const p = parseRef(snap.selection.ref);
2181
+ const s0 = this.geom.sheet;
2182
+ this.selectCell(mod ? Math.max(0, s0.rows.length - 1) : p.row, mod ? g.g.cols - 1 : Math.max(0, (s0.rows[p.row]?.length ?? 1) - 1), e.shiftKey);
2183
+ }
2184
+ else if ((k === 'Enter' || k === 'F2') && edit)
2185
+ this.editCell();
2186
+ else if ((k === 'f' || k === 'F') && edit && !mod && !e.altKey)
2187
+ this.flag();
2188
+ else if (k === 'Escape') {
2189
+ if (!this.escape())
2190
+ handled = false;
2191
+ }
2192
+ else if ((k === 'Delete' || k === 'Backspace') && edit) {
2193
+ if (range)
2194
+ this.clearRange(range);
2195
+ else if (this.allowChange(snap.selection.sheet, snap.selection.ref, null))
2196
+ s.setCell(snap.selection.sheet, snap.selection.ref, null, { origin: this });
2197
+ }
2198
+ else if (edit && !mod && !e.altKey && k.length === 1) {
2199
+ this.editCell(k);
2200
+ }
2201
+ else
2202
+ handled = false;
2203
+ if (handled) {
2204
+ e.preventDefault();
2205
+ e.stopPropagation();
2206
+ }
2207
+ }
2208
+ /** Esc cancels the innermost thing open; false when nothing was. */
2209
+ escape() {
2210
+ const s = this._store;
2211
+ if (this.menu) {
2212
+ this.menu = undefined;
2213
+ this.schedule();
2214
+ return true;
2215
+ }
2216
+ if (this.hdrMenu) {
2217
+ this.hdrMenu = undefined;
2218
+ this.schedule();
2219
+ return true;
2220
+ }
2221
+ if (this.tabMenu || this.hiddenMenuOpen) {
2222
+ this.tabMenu = undefined;
2223
+ this.hiddenMenuOpen = false;
2224
+ this.schedule();
2225
+ return true;
2226
+ }
2227
+ if (this.ctxOpen || this.moreOpen || this.dlOpen) {
2228
+ this.ctxOpen = false;
2229
+ this.moreOpen = false;
2230
+ this.dlOpen = false;
2231
+ this.schedule();
2232
+ return true;
2233
+ }
2234
+ if (s?.getSnapshot().composing) {
2235
+ s.compose(null);
2236
+ return true;
2237
+ }
2238
+ if (this.findText) {
2239
+ this.q.findin.value = '';
2240
+ this.findText = '';
2241
+ this.findHits = [];
2242
+ this.schedule();
2243
+ return true;
2244
+ }
2245
+ if (this.copied) {
2246
+ this.copied = undefined;
2247
+ this.selKey = '';
2248
+ this.schedule();
2249
+ return true;
2250
+ }
2251
+ return false;
2252
+ }
2253
+ move(dr, dc, extend) {
2254
+ const s = this._store, snap = s.getSnapshot(), g = this.geometry();
2255
+ if (!g)
2256
+ return;
2257
+ const base = extend && this.anchor && snap.selection.range ? this.rangeEnd(snap.selection) : parseRef(snap.selection.ref);
2258
+ let row = Math.min(g.g.rows - 1, Math.max(0, base.row + dr)), col = Math.min(g.g.cols - 1, Math.max(0, base.col + dc));
2259
+ if (!extend) {
2260
+ const m = mergeAt(g.g, base.row, base.col);
2261
+ if (m && !(dr === 0 && dc === 0)) {
2262
+ if (dr > 0)
2263
+ row = Math.min(g.g.rows - 1, m.bottom + 1);
2264
+ if (dc > 0)
2265
+ col = Math.min(g.g.cols - 1, m.right + 1);
2266
+ }
2267
+ const m2 = mergeAt(g.g, row, col);
2268
+ if (m2) {
2269
+ row = m2.top;
2270
+ col = m2.left;
2271
+ }
2272
+ }
2273
+ if (extend && !this.anchor)
2274
+ this.anchor = parseRef(snap.selection.ref);
2275
+ // stepping onto hidden rows (columns) carries on past them, in the direction of travel
2276
+ this.selectCell(row, col, extend, { dr: dr || 1, dc: dc || 1 });
2277
+ }
2278
+ /** Enter / Tab inside a range: the active cell moves within it and wraps, as in Excel (W-46 DoD 5). */
2279
+ stepInRange(r, across, back) {
2280
+ const s = this._store, sel = s.getSnapshot().selection, p = parseRef(sel.ref);
2281
+ const h = r.bottom - r.top + 1, w = r.right - r.left + 1, n = h * w;
2282
+ // the cells in order: down the columns for Enter, along the rows for Tab
2283
+ const idx = across ? (p.row - r.top) * w + (p.col - r.left) : (p.col - r.left) * h + (p.row - r.top);
2284
+ const next = (idx + (back ? -1 : 1) + n) % n;
2285
+ const pos = across ? { row: r.top + Math.floor(next / w), col: r.left + (next % w) } : { row: r.top + (next % h), col: r.left + Math.floor(next / h) };
2286
+ s.select({ sheet: sel.sheet, ref: cellRef(pos.row, pos.col), range: sel.range }, this);
2287
+ this.scrollToSel = true;
2288
+ }
2289
+ /** Ctrl/⌘+C: one cell's value, or a range as TSV and an HTML table; the copied range is outlined (W-46 DoD 6). */
2290
+ copySelection() {
2291
+ const s = this._store, sel = s.getSnapshot().selection;
2292
+ const r = sel.range ? parseRange(sel.range) : undefined;
2293
+ if (!r) {
2294
+ this.copied = undefined;
2295
+ this.copy(s.text(sel.sheet, sel.ref));
2296
+ this.schedule();
2297
+ return;
2298
+ }
2299
+ if (cellCount(r) > 1_000_000) {
2300
+ this.say0(`Nothing copied — ${cellCount(r).toLocaleString()} cells is over the 1,000,000 a copy holds`);
2301
+ return;
2302
+ }
2303
+ const g = this.geometry().g, left = this.hiddenIn(g, r);
2304
+ const { tsv, html } = clipOf(r, (row, col) => s.text(sel.sheet, cellRef(row, col)), { row: (i) => this.shownRow(g, i), col: (i) => this.shownCol(g, i) });
2305
+ if (left)
2306
+ this.say0(`Copied the shown cells — ${left} hidden cell${left === 1 ? '' : 's'} left out`);
2307
+ const fallback = () => this.copy(tsv);
2308
+ const Item = globalThis.ClipboardItem;
2309
+ if (navigator.clipboard?.write && Item)
2310
+ navigator.clipboard.write([new Item({ 'text/plain': new Blob([tsv], { type: 'text/plain' }), 'text/html': new Blob([html], { type: 'text/html' }) })]).catch(fallback);
2311
+ else
2312
+ fallback();
2313
+ this.copied = { sheet: sel.sheet, range: r };
2314
+ this.selKey = '';
2315
+ this.schedule();
2316
+ }
2317
+ /** A status-line notice for a few seconds. */
2318
+ say0(text) {
2319
+ this.notice = text;
2320
+ clearTimeout(this.noticeTimer);
2321
+ this.noticeTimer = setTimeout(() => { this.notice = ''; this.schedule(); }, 4000);
2322
+ this.schedule();
2323
+ }
2324
+ /** Delete / Backspace over a range (Edit): every filled cell through `sheetpane-beforechange`, refused ones skipped, one undo step. */
2325
+ clearRange(r) {
2326
+ const s = this._store, sheet = s.getSnapshot().selection.sheet, g = this.geometry();
2327
+ if (!g)
2328
+ return;
2329
+ const cells = [];
2330
+ const left = this.hiddenIn(g.g, r);
2331
+ if (left)
2332
+ this.say0(`Cleared the shown cells — ${left} hidden cell${left === 1 ? '' : 's'} left out`);
2333
+ for (let row = r.top; row <= Math.min(r.bottom, g.g.rows - 1); row++)
2334
+ for (let col = r.left; col <= Math.min(r.right, g.g.cols - 1); col++) {
2335
+ if (!this.shownRow(g.g, row) || !this.shownCol(g.g, col))
2336
+ continue;
2337
+ const ref = cellRef(row, col), v = s.value(sheet, ref);
2338
+ if (v === null || v === undefined || v === '')
2339
+ continue;
2340
+ if (this.allowChange(sheet, ref, null))
2341
+ cells.push({ sheet, ref, input: null });
2342
+ }
2343
+ if (cells.length)
2344
+ s.setCells(cells, { origin: this });
2345
+ }
2346
+ rangeEnd(sel) {
2347
+ const r = parseRange(sel.range), a = this.anchor;
2348
+ return { row: a.row === r.top ? r.bottom : r.top, col: a.col === r.left ? r.right : r.left };
2349
+ }
2350
+ editCell(first) {
2351
+ const cell = this.q.cell;
2352
+ if (cell.hidden)
2353
+ return;
2354
+ cell.focus();
2355
+ if (first !== undefined) {
2356
+ cell.value = first;
2357
+ cell.dispatchEvent(new Event('input'));
2358
+ }
2359
+ else
2360
+ cell.select();
2361
+ }
2362
+ /** Flag issue (`F`): the store decides which form opens (contract decision 4). */
2363
+ flag() {
2364
+ const s = this._store, sel = s.getSnapshot().selection;
2365
+ if (!s.flagging)
2366
+ return; // flagging="off" (W-33)
2367
+ const t = this._errorTypes[0];
2368
+ this.draft = { type: t.id, message: '', expected: '', severity: t.severity, change: false };
2369
+ this.composerKey = '';
2370
+ const c = { sheet: sel.sheet, ref: sel.ref };
2371
+ if (sel.range)
2372
+ c.range = sel.range;
2373
+ s.compose(c);
2374
+ }
2375
+ toggleFreeze() {
2376
+ const s = this._store, sel = s.getSnapshot().selection, cur = s.freeze(sel.sheet);
2377
+ if (cur.rows || cur.cols)
2378
+ s.setFreeze(sel.sheet, { rows: 0, cols: 0 }, this);
2379
+ else {
2380
+ const p = parseRef(sel.ref);
2381
+ s.setFreeze(sel.sheet, { rows: p.row, cols: p.col }, this);
2382
+ }
2383
+ }
2384
+ findNext(dir) {
2385
+ this.computeHits();
2386
+ if (!this.findHits.length)
2387
+ return;
2388
+ this.findIdx = (this.findIdx + dir + this.findHits.length) % this.findHits.length;
2389
+ const h = this.findHits[this.findIdx];
2390
+ this._store.select({ sheet: h.sheet, ref: h.ref }, this);
2391
+ this.anchor = parseRef(h.ref);
2392
+ this.scrollToSel = true;
2393
+ this.schedule();
2394
+ }
2395
+ /** Find covers every row of every sheet, not just the rendered window. */
2396
+ computeHits() {
2397
+ const s = this._store, snap = s.getSnapshot();
2398
+ if (this.findVersion === snap.version)
2399
+ return;
2400
+ this.findVersion = snap.version;
2401
+ this.findHits = [];
2402
+ const q = this.findText.trim().toLowerCase();
2403
+ if (!q || !snap.book)
2404
+ return;
2405
+ const order = [snap.selection.sheet, ...snap.book.sheets.map((x) => x.name).filter((n) => n !== snap.selection.sheet)];
2406
+ for (const name of order) {
2407
+ const sh = s.sheet(name);
2408
+ for (let r = 0; r < sh.text.length; r++) {
2409
+ const row = sh.text[r];
2410
+ for (let c = 0; c < row.length; c++) {
2411
+ const ref = cellRef(r, c);
2412
+ if (s.text(name, ref).toLowerCase().includes(q))
2413
+ this.findHits.push({ sheet: name, ref });
2414
+ }
2415
+ }
2416
+ }
2417
+ for (const [k] of snap.changes) {
2418
+ const { sheet, ref } = splitKey(k);
2419
+ if (!ref.startsWith('#') && s.text(sheet, ref).toLowerCase().includes(q) && !this.findHits.some((h) => h.sheet === sheet && h.ref === ref))
2420
+ this.findHits.push({ sheet, ref });
2421
+ }
2422
+ }
2423
+ onOverlayClick(e) {
2424
+ const t = e.target, s = this._store, snap = s.getSnapshot();
2425
+ const act = t.closest('[data-act]')?.dataset.act;
2426
+ const pin = t.closest('[data-pin]');
2427
+ if (pin) {
2428
+ const err = snap.errors.find((x) => x.id === pin.dataset.pin);
2429
+ if (err) {
2430
+ const r = parseRange(err.range);
2431
+ this.anchor = { row: r.top, col: r.left };
2432
+ s.select({ sheet: err.sheet, ref: cellRef(r.top, r.left) }, this);
2433
+ }
2434
+ return;
2435
+ }
2436
+ const inCtx = !!t.closest('.ctxmenu:not(.hdrmenu)');
2437
+ if (inCtx) {
2438
+ this.ctxOpen = false;
2439
+ this.schedule();
2440
+ queueMicrotask(() => this.q.grid.focus({ preventScroll: true }));
2441
+ }
2442
+ if (!act) {
2443
+ const c = t.closest('[data-control]');
2444
+ if (c) {
2445
+ this.runControl(e);
2446
+ if (c.dataset.menu === 'header') {
2447
+ this.hdrMenu = undefined;
2448
+ this.schedule();
2449
+ this.q.grid.focus({ preventScroll: true });
2450
+ }
2451
+ }
2452
+ return;
2453
+ }
2454
+ const sel = snap.selection;
2455
+ const err = this.errorAt(sel.sheet, sel.ref);
2456
+ switch (act) {
2457
+ case 'hfit':
2458
+ if (this.hdrMenu) {
2459
+ const m = this.hdrMenu;
2460
+ this.hdrMenu = undefined;
2461
+ this.fit(m.axis, m.indexes, 'menu');
2462
+ this.q.grid.focus({ preventScroll: true });
2463
+ }
2464
+ break;
2465
+ case 'hreset':
2466
+ if (this.hdrMenu) {
2467
+ const m = this.hdrMenu;
2468
+ this.hdrMenu = undefined;
2469
+ this.setSizes(m.axis, m.indexes, undefined, 'reset');
2470
+ this.q.grid.focus({ preventScroll: true });
2471
+ }
2472
+ break;
2473
+ case 'hsize':
2474
+ if (this.hdrMenu) {
2475
+ this.hdrMenu.sizing = true;
2476
+ this.schedule();
2477
+ requestAnimationFrame(() => this.q.overlay.querySelector('.szpop input')?.select());
2478
+ }
2479
+ break;
2480
+ case 'happly':
2481
+ this.applySizeField();
2482
+ break;
2483
+ case 'hcancel':
2484
+ this.hdrMenu = undefined;
2485
+ this.schedule();
2486
+ this.q.grid.focus({ preventScroll: true });
2487
+ break;
2488
+ case 'flag':
2489
+ this.flag();
2490
+ break;
2491
+ case 'copy-ref':
2492
+ this.copy(this.refOf(sel));
2493
+ break;
2494
+ case 'copy-value':
2495
+ this.copySelection();
2496
+ break;
2497
+ case 'resolve':
2498
+ if (err)
2499
+ s.updateError(err.id, { status: err.status === 'open' ? 'resolved' : 'open' }, this);
2500
+ break;
2501
+ case 'delete':
2502
+ if (err)
2503
+ s.deleteError(err.id, this);
2504
+ break;
2505
+ case 'prev':
2506
+ case 'next':
2507
+ this.stepError(act === 'next' ? 1 : -1);
2508
+ break;
2509
+ case 'sev':
2510
+ this.draft.severity = t.closest('[data-sev]').dataset.sev;
2511
+ this.composerKey = '';
2512
+ this.schedule();
2513
+ break;
2514
+ case 'cancel':
2515
+ s.compose(null);
2516
+ this.q.grid.focus({ preventScroll: true });
2517
+ break;
2518
+ case 'add':
2519
+ this.addFromComposer();
2520
+ break;
2521
+ case 'pick-option': {
2522
+ const m = this.menu;
2523
+ this.menu = undefined;
2524
+ const c = m && s.plugins.flatMap((p) => p.controls ?? []).find((x) => x.id === m.control);
2525
+ if (m && c?.choose)
2526
+ c.choose(s, { sheet: m.sheet, ref: m.ref }, t.closest('[data-v]').dataset.v);
2527
+ this.schedule();
2528
+ break;
2529
+ }
2530
+ }
2531
+ }
2532
+ onComposerInput(e) {
2533
+ const t = e.target;
2534
+ const f = t.dataset.field;
2535
+ if (!f)
2536
+ return;
2537
+ if (f === 'type') {
2538
+ const ty = this._errorTypes.find((x) => x.id === t.value);
2539
+ this.draft.type = t.value;
2540
+ if (ty)
2541
+ this.draft.severity = ty.severity;
2542
+ this.composerKey = '';
2543
+ this.schedule();
2544
+ }
2545
+ else if (f === 'message')
2546
+ this.draft.message = t.value;
2547
+ else if (f === 'expected')
2548
+ this.draft.expected = t.value;
2549
+ else if (f === 'change')
2550
+ this.draft.change = t.checked;
2551
+ }
2552
+ addFromComposer() {
2553
+ const s = this._store, c = s.getSnapshot().composing;
2554
+ if (!c)
2555
+ return;
2556
+ let range = c.range ?? c.ref;
2557
+ if (this.draft.type === 'duplicate-row') {
2558
+ const p = parseRef(c.ref);
2559
+ const g = this.geometry();
2560
+ range = `${cellRef(p.row, 0)}:${cellRef(p.row, (g?.g.cols ?? 1) - 1)}`;
2561
+ }
2562
+ s.addError({ sheet: c.sheet, range, type: this.draft.type, severity: this.draft.severity, expected: this.draft.expected, message: this.draft.message, cellChanged: this.draft.change, author: this.getAttribute('author') ?? '' }, this);
2563
+ this.q.grid.focus({ preventScroll: true });
2564
+ }
2565
+ stepError(dir) {
2566
+ const s = this._store, snap = s.getSnapshot();
2567
+ const list = snap.errors.filter((e) => e.sheet === snap.selection.sheet);
2568
+ if (!list.length)
2569
+ return;
2570
+ const cur = this.errorAt(snap.selection.sheet, snap.selection.ref);
2571
+ const i = cur ? list.indexOf(cur) : -1;
2572
+ const nx = list[(i + dir + list.length) % list.length];
2573
+ const r = parseRange(nx.range);
2574
+ this.anchor = { row: r.top, col: r.left };
2575
+ s.select({ sheet: nx.sheet, ref: cellRef(r.top, r.left) }, this);
2576
+ }
2577
+ errorAt(sheet, ref) {
2578
+ const p = parseRef(ref);
2579
+ if (!p)
2580
+ return undefined;
2581
+ const hits = this._store.getSnapshot().errors.filter((e) => {
2582
+ if (e.sheet !== sheet)
2583
+ return false;
2584
+ const r = parseRange(e.range);
2585
+ return !!r && p.row >= r.top && p.row <= r.bottom && p.col >= r.left && p.col <= r.right;
2586
+ });
2587
+ return hits.sort((a, b) => (a.status === 'open' ? 0 : 1) - (b.status === 'open' ? 0 : 1) || sevOrder[b.severity] - sevOrder[a.severity])[0];
2588
+ }
2589
+ chooseControl(e) {
2590
+ const t = e.target;
2591
+ if (t.tagName !== 'SELECT' || !t.dataset.control)
2592
+ return;
2593
+ const c = this._store.plugins.flatMap((p) => p.controls ?? []).find((x) => x.id === t.dataset.control);
2594
+ c?.choose?.(this._store, this._store.getSnapshot().selection, t.value);
2595
+ }
2596
+ runControl(e) {
2597
+ const b = e.target.closest('[data-control]');
2598
+ if (!b || b.tagName === 'SELECT')
2599
+ return;
2600
+ const c = this._store.plugins.flatMap((p) => p.controls ?? []).find((x) => x.id === b.dataset.control);
2601
+ const snap = this._store.getSnapshot();
2602
+ if (c?.disabled?.(snap, snap.selection))
2603
+ return;
2604
+ c?.run?.(this._store, snap.selection);
2605
+ }
2606
+ runCellControl(mk) {
2607
+ const s = this._store;
2608
+ const c = s.plugins.flatMap((p) => p.controls ?? []).find((x) => x.id === mk.dataset.control);
2609
+ const cell = mk.closest('.c');
2610
+ const sel = { sheet: s.getSnapshot().selection.sheet, ref: cellRef(Number(cell.dataset.r), Number(cell.dataset.c)) };
2611
+ s.select(sel, this);
2612
+ const ctx = s.cellContext(sel.sheet, sel.ref);
2613
+ if (c?.cellOptions && c.choose && ctx) {
2614
+ this.menu = { control: c.id, sheet: sel.sheet, ref: sel.ref, options: c.cellOptions(ctx) };
2615
+ this.schedule();
2616
+ return;
2617
+ }
2618
+ c?.run?.(s, sel);
2619
+ }
2620
+ menu;
2621
+ focusMenu = false;
2622
+ // ── rendering ────────────────────────────────────────────────────────────
2623
+ schedule() {
2624
+ if (this.raf || !this.built)
2625
+ return;
2626
+ this.raf = requestAnimationFrame(() => { this.raf = 0; this.render(); });
2627
+ }
2628
+ /** Renders now (tests and the first frame). */
2629
+ renderNow() { cancelAnimationFrame(this.raf); this.raf = 0; this.render(); }
2630
+ geometry() {
2631
+ const s = this._store, snap = s?.getSnapshot();
2632
+ const sheet = snap && s.sheet(snap.selection.sheet);
2633
+ if (!sheet)
2634
+ return undefined;
2635
+ const merges = s.merges(sheet.name);
2636
+ const hid = s.hidden(sheet.name), rev = this.revealed[sheet.name];
2637
+ const ol = s.outline(sheet.name);
2638
+ const mk = `${merges.join('|')}#${this.sizesVer}#${hid.rows.join(',')}#${hid.cols.join(',')}#${this.revealVer}#${ol.levelRow},${ol.levelCol}`;
2639
+ if (this.geom?.sheet !== sheet || this.geomMerges !== mk) {
2640
+ // the file's hiding, less what this element reveals for viewing (W-49)
2641
+ const vh = this.viewHidden[sheet.name];
2642
+ const keep = (list, shown, view) => new Set([...(rev?.all ? [] : list.filter((i) => !shown?.has(i))), ...(view ?? [])]);
2643
+ this.geom = { sheet, g: sheetGeometry(sheet, merges, this._sizes[sheet.name], { rows: keep(hid.rows, rev?.rows, vh?.rows), cols: keep(hid.cols, rev?.cols, vh?.cols) }) };
2644
+ this.geomMerges = mk;
2645
+ }
2646
+ return this.geom;
2647
+ }
2648
+ geomMerges = '';
2649
+ /** Geometry as plugins see it: pixels at 100 % zoom from A1's top-left. */
2650
+ geometryFor(sheet) {
2651
+ const sh = this._store?.sheet(sheet);
2652
+ if (!sh)
2653
+ return undefined;
2654
+ const g = this.geom?.sheet === sh ? this.geom.g : sheetGeometry(sh);
2655
+ // past the last drawn column a column is 64 px wide (Excel's default), so x keeps growing
2656
+ return { colX: (c) => (c <= g.cols ? g.colX[c] : g.colX[g.cols] + (c - g.cols) * DEFAULT_COL_PX), colWidth: (c) => colWidth(g, c), rowY: (r) => rowTop(g, r), rowHeight: (r) => rowHeight(g, r) };
2657
+ }
2658
+ render() {
2659
+ const s = this._store;
2660
+ if (!s || !this.built)
2661
+ return;
2662
+ const snap = s.getSnapshot(), q = this.q;
2663
+ const ready = snap.load.status === 'ready' && !this.showEmpty;
2664
+ q.scroller.hidden = !ready;
2665
+ q.state.hidden = ready;
2666
+ for (const k of ['.bar', '.fx', '.tabs'])
2667
+ q.root.querySelector(k).hidden = !ready;
2668
+ if (!ready) {
2669
+ this.renderState();
2670
+ q.frow.hidden = true;
2671
+ q.status.textContent = 'Read in this tab — nothing is uploaded';
2672
+ return;
2673
+ }
2674
+ const book = snap.book, src = snap.source;
2675
+ const edit = snap.mode === 'edit';
2676
+ const g = this.geometry();
2677
+ const sheet = g.sheet;
2678
+ // toolbar
2679
+ q.fname.textContent = src.name;
2680
+ q.meta.textContent = `${src.kind} · ${fmtSize(src.size)} · ${src.kind === 'csv' ? delimName(book.delimiter) : `${book.sheets.length} sheet${book.sheets.length === 1 ? '' : 's'}`}`;
2681
+ for (const b of Array.from(q.modeseg.querySelectorAll('button')))
2682
+ b.setAttribute('aria-pressed', String(b.dataset.mode === snap.mode));
2683
+ const open = { error: 0, warning: 0, note: 0 };
2684
+ for (const e of snap.errors)
2685
+ if (e.status === 'open')
2686
+ open[e.severity]++;
2687
+ q.counts.innerHTML = edit ? `<span class="chip chip-fail" title="Open errors">✕ ${open.error}</span> <span class="chip chip-warn" title="Open warnings">! ${open.warning}</span> <span class="chip chip-info" title="Open notes">i ${open.note}</span>` : '';
2688
+ q.editonly.style.display = edit ? 'contents' : 'none';
2689
+ q.undo.disabled = !snap.canUndo;
2690
+ q.redo.disabled = !snap.canRedo;
2691
+ const fz = s.freeze(sheet.name);
2692
+ const frozen = !!(fz.rows || fz.cols);
2693
+ q.freeze.setAttribute('aria-pressed', String(frozen));
2694
+ q.freeze.setAttribute('aria-label', frozen ? 'Unfreeze panes' : 'Freeze panes at the selected cell');
2695
+ q.freeze.title = frozen ? 'Unfreeze panes' : 'Freeze rows above and columns left of the selected cell';
2696
+ const n = s.changeCount;
2697
+ q.changes.textContent = n === 0 ? 'No changes yet' : `${n} change${n === 1 ? '' : 's'} in memory`;
2698
+ // one Download button in View and Edit (W-31): its count says the file will carry changes
2699
+ const n2 = s.changeCount;
2700
+ q.export.querySelector('.lbl').textContent = `Download .${src.kind}`;
2701
+ const badge = q.export.querySelector('.badge-n');
2702
+ badge.hidden = n2 === 0;
2703
+ badge.textContent = String(n2);
2704
+ const tip = this.untouched() ? `Download the file as it was opened` : `Download with ${n2} change${n2 === 1 ? '' : 's'}${n2 === 0 ? ' (the review as comments)' : ''}`;
2705
+ q.export.title = tip;
2706
+ q.export.setAttribute('aria-label', `Download .${src.kind} — ${tip}`);
2707
+ q.dlmore.setAttribute('aria-expanded', String(this.dlOpen));
2708
+ const dl = this.dlOpen ? this.downloadMenuHtml(src.kind) : '';
2709
+ if (q.dlslot.dataset.html !== dl) {
2710
+ q.dlslot.innerHTML = dl;
2711
+ q.dlslot.dataset.html = dl;
2712
+ }
2713
+ // cell bar
2714
+ const sel = snap.selection;
2715
+ const p = parseRef(sel.ref);
2716
+ const merge = mergeAt(g.g, p.row, p.col);
2717
+ q.ref.textContent = sel.range ?? (merge ? `${cellRef(merge.top, merge.left)}:${cellRef(merge.bottom, merge.right)}` : sel.ref);
2718
+ const composing = !!snap.composing;
2719
+ const editable = edit && !composing;
2720
+ const cell = q.cell;
2721
+ cell.hidden = !editable;
2722
+ q.val.hidden = editable;
2723
+ q.cellname.textContent = `Value of ${sel.ref}`;
2724
+ const curText = s.input(sel.sheet, sel.ref);
2725
+ if (this.shadow.activeElement !== cell)
2726
+ cell.value = curText;
2727
+ q.val.textContent = s.text(sel.sheet, sel.ref) || '(empty)';
2728
+ q.merge.hidden = !merge;
2729
+ if (merge)
2730
+ q.merge.textContent = `Merged ${cellRef(merge.top, merge.left)}:${cellRef(merge.bottom, merge.right)} · centred`;
2731
+ const ch = s.changeOf(sel.sheet, sel.ref);
2732
+ const fmt = s.changesAt(sel.sheet, sel.ref).filter((c) => c !== ch);
2733
+ q.changed.hidden = !ch && !fmt.length;
2734
+ q.revert.hidden = (!ch && !fmt.length) || !edit;
2735
+ if (ch || fmt.length)
2736
+ q.changed.textContent = `Changed · was ${s.original(sel.sheet, sel.ref).text || '(blank)'}`; // the board's wording for any change to the cell
2737
+ const err = edit ? this.errorAt(sel.sheet, sel.ref) : undefined;
2738
+ q.issue.hidden = !err;
2739
+ if (err) {
2740
+ q.issue.className = `chip ${err.status === 'resolved' ? 'chip-pass' : err.severity === 'error' ? 'chip-fail' : err.severity === 'warning' ? 'chip-warn' : 'chip-info'}`;
2741
+ q.issue.textContent = `${err.status === 'resolved' ? '✓' : SEVERITY[err.severity].glyph} Error ${errNum(err)}`;
2742
+ }
2743
+ q.hint.hidden = !(composing && s.hasErrorsView);
2744
+ // column rules: the draft's failure as typed, or a refused value (W-27)
2745
+ const rm = this.ruleMsg && this.ruleMsg.sheet === sel.sheet && this.ruleMsg.ref === sel.ref ? this.ruleMsg : undefined;
2746
+ if (this.ruleMsg && !rm)
2747
+ this.ruleMsg = undefined; // the selection moved on
2748
+ q.rulemsg.hidden = !rm || !edit;
2749
+ cell.toggleAttribute('aria-invalid', !!rm);
2750
+ if (rm) {
2751
+ const sv = SEVERITY[rm.outcome.severity];
2752
+ q.rulemsg.className = `chip rulemsg ${rm.outcome.severity === 'error' ? 'chip-fail' : rm.outcome.severity === 'warning' ? 'chip-warn' : 'chip-info'}`;
2753
+ q.rulemsg.textContent = `${sv.glyph} ${rm.blocked ? 'Not saved — ' : ''}${rm.outcome.message}`;
2754
+ cell.title = rm.outcome.message;
2755
+ }
2756
+ else
2757
+ cell.removeAttribute('title');
2758
+ q.plugCellbar.innerHTML = this.controlsHtml(controlsFor(s.plugins, 'cellbar', snap.mode), 'btn btn-g');
2759
+ // docked selection actions (W-31): Flag issue · selbar plugin controls · ⋯ — hidden while typing or composing
2760
+ const typing = !!this.editing && this.shadow.activeElement === cell;
2761
+ q.dock.hidden = typing || composing;
2762
+ if (!q.dock.hidden) {
2763
+ const flagBtn = edit && !err && s.flagging ? `<button type="button" class="btn btn-g" data-act="flag" data-sp="flag" title="Flag issue (F)" aria-keyshortcuts="F">${ICON.flag}Flag issue<span class="kbd" aria-hidden="true">F</span></button>` : '';
2764
+ const plug = edit ? this.controlsHtml(controlsFor(s.plugins, 'selbar', 'edit'), 'btn btn-g') : '';
2765
+ const more = `<button type="button" class="btn btn-g btn-i" data-act="more" aria-haspopup="menu" aria-expanded="${this.moreOpen}" title="More actions (Shift+F10)" aria-label="More actions (Shift+F10)">⋯</button>`;
2766
+ // the ⋯ menu: Copy ref, Copy value, and the plugin buttons, which show here only once they fold out of the bar
2767
+ const menu = this.moreOpen ? `<div class="card moremenu" role="menu" aria-label="More actions">${this.actionItems(edit, err, { flag: false, plugins: true })}</div>` : '';
2768
+ const html = `${flagBtn}${plug ? `<span class="plug">${plug}</span>` : ''}<span class="morewrap">${more}${menu}</span>`;
2769
+ if (q.dock.dataset.html !== html) {
2770
+ q.dock.innerHTML = html;
2771
+ q.dock.dataset.html = html;
2772
+ }
2773
+ }
2774
+ else if (this.moreOpen)
2775
+ this.moreOpen = false;
2776
+ // format row: Edit only, and only when a plugin or the host put something in it
2777
+ const frow = controlsFor(s.plugins, 'format-row', 'edit');
2778
+ const hostSlot = q.frow.querySelector('slot').assignedElements().length > 0;
2779
+ q.frow.hidden = !edit || (!frow.length && !hostSlot);
2780
+ q.plugFrow.innerHTML = this.controlsHtml(frow, 'btn btn-g', true);
2781
+ // find
2782
+ if (this.findText)
2783
+ this.computeHits();
2784
+ // W-49: find still sees hidden cells; the count says how many, and a hit there is shown when reached
2785
+ const inHidden = this.findText ? this.findHits.filter((h) => { const hd = s.hidden(h.sheet), p2 = parseRef(h.ref); return hd.state !== 'visible' || hd.rows.includes(p2.row) || hd.cols.includes(p2.col); }).length : 0;
2786
+ q.findcnt.textContent = this.findText ? (this.findHits.length ? `${this.findIdx >= 0 ? this.findIdx + 1 : 0} of ${this.findHits.length}${inHidden ? ` (${inHidden} in hidden cells)` : ''}` : 'none') : '';
2787
+ // tabs (.xlsx only)
2788
+ q.tabs.hidden = src.kind === 'csv';
2789
+ // W-49: hidden sheets are not tabs — one ⊘ button lists them; one opened for viewing shows dashed
2790
+ const tabsHtml = book.sheets.filter((sh) => s.hidden(sh.name).state === 'visible' || this.openedSheets.has(sh.name) || sh.name === sheet.name).map((sh) => {
2791
+ const cnt = edit ? snap.errors.filter((e) => e.sheet === sh.name && e.status === 'open').length : 0;
2792
+ const hid = s.hidden(sh.name).state !== 'visible';
2793
+ return `<button type="button" role="tab" class="tab${hid ? ' hid' : ''}" data-sheet="${esc(sh.name)}" data-sp-sheet="${esc(sh.name)}" aria-selected="${sh.name === sheet.name}"${hid ? ' title="Hidden in the file — shown for viewing"' : ''}>${hid ? '⊘ ' : ''}${esc(sh.name)}${cnt ? `<span class="cnt" aria-label="${cnt} open">${cnt}</span>` : ''}</button>`;
2794
+ }).join('');
2795
+ const tabsHtml2 = `<div class="tablist" role="tablist" aria-label="Sheets">${tabsHtml}</div>${this.hiddenSheetsHtml()}`;
2796
+ if (q.tabs.dataset.html !== tabsHtml2) {
2797
+ q.tabs.innerHTML = tabsHtml2;
2798
+ q.tabs.dataset.html = tabsHtml2;
2799
+ }
2800
+ const hm = this.hiddenMenuHtml();
2801
+ if (q.hidmenuslot.dataset.html !== hm) {
2802
+ q.hidmenuslot.innerHTML = hm;
2803
+ q.hidmenuslot.dataset.html = hm;
2804
+ }
2805
+ // the banner on a hidden sheet, or the note after a reveal
2806
+ const curHidden = s.hidden(sheet.name).state !== 'visible';
2807
+ const note = this.revealNote?.sheet === sheet.name ? this.revealNote : undefined;
2808
+ const barHtml = curHidden
2809
+ ? `<span>⊘ This sheet is hidden in the file</span><button type="button" class="btn btn-g" data-act="hide-sheet-again">Hide again</button>${this.canUnhide() ? '<button type="button" class="btn btn-g" data-act="unhide-sheet">Unhide</button>' : ''}`
2810
+ : note ? `<span>${esc(note.text)}</span><button type="button" class="btn btn-g" data-act="hide-again">Hide again</button><button type="button" class="btn btn-g btn-i" data-act="note-close" aria-label="Dismiss">${ICON.close}</button>` : '';
2811
+ q.hidbar.hidden = !barHtml;
2812
+ if (q.hidbar.dataset.html !== barHtml) {
2813
+ q.hidbar.innerHTML = barHtml;
2814
+ q.hidbar.dataset.html = barHtml;
2815
+ }
2816
+ this.renderGrid(g.g, sheet, snap.mode);
2817
+ // status line
2818
+ const statusPlug = controlsFor(s.plugins, 'status', snap.mode).map((c) => c.text?.(snap, sel) ?? '').filter(Boolean);
2819
+ const dataRows = sheet.rows.length, dataCols = sheet.rows.reduce((m, r) => Math.max(m, r.length), 0);
2820
+ q.status.innerHTML = [
2821
+ `<span>${esc(sheet.name)} · ${dataRows} row${dataRows === 1 ? '' : 's'} × ${dataCols} column${dataCols === 1 ? '' : 's'}${g.g.merges.length ? ` · ${g.g.merges.length} merged range${g.g.merges.length === 1 ? '' : 's'}` : ''}</span>`,
2822
+ n ? `<span>· ${n} cell change${n === 1 ? '' : 's'} kept in memory until you export</span>` : '',
2823
+ (() => { const t = this.totalsFor(g.g); return t ? `<span class="totals" data-sp="totals">· ${esc(t)}</span>` : ''; })(),
2824
+ (() => {
2825
+ const h = s.hidden(sheet.name);
2826
+ if (!h.rows.length && !h.cols.length)
2827
+ return '';
2828
+ const parts = [h.cols.length ? `${h.cols.length} column${h.cols.length === 1 ? '' : 's'}` : '', h.rows.length ? `${h.rows.length} row${h.rows.length === 1 ? '' : 's'}` : ''].filter(Boolean).join(', ');
2829
+ const all = this.revealed[sheet.name]?.all;
2830
+ return `<span>· ${parts} hidden · <button type="button" class="link" data-act="show-hidden" aria-pressed="${!!all}">${all ? 'Hide them again' : 'Show hidden'}</button></span>`;
2831
+ })(),
2832
+ ...statusPlug.map((t) => `<span>${esc(t)}</span>`),
2833
+ this.notice ? `<span role="status" class="warn-t">· ${esc(this.notice)}</span>` : '',
2834
+ s.ruleProgress ? `<span role="status">· Checking column rules · ${Math.floor((100 * s.ruleProgress.done) / Math.max(1, s.ruleProgress.total))} %</span>` : '',
2835
+ `<span class="spacer"></span><span>Read in this tab — nothing uploaded</span>`,
2836
+ this.getAttribute('display') === 'popup' ? '<span>Esc to close</span>' : '',
2837
+ ].join('');
2838
+ }
2839
+ /** Count · Sum · Average of a range of two or more cells (W-46 DoD 8), cached until the range or the cells change. */
2840
+ totalsFor(g) {
2841
+ const s = this._store, snap = s.getSnapshot(), sel = snap.selection;
2842
+ const r = sel.range ? parseRange(sel.range) : undefined;
2843
+ if (!r || cellCount(r) < 2)
2844
+ return '';
2845
+ const key = [sel.sheet, sel.range, sel.ref, snap.book, snap.changes, snap.derived, this.revealVer];
2846
+ if (key.length === this.totalsKey.length && key.every((k, i) => Object.is(k, this.totalsKey[i])))
2847
+ return this.totalsText;
2848
+ this.totalsKey = key;
2849
+ const t = totalsOf(r, (row, col) => s.value(sel.sheet, cellRef(row, col)), g.rows - 1, { row: (i) => this.shownRow(g, i), col: (i) => this.shownCol(g, i) });
2850
+ const left = this.hiddenIn(g, r);
2851
+ // the active cell's number format when a format plugin (numfmt) is installed, else locale grouping
2852
+ const formatted = s.plugins.some((p) => p.provides.includes('format'));
2853
+ const fmt = (v) => (formatted ? s.format(sel.sheet, sel.ref, v) : new Intl.NumberFormat(undefined, { maximumFractionDigits: 10 }).format(v));
2854
+ this.totalsText = (t.numbers ? `Count ${t.count.toLocaleString()} · Sum ${fmt(t.sum)} · Average ${fmt(t.sum / t.numbers)}` : `Count ${t.count.toLocaleString()}`) + (left ? ` (${left} hidden cell${left === 1 ? '' : 's'} left out)` : '');
2855
+ return this.totalsText;
2856
+ }
2857
+ controlsHtml(list, cls, separators = false) {
2858
+ const snap = this._store.getSnapshot();
2859
+ let group = '';
2860
+ return list.map((c) => {
2861
+ const sep = separators && group && c.group !== group ? '<span class="sep" aria-hidden="true"></span>' : '';
2862
+ group = c.group;
2863
+ if (c.options && c.choose) {
2864
+ const cur = c.value?.(snap, snap.selection) ?? '';
2865
+ return `${sep}<select class="input" data-control="${esc(c.id)}" aria-label="${esc(c.label)}" title="${esc(c.label)}" style="height:28px;font-size:12px">${c.options(snap, snap.selection).map((o) => `<option value="${esc(o.value)}"${o.value === cur ? ' selected' : ''}>${esc(o.label)}</option>`).join('')}</select>`;
2866
+ }
2867
+ if (c.text && !c.run) { // read-only text (a status-like control in another slot): shown, not pressed
2868
+ const t = c.text(snap, snap.selection);
2869
+ return t ? `${sep}<span class="ptext" data-control="${esc(c.id)}" title="${esc(c.label)}">${esc(t)}</span>` : '';
2870
+ }
2871
+ const active = c.isActive?.(snap, snap.selection);
2872
+ const label = c.labelFor?.(snap, snap.selection) ?? c.label;
2873
+ const named = `${label}${c.shortcut ? ` (${c.shortcut})` : ''}`;
2874
+ const off = c.disabled?.(snap, snap.selection) ?? false;
2875
+ const why = off ? c.disabledReason?.(snap, snap.selection) : undefined; // W-49: a disabled control says why
2876
+ return `${sep}<button type="button" class="${cls}" data-control="${esc(c.id)}" aria-label="${esc(named)}" title="${esc(why ? `${named} — ${why}` : named)}"${active !== undefined ? ` aria-pressed="${active}"` : ''}${off ? ' disabled' : ''}>${c.icon ?? ''}${c.icon ? '' : esc(label)}</button>`;
2877
+ }).join('');
2878
+ }
2879
+ renderState() {
2880
+ const snap = this._store.getSnapshot(), st = this.q.state;
2881
+ let html;
2882
+ if (!this.showEmpty && snap.load.status === 'error') {
2883
+ const f = snap.load;
2884
+ const pw = f.detail === 'password';
2885
+ html = `<div class="msg ${pw ? 'warn' : 'fail'}" role="alert"><span class="g ${pw ? 'g-warning' : 'g-error'}">${pw ? '!' : '✕'}</span><div style="display:flex;flex-direction:column;gap:8px"><span class="t">${pw ? 'Password protected' : 'Can’t open this file'}</span><span>${esc(f.message)}</span><span class="xs faint code">${esc(f.spCode)}</span><div><button type="button" class="btn again">Choose another file</button></div></div></div>`;
2886
+ }
2887
+ else if (snap.load.status === 'loading') {
2888
+ html = `<div class="msg" role="status"><div style="display:flex;flex-direction:column;gap:6px"><span class="t">Reading</span><span>${esc(snap.load.name)}</span></div></div>`;
2889
+ }
2890
+ else {
2891
+ html = `<div class="drop"><span class="faint">${ICON.upload}</span><span class="t" style="font-weight:600">No workbook yet</span><span>Drop an .xlsx or .csv file here</span><span class="xs faint">Up to ${MAX_BYTES / 2 ** 20} MB · or <button type="button" class="link browse">browse your files</button></span>
2892
+ <div class="row"><label style="display:contents"><span class="vh">Open from a link</span><input class="input linkin" placeholder="Or open from a link"></label><button type="button" class="btn openlink">Open</button></div>
2893
+ <div class="row"><label style="display:contents"><span class="vh">Paste base64</span><textarea class="input b64in" rows="1" placeholder="Or paste base64" style="min-height:28px"></textarea></label><button type="button" class="btn openb64">Open</button></div>
2894
+ <span class="xs faint">Read in this tab — nothing is uploaded</span></div>`;
2895
+ }
2896
+ if (st.dataset.html !== html) {
2897
+ st.innerHTML = html;
2898
+ st.dataset.html = html;
2899
+ }
2900
+ }
2901
+ renderGrid(g, sheet, mode) {
2902
+ const s = this._store, snap = s.getSnapshot(), q = this.q, z = this.zoom;
2903
+ const edit = mode === 'edit';
2904
+ const fz = s.freeze(sheet.name);
2905
+ const fr = Math.min(fz.rows, g.rows), fc = Math.min(fz.cols, g.cols);
2906
+ const HH = this.headH() * z, RW = this.rowHeadW() * z;
2907
+ const X = (c) => g.colX[c] * z;
2908
+ const W = (c) => (g.colX[c + 1] - g.colX[c]) * z;
2909
+ const Y = (r) => rowTop(g, r) * z, H = (r) => rowHeight(g, r) * z;
2910
+ const totalW = RW + X(g.cols), totalH = HH + Y(g.rows);
2911
+ const sc = q.scroller;
2912
+ q.sheet.style.cssText = `width:${totalW}px;height:${totalH}px;font-size:${14 * z}px`;
2913
+ if (this.scrollToSel && sc.clientWidth > 0) {
2914
+ this.scrollToSel = false;
2915
+ this.scrollSelIntoView(g, z, fr, fc);
2916
+ }
2917
+ const win = visibleWindow(g, z, sc.scrollTop, sc.scrollLeft, sc.clientHeight || 600, sc.clientWidth || 900, { rows: fr, cols: fc });
2918
+ const grid = q.grid;
2919
+ grid.style.cssText = `position:relative;width:${totalW}px;height:${totalH}px`;
2920
+ grid.setAttribute('aria-rowcount', String(g.rows + 1));
2921
+ grid.setAttribute('aria-colcount', String(g.cols + 1));
2922
+ grid.setAttribute('aria-readonly', String(!edit));
2923
+ // selection, find, errors
2924
+ const sel = snap.selection;
2925
+ const selP = parseRef(sel.ref);
2926
+ const hitSet = new Set(this.findText ? this.findHits.filter((h) => h.sheet === sheet.name).map((h) => h.ref) : []);
2927
+ const curHit = this.findIdx >= 0 ? this.findHits[this.findIdx] : undefined;
2928
+ const marks = new Map();
2929
+ const rowWorst = new Map();
2930
+ if (edit)
2931
+ for (const e of snap.errors) {
2932
+ if (e.sheet !== sheet.name)
2933
+ continue;
2934
+ const r = parseRange(e.range);
2935
+ if (!r)
2936
+ continue;
2937
+ const m = e.status === 'resolved' ? 'm-pass' : `m-${e.severity}`;
2938
+ for (let rr = r.top; rr <= Math.min(r.bottom, r.top + 2000); rr++)
2939
+ for (let cc = r.left; cc <= r.right; cc++)
2940
+ marks.set(`${rr},${cc}`, m);
2941
+ if (e.status === 'open')
2942
+ for (let rr = r.top; rr <= Math.min(r.bottom, r.top + 2000); rr++) {
2943
+ const w = rowWorst.get(rr);
2944
+ if (!w || sevOrder[e.severity] > sevOrder[w])
2945
+ rowWorst.set(rr, e.severity);
2946
+ }
2947
+ }
2948
+ const cellControls = controlsFor(s.plugins, 'cell', mode);
2949
+ const decorating = s.plugins.some((p) => p.provides.includes('decorate'));
2950
+ const covered = (r, c) => { const m = mergeAt(g, r, c); return m && !(m.top === r && m.left === c) ? m : undefined; };
2951
+ const cellHtml = (r, c, frozenCol) => {
2952
+ if (covered(r, c))
2953
+ return '';
2954
+ const ref = cellRef(r, c);
2955
+ const m = g.merges.find((x) => x.top === r && x.left === c);
2956
+ const v = s.value(sheet.name, ref);
2957
+ const text = s.text(sheet.name, ref);
2958
+ const cls = ['c'];
2959
+ if (frozenCol)
2960
+ cls.push('fz');
2961
+ if (this.isRevealed(sheet.name, 'rows', r) || this.isRevealed(sheet.name, 'cols', c))
2962
+ cls.push('rev');
2963
+ if (typeof v === 'number')
2964
+ cls.push('n');
2965
+ if (m)
2966
+ cls.push('merge');
2967
+ if (hitSet.has(ref))
2968
+ cls.push('hit');
2969
+ if (curHit && curHit.sheet === sheet.name && curHit.ref === ref)
2970
+ cls.push('cur');
2971
+ if (s.changesAt(sheet.name, ref).length)
2972
+ cls.push('chg');
2973
+ const mk = marks.get(`${r},${c}`);
2974
+ if (mk)
2975
+ cls.push(mk);
2976
+ const stale = s.mayBeStale(sheet.name, ref);
2977
+ if (stale)
2978
+ cls.push('stale');
2979
+ if (snap.derived.has(`${sheet.name}!${ref}`) && !s.changeOf(sheet.name, ref))
2980
+ cls.push('derived');
2981
+ const w = m ? X(m.right + 1) - X(m.left) : W(c);
2982
+ const h = m ? Y(m.bottom + 1) - Y(m.top) : H(r);
2983
+ let style = frozenCol ? `left:${RW + X(c)}px;width:${W(c)}px;height:${H(r)}px;flex:none` : `left:${RW + X(c)}px;width:${w}px;height:${h}px`;
2984
+ if (decorating) {
2985
+ const d = s.decorate(sheet.name, ref);
2986
+ for (const [k2, v2] of Object.entries(d)) {
2987
+ // cells are a column of one text line: vertical alignment is the column's justify-content
2988
+ if (k2 === 'vertical-align')
2989
+ style += `;justify-content:${v2 === 'top' ? 'flex-start' : v2 === 'bottom' ? 'flex-end' : 'center'}`;
2990
+ else
2991
+ style += `;${k2}:${v2}`;
2992
+ }
2993
+ }
2994
+ let extra = '';
2995
+ if (cellControls.length) {
2996
+ const ctx = s.cellContext(sheet.name, ref);
2997
+ for (const cc of cellControls)
2998
+ if (ctx && cc.appliesTo?.(ctx))
2999
+ extra += `<button type="button" class="mk" data-control="${esc(cc.id)}" aria-label="${esc(cc.label)}" tabindex="-1">${cc.icon ?? '▾'}</button>`;
3000
+ }
3001
+ const title = stale ? ' title="May be stale — Excel recalculates on open"' : text.length > 24 ? ` title="${esc(text)}"` : '';
3002
+ return `<div id="c${r}_${c}" role="gridcell" class="${cls.join(' ')}" data-r="${r}" data-c="${c}" data-sp-ref="${cellRef(r, c)}" aria-colindex="${c + 2}" aria-selected="false" style="${style}"${title}><span class="v">${esc(text)}</span>${extra}</div>`;
3003
+ };
3004
+ const rowHead = (r) => {
3005
+ const w = rowWorst.get(r);
3006
+ return `<div class="rh${this.isRevealed(sheet.name, 'rows', r) ? ' rev' : ''}" role="rowheader" data-hr="${r}" style="width:${RW}px;height:${H(r)}px">${w ? `<span class="g g-${w}" aria-label="${SEVERITY[w].name}">${SEVERITY[w].glyph}</span>` : ''}${r + 1}${r > 0 ? `<span class="rz rzs" data-rz-r="${r - 1}" aria-hidden="true"></span>` : ''}<span class="rz rze" data-rz-r="${r}" aria-hidden="true"></span></div>`;
3007
+ };
3008
+ const cellsOf = (r) => {
3009
+ let out = '';
3010
+ for (let c = 0; c < fc; c++)
3011
+ if (W(c) > 0)
3012
+ out += cellHtml(r, c, true);
3013
+ for (let c = win.c0; c <= win.c1; c++)
3014
+ if (W(c) > 0)
3015
+ out += cellHtml(r, c, false);
3016
+ // merges that start left of the window but reach into it
3017
+ for (const m of g.merges)
3018
+ if (m.top === r && m.left < win.c0 && m.left >= fc && m.right >= win.c0)
3019
+ out += cellHtml(r, m.left, false);
3020
+ return out;
3021
+ };
3022
+ // header row
3023
+ let hdr = `<div class="corner" role="columnheader" data-hall title="Select all" style="width:${RW}px;height:${HH}px"><span class="vh">Row</span></div>`;
3024
+ const chTop = HH - HEAD_H * z; // the column outline gutter sits above the letters
3025
+ const zones = (c) => `${c > 0 ? `<span class="rz rzs" data-rz-c="${c - 1}" aria-hidden="true"></span>` : ''}<span class="rz rze" data-rz-c="${c}" aria-hidden="true"></span>`;
3026
+ for (let c = 0; c < fc; c++)
3027
+ if (W(c) > 0)
3028
+ hdr += `<div class="ch fz${this.isRevealed(sheet.name, 'cols', c) ? ' rev' : ''}" role="columnheader" data-hc="${c}" style="left:${RW + X(c)}px;width:${W(c)}px;height:${HEAD_H * z}px;flex:none;align-self:flex-end" aria-colindex="${c + 2}">${colName(c)}${zones(c)}</div>`;
3029
+ for (let c = win.c0; c <= win.c1; c++)
3030
+ if (W(c) > 0)
3031
+ hdr += `<div class="ch${this.isRevealed(sheet.name, 'cols', c) ? ' rev' : ''}" role="columnheader" data-hc="${c}" style="left:${RW + X(c)}px;top:${chTop}px;width:${W(c)}px;height:${HEAD_H * z}px" aria-colindex="${c + 2}">${colName(c)}${zones(c)}</div>`;
3032
+ // W-49: where the file hides columns, a ◂▸ marker on the header boundary
3033
+ const hmarks = this.hiddenMarks(sheet.name, g, win, fr, fc);
3034
+ hmarks.layer += this.outlineMarks(sheet.name, g, win);
3035
+ hdr += hmarks.cols;
3036
+ // frozen rows (sticky), then the window
3037
+ let frozen = '';
3038
+ for (let r = 0; r < fr; r++)
3039
+ if (H(r) > 0)
3040
+ frozen += `<div class="frozen" role="row" aria-rowindex="${r + 2}" style="top:${HH + Y(r)}px;height:${H(r)}px;width:${totalW}px;position:sticky">${rowHead(r)}${cellsOf(r)}</div>`;
3041
+ const rows = new Set();
3042
+ for (let r = win.r0; r <= win.r1; r++)
3043
+ if (H(r) > 0)
3044
+ rows.add(r);
3045
+ for (const m of g.merges)
3046
+ if (m.top < win.r0 && m.top >= fr && m.bottom >= win.r0 && H(m.top) > 0)
3047
+ rows.add(m.top);
3048
+ let body = '';
3049
+ for (const r of [...rows].sort((a, b) => a - b))
3050
+ body += `<div class="row" role="row" aria-rowindex="${r + 2}" style="top:${HH + Y(r)}px;height:${H(r)}px;width:${totalW}px">${rowHead(r)}${cellsOf(r)}</div>`;
3051
+ const key = `${hdr}|${frozen}|${body}|${hmarks.layer}`;
3052
+ if (key !== this.lastBody) {
3053
+ this.lastBody = key;
3054
+ q.hidlayer.innerHTML = hmarks.layer;
3055
+ q.hrow.innerHTML = hdr;
3056
+ q.hrow.style.cssText = `width:${totalW}px;height:${HH}px`;
3057
+ q.frozen.innerHTML = frozen;
3058
+ q.rows.innerHTML = body;
3059
+ this.selKey = '';
3060
+ this.ariaOn.clear(); // new nodes: the layer's bands and aria-selected go on again
3061
+ }
3062
+ grid.setAttribute('aria-activedescendant', `c${selP.row}_${selP.col}`);
3063
+ this.drawSelection();
3064
+ this.renderOverlay(g, sheet, z, RW, HH, Y, H, X, W, win);
3065
+ }
3066
+ /**
3067
+ * The selection layer (W-46 DoD 1, 2): per pane part a fill (a hole at the active cell), one border on
3068
+ * the range's own edges, the active cell's ring, the copied outline and — after a touch — two handles.
3069
+ * The scrolling part sits in `.sellayer`; the frozen-column part sticks left there; the frozen-row
3070
+ * parts are bands inside the frozen rows. No cell is re-created: only `aria-selected` changes on them.
3071
+ */
3072
+ drawSelection() {
3073
+ const s = this._store, g = this.geometry();
3074
+ if (!s || !g || !this.built || s.getSnapshot().load.status !== 'ready')
3075
+ return;
3076
+ const snap = s.getSnapshot(), q = this.q, z = this.zoom, gg = g.g;
3077
+ const HH = this.headH() * z, RW = this.rowHeadW() * z;
3078
+ const X = (c) => gg.colX[Math.min(c, gg.cols)] * z;
3079
+ const Y = (r) => rowTop(gg, r) * z, H = (r) => rowHeight(gg, r) * z;
3080
+ const fz = s.freeze(g.sheet.name), fr = Math.min(fz.rows, gg.rows), fc = Math.min(fz.cols, gg.cols);
3081
+ const d = this.drag, sel = snap.selection;
3082
+ const active = d && d.kind === 'cell' ? d.anchor : parseRef(sel.ref);
3083
+ const am = mergeAt(gg, active.row, active.col) ?? { top: active.row, left: active.col, bottom: active.row, right: active.col };
3084
+ const range = d ? this.dragRange(d) : sel.range ? parseRange(sel.range) ?? am : am;
3085
+ const multi = !(range.top === am.top && range.left === am.left && range.bottom === am.bottom && range.right === am.right);
3086
+ const copied = this.copied && this.copied.sheet === g.sheet.name ? this.copied.range : undefined;
3087
+ if (d)
3088
+ q.ref.textContent = `${range.bottom - range.top + 1}R × ${range.right - range.left + 1}C`;
3089
+ const key = [g.sheet.name, z, fr, fc, gg.rows, gg.cols, rangeText(range), rangeText(am), copied ? rangeText(copied) : '', this.touchUi && !d && multi ? 1 : 0].join('|');
3090
+ if (key === this.selKey)
3091
+ return;
3092
+ this.selKey = key;
3093
+ let layer = '';
3094
+ const bands = new Map();
3095
+ const borders = (e) => `border-width:${e.top ? 2 : 0}px ${e.right ? 2 : 0}px ${e.bottom ? 2 : 0}px ${e.left ? 2 : 0}px`;
3096
+ /** One part's element: in the layer for the scrolling rows, a band per frozen row otherwise. */
3097
+ const put = (pane, r0, r1, c0, c1, cls, style) => {
3098
+ const x = RW + X(c0), w = X(c1 + 1) - X(c0);
3099
+ if (pane === 'main' || pane === 'left') {
3100
+ const pos = pane === 'left' ? `position:sticky;left:${x}px;z-index:2` : 'position:relative;z-index:1';
3101
+ layer += `<div class="${cls}" style="${pos};margin:${HH + Y(r0)}px 0 0 ${x}px;width:${w}px;height:${Y(r1 + 1) - Y(r0)}px;${style(undefined)}"></div>`;
3102
+ return;
3103
+ }
3104
+ const flow = RW + X(fc); // a corner band follows the frozen cells in the row's flex flow
3105
+ for (let r = r0; r <= r1; r++) {
3106
+ const pos = pane === 'top' ? `position:absolute;top:0;left:${x}px;z-index:1` : `position:sticky;left:${x}px;flex:none;margin:0 ${-(x - flow + w)}px 0 ${x - flow}px;z-index:3`;
3107
+ bands.set(r, (bands.get(r) ?? '') + `<div class="sband ${cls}" style="${pos};width:${w}px;height:${H(r)}px;${style(r)}"></div>`);
3108
+ }
3109
+ };
3110
+ if (multi) {
3111
+ for (const p of splitByPanes(range, fr, fc)) {
3112
+ // the fill leaves the active cell clear: an even-odd hole where it meets this part
3113
+ const hole = (row) => {
3114
+ const t = Math.max(p.top, am.top, row ?? -1), b = Math.min(p.bottom, am.bottom, row ?? Infinity), l = Math.max(p.left, am.left), rr = Math.min(p.right, am.right);
3115
+ if (t > b || l > rr)
3116
+ return '';
3117
+ const y0 = row === undefined ? Y(t) - Y(p.top) : 0, y1 = row === undefined ? Y(b + 1) - Y(p.top) : H(row);
3118
+ const x0 = X(l) - X(p.left), x1 = X(rr + 1) - X(p.left);
3119
+ return `;clip-path:polygon(evenodd,0 0,100% 0,100% 100%,0 100%,0 0,${x0}px ${y0}px,${x1}px ${y0}px,${x1}px ${y1}px,${x0}px ${y1}px,${x0}px ${y0}px)`;
3120
+ };
3121
+ put(p.pane, p.top, p.bottom, p.left, p.right, 'sf', hole);
3122
+ put(p.pane, p.top, p.bottom, p.left, p.right, 'sb', (row) => borders(row === undefined ? p.edges : { ...p.edges, top: p.edges.top && row === p.top, bottom: p.edges.bottom && row === p.bottom }));
3123
+ }
3124
+ }
3125
+ for (const p of splitByPanes(am, fr, fc))
3126
+ put(p.pane, p.top, p.bottom, p.left, p.right, 'sa', (row) => borders(row === undefined ? p.edges : { ...p.edges, top: p.edges.top && row === p.top, bottom: p.edges.bottom && row === p.bottom }));
3127
+ if (copied)
3128
+ for (const p of splitByPanes(copied, fr, fc))
3129
+ put(p.pane, p.top, p.bottom, p.left, p.right, 'sc', (row) => borders(row === undefined ? p.edges : { ...p.edges, top: p.edges.top && row === p.top, bottom: p.edges.bottom && row === p.bottom }));
3130
+ // touch: two round handles, top-left and bottom-right, to extend the range
3131
+ if (this.touchUi && !d && multi) {
3132
+ for (const [end, row, col] of [['start', range.top, range.left], ['end', range.bottom + 1, range.right + 1]]) {
3133
+ layer += `<div class="sh" data-end="${end}" style="position:relative;z-index:3;margin:${HH + Y(row) - 12}px 0 0 ${RW + X(col) - 12}px"></div>`;
3134
+ }
3135
+ }
3136
+ q.sellayer.innerHTML = layer;
3137
+ for (const b of Array.from(q.frozen.querySelectorAll('.sband')))
3138
+ b.remove();
3139
+ const frozenRows = q.frozen.children;
3140
+ for (const [r, html] of bands)
3141
+ frozenRows[r]?.insertAdjacentHTML('beforeend', html);
3142
+ // headers: tinted with an accent edge where the selection spans them
3143
+ for (const h of Array.from(q.hrow.querySelectorAll('[data-hc]'))) {
3144
+ const c = Number(h.dataset.hc);
3145
+ h.classList.toggle('on', c >= range.left && c <= range.right);
3146
+ }
3147
+ for (const h of Array.from(q.grid.querySelectorAll('.rh[data-hr]'))) {
3148
+ const r = Number(h.dataset.hr);
3149
+ h.classList.toggle('on', r >= range.top && r <= range.bottom);
3150
+ }
3151
+ // aria-selected on the rendered cells of the range
3152
+ const next = new Set();
3153
+ for (const c of Array.from(q.grid.querySelectorAll('.c[data-r]'))) {
3154
+ const r = Number(c.dataset.r), cc = Number(c.dataset.c);
3155
+ if (r >= range.top && r <= range.bottom && cc >= range.left && cc <= range.right)
3156
+ next.add(c);
3157
+ }
3158
+ for (const el of this.ariaOn)
3159
+ if (!next.has(el))
3160
+ el.setAttribute('aria-selected', 'false');
3161
+ for (const el of next)
3162
+ if (!this.ariaOn.has(el))
3163
+ el.setAttribute('aria-selected', 'true');
3164
+ this.ariaOn = next;
3165
+ }
3166
+ scrollSelIntoView(g, z, fr, fc) {
3167
+ const s = this._store, p = parseRef(s.getSnapshot().selection.ref), sc = this.q.scroller;
3168
+ const HH = this.headH() * z, RW = this.rowHeadW() * z;
3169
+ const top = rowTop(g, p.row) * z, bottom = rowTop(g, p.row + 1) * z;
3170
+ const stickTop = HH + rowTop(g, fr) * z, stickLeft = RW + g.colX[fc] * z;
3171
+ if (p.row >= fr) {
3172
+ if (top + HH < sc.scrollTop + stickTop)
3173
+ sc.scrollTop = Math.max(0, top + HH - stickTop);
3174
+ else if (bottom + HH > sc.scrollTop + sc.clientHeight)
3175
+ sc.scrollTop = bottom + HH - sc.clientHeight;
3176
+ }
3177
+ if (p.col >= fc) {
3178
+ const left = g.colX[p.col] * z + RW, right = (g.colX[p.col + 1] ?? g.colX[p.col]) * z + RW;
3179
+ if (left < sc.scrollLeft + stickLeft)
3180
+ sc.scrollLeft = Math.max(0, left - stickLeft);
3181
+ else if (right > sc.scrollLeft + sc.clientWidth)
3182
+ sc.scrollLeft = right - sc.clientWidth;
3183
+ }
3184
+ }
3185
+ renderOverlay(g, sheet, z, RW, HH, Y, H, X, W, win) {
3186
+ const s = this._store, snap = s.getSnapshot(), edit = snap.mode === 'edit', ov = this.q.overlay;
3187
+ const at = (r, c) => ({ x: RW + X(c), y: HH + Y(r) });
3188
+ const parts = [];
3189
+ // pivot badges (W-01 DoD 15): the cached cells show; Excel refreshes
3190
+ for (const pt of sheet.pivotTables) {
3191
+ const r = parseRange(pt.ref);
3192
+ if (r) {
3193
+ const p = at(r.top, r.left);
3194
+ parts.push(`<span class="badge" style="top:${p.y + 2}px;left:${p.x + 2}px" title="${esc(pt.name)}">pivot table — refreshes in Excel</span>`);
3195
+ }
3196
+ }
3197
+ const sel = snap.selection, selP = parseRef(sel.ref);
3198
+ if (edit) {
3199
+ for (const e of snap.errors) {
3200
+ if (e.sheet !== sheet.name)
3201
+ continue;
3202
+ const r = parseRange(e.range);
3203
+ if (!r || r.top > win.r1 + 40)
3204
+ continue;
3205
+ const p = at(r.top, r.left);
3206
+ const on = r.top === selP.row && selP.col >= r.left && selP.col <= r.right;
3207
+ const mach = machineAuthored(e.author);
3208
+ const left = mach ? RW + X(r.right + 1) - 18 : p.x - 6;
3209
+ parts.push(`<button type="button" class="pin ${e.status === 'resolved' ? 'resolved' : e.severity}${on ? ' on' : ''}${mach ? ' square' : ''}" data-pin="${esc(e.id)}" aria-label="${mach ? `${esc(BUILTIN_TYPE_NAMES[e.type] ?? e.type)}` : `Error ${errNum(e)}`}, ${esc(e.range)}, ${SEVERITY[e.severity].name}${e.status === 'resolved' ? ', resolved' : ''}" style="top:${p.y - 8}px;left:${left}px">${e.status === 'resolved' ? '✓' : mach ? '⚠' : errNum(e)}</button>`);
3210
+ }
3211
+ }
3212
+ const composing = snap.composing;
3213
+ const err = edit && !composing ? this.errorAt(sel.sheet, sel.ref) : undefined;
3214
+ const p = at(selP.row, selP.col);
3215
+ const cw = W(selP.col);
3216
+ const sheetW = RW + X(g.cols);
3217
+ const sideLeft = (boxW) => { let l = p.x + cw + 10; if (l + boxW > Math.max(sheetW, this.q.scroller.scrollLeft + this.q.scroller.clientWidth))
3218
+ l = p.x - boxW - 10; return Math.max(RW + 4, l); };
3219
+ const sideTop = (boxH) => Math.max(HH, Math.min(p.y - 6, Math.max(HH, this.q.scroller.scrollTop + this.q.scroller.clientHeight - boxH - 8)));
3220
+ let card = '';
3221
+ if (composing && edit && composing.sheet === sheet.name) {
3222
+ parts.push(`<span class="pin pending" aria-hidden="true" style="top:${p.y - 8}px;left:${p.x - 6}px">${snap.errors.reduce((m, e) => Math.max(m, errNum(e)), 0) + 1}</span>`);
3223
+ if (!s.hasErrorsView)
3224
+ card = this.composerHtml(sideLeft(330), sideTop(380), sel.ref);
3225
+ }
3226
+ else if (this.menu && this.menu.sheet === sheet.name && this.menu.ref === sel.ref) {
3227
+ const m = this.menu;
3228
+ card = `<div class="card menu" role="listbox" aria-label="Choices" style="top:${p.y + H(selP.row)}px;left:${p.x}px;min-width:${Math.max(cw, 140)}px;max-height:260px;overflow:auto">${m.options.map((o) => `<button type="button" role="option" class="btn btn-g" style="justify-content:flex-start;width:100%;border-radius:0" data-act="pick-option" data-v="${esc(o)}">${esc(o) || '(empty)'}</button>`).join('')}</div>`;
3229
+ }
3230
+ else if (err && s.flagging) {
3231
+ card = this.calloutHtml(err, sideLeft(300), sideTop(260));
3232
+ }
3233
+ // a header edge drag: a guide line across the grid and the size as a tip (W-47 DoD 1)
3234
+ if (this.rz?.moved) {
3235
+ const r = this.rz, sc = this.q.scroller, last = r.indexes[r.indexes.length - 1];
3236
+ if (r.axis === 'col') {
3237
+ const gx = RW + X(Math.min(last + 1, g.cols));
3238
+ parts.push(`<span class="rzguide" style="left:${gx - 1}px;top:${sc.scrollTop}px;width:2px;height:${sc.clientHeight}px"></span><span class="rztip" role="status" style="left:${gx + 6}px;top:${sc.scrollTop + HH + 4}px">Width ${r.px} px</span>`);
3239
+ }
3240
+ else {
3241
+ const gy = HH + Y(last + 1);
3242
+ parts.push(`<span class="rzguide" style="top:${gy - 1}px;left:${sc.scrollLeft}px;height:2px;width:${sc.clientWidth}px"></span><span class="rztip" role="status" style="top:${gy + 6}px;left:${sc.scrollLeft + RW + 4}px">Height ${r.px} px</span>`);
3243
+ }
3244
+ }
3245
+ if (this.hdrMenu)
3246
+ card = this.headerMenuHtml(g, z);
3247
+ // the context menu (right-click, Shift+F10, the Menu key) sits just under the cell, above any card
3248
+ if (this.ctxOpen && sel.sheet === sheet.name) {
3249
+ const mg = mergeAt(g, selP.row, selP.col);
3250
+ card = this.contextMenuHtml(p.x, (mg ? HH + Y(mg.bottom + 1) : p.y + H(selP.row)) + 2, edit, err);
3251
+ }
3252
+ if (!card && this.helpOpen) {
3253
+ const sc = this.q.scroller;
3254
+ card = `<div class="card help" role="dialog" aria-label="Keyboard shortcuts" style="top:${sc.scrollTop + 8}px;left:${sc.scrollLeft + sc.clientWidth - 300}px;width:290px"><div class="hd"><span class="t">Keyboard</span></div><div class="bd"><dl class="fe" style="grid-template-columns:120px 1fr">${this.shortcuts().map((x) => `<dt>${esc(x.keys)}</dt><dd>${esc(x.what)}</dd>`).join('')}</dl></div></div>`;
3255
+ }
3256
+ if (!card)
3257
+ this.composerKey = '';
3258
+ const html = parts.join('');
3259
+ let fixed = ov.querySelector(':scope > .dyn');
3260
+ if (!fixed) {
3261
+ fixed = document.createElement('div');
3262
+ fixed.className = 'dyn';
3263
+ fixed.style.cssText = 'position:absolute;inset:0;pointer-events:none';
3264
+ ov.append(fixed);
3265
+ }
3266
+ if (fixed.dataset.html !== html) {
3267
+ fixed.innerHTML = html;
3268
+ fixed.dataset.html = html;
3269
+ for (const el of Array.from(fixed.children))
3270
+ el.style.pointerEvents = 'auto';
3271
+ }
3272
+ let cardEl = ov.querySelector(':scope > .cardslot');
3273
+ if (!cardEl) {
3274
+ cardEl = document.createElement('div');
3275
+ cardEl.className = 'cardslot';
3276
+ ov.append(cardEl);
3277
+ }
3278
+ if (cardEl.dataset.html !== card) {
3279
+ const keepComposer = card.startsWith('<div class="card composer"') && cardEl.dataset.key === this.composerKey && this.composerKey !== '';
3280
+ if (!keepComposer) {
3281
+ cardEl.innerHTML = card;
3282
+ cardEl.dataset.html = card;
3283
+ if (this.focusMenu && card.startsWith('<div class="card menu"')) {
3284
+ this.focusMenu = false;
3285
+ cardEl.querySelector('button')?.focus();
3286
+ }
3287
+ if (card.startsWith('<div class="card composer"')) {
3288
+ this.composerKey = `${sel.sheet}!${sel.ref}|${this.draft.type}|${this.draft.severity}`;
3289
+ cardEl.dataset.key = this.composerKey;
3290
+ cardEl.querySelector('textarea')?.focus();
3291
+ }
3292
+ }
3293
+ }
3294
+ this.renderPluginOverlays(sheet, z, RW, HH, ov);
3295
+ }
3296
+ renderPluginOverlays(sheet, z, RW, HH, ov) {
3297
+ const s = this._store;
3298
+ const providers = s.plugins.filter((p) => p.provides.includes('overlay'));
3299
+ let layer = ov.querySelector(':scope > .plug');
3300
+ if (!providers.length) {
3301
+ layer?.remove();
3302
+ return;
3303
+ }
3304
+ if (!layer) {
3305
+ layer = document.createElement('div');
3306
+ layer.className = 'plug';
3307
+ layer.style.cssText = 'position:absolute;inset:0;pointer-events:none';
3308
+ ov.prepend(layer);
3309
+ }
3310
+ const geometry = this.geometryFor(sheet.name);
3311
+ const items = providers.flatMap((p) => { try {
3312
+ return p.overlay(sheet.name, { ...s.pluginContext(), geometry });
3313
+ }
3314
+ catch (e) {
3315
+ console.error(spMessage('SP-3014', `sheetpane: plugin "${p.id}" overlay failed`), e);
3316
+ return [];
3317
+ } });
3318
+ const keep = new Set();
3319
+ for (const it of items) {
3320
+ const id = `${sheet.name}|${it.id}`;
3321
+ keep.add(id);
3322
+ let el = this.overlayCache.get(id);
3323
+ if (!el) {
3324
+ el = document.createElement('div');
3325
+ el.className = 'olay';
3326
+ el.append(it.render());
3327
+ this.overlayCache.set(id, el);
3328
+ }
3329
+ // role and name follow the item on every pass (a chart's title is known only after its part is read)
3330
+ if (el.getAttribute('role') !== (it.role ?? 'img'))
3331
+ el.setAttribute('role', it.role ?? 'img');
3332
+ if (it.alt) {
3333
+ if (el.getAttribute('aria-label') !== it.alt)
3334
+ el.setAttribute('aria-label', it.alt);
3335
+ }
3336
+ else
3337
+ el.removeAttribute('aria-label');
3338
+ el.style.cssText = `left:${RW + it.rect.x * z}px;top:${HH + it.rect.y * z}px;width:${it.rect.width * z}px;height:${it.rect.height * z}px;pointer-events:${it.passThrough ? 'none' : 'auto'}`;
3339
+ if (el.parentNode !== layer)
3340
+ layer.append(el);
3341
+ }
3342
+ for (const [id, el] of this.overlayCache)
3343
+ if (!keep.has(id)) {
3344
+ el.remove();
3345
+ if (!id.startsWith(`${sheet.name}|`))
3346
+ continue;
3347
+ this.overlayCache.delete(id);
3348
+ }
3349
+ }
3350
+ calloutHtml(e, left, top) {
3351
+ const s = this._store, sv = SEVERITY[e.severity], t = this._errorTypes.find((x) => x.id === e.type);
3352
+ const tags = [e.matchesExpected ? '<span class="chip chip-pass">matches suggestion</span>' : '', s.isStale(e) ? '<span class="chip">stale</span>' : ''].join('');
3353
+ const meta = [sv.name, e.author || '', e.code ?? '', e.createdAt ? new Date(e.createdAt).toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' }) : '', e.cellChanged ? 'cell changed' : ''].filter(Boolean).join(' · ');
3354
+ const ro = machineAuthored(e.author);
3355
+ return `<div class="card callout" role="group" aria-label="Error details" data-sp="callout" style="top:${top}px;left:${left}px;width:300px">
3356
+ <div class="hd"><span class="g ${e.status === 'resolved' ? 'g-pass' : `g-${e.severity}`}" aria-hidden="true">${e.status === 'resolved' ? '✓' : sv.glyph}</span><span class="t">${esc(t?.name ?? BUILTIN_TYPE_NAMES[e.type] ?? e.type)}</span><span class="xs faint">${esc(e.range)}</span><span class="spacer"></span><button type="button" class="btn btn-g btn-i" data-act="prev" aria-label="Previous error">${ICON.prev}</button><button type="button" class="btn btn-g btn-i" data-act="next" aria-label="Next error">${ICON.next}</button></div>
3357
+ <div class="bd"><dl class="fe"><dt>Found</dt><dd><span class="found">${esc(e.found)}</span></dd><dt>Should be</dt><dd><span class="want">${esc(e.expected || '—')}</span></dd></dl>${e.message ? `<p style="margin:0;font-size:13px">${esc(e.message)}</p>` : ''}${tags ? `<div style="display:flex;gap:6px">${tags}</div>` : ''}<span class="xs faint">${esc(meta)}</span></div>
3358
+ ${ro ? '' : `<div class="ft"><button type="button" class="btn" data-act="resolve">${e.status === 'resolved' ? 'Reopen' : '✓ Resolve'}</button><span class="spacer"></span><button type="button" class="btn btn-g btn-d" data-act="delete">Delete</button></div>`}</div>`;
3359
+ }
3360
+ composerHtml(left, top, ref) {
3361
+ const s = this._store, snap = s.getSnapshot(), c = snap.composing;
3362
+ const found = s.foundFor(c.sheet, c.range ?? c.ref);
3363
+ const ex = this._errorTypes.find((x) => x.id === this.draft.type) ?? this._errorTypes[0];
3364
+ const sevs = ['error', 'warning', 'note'].map((v) => `<button type="button" data-act="sev" data-sev="${v}" aria-pressed="${this.draft.severity === v}"><span class="g g-${v}">${SEVERITY[v].glyph}</span>${SEVERITY[v].name}</button>`).join('');
3365
+ return `<div class="card composer" role="dialog" aria-label="New error" data-sp="composer" style="top:${top}px;left:${left}px;width:330px">
3366
+ <div class="hd"><span class="t">New error</span><span class="xs faint">${esc(c.range ?? ref)} · found ${esc(found)}</span><span class="spacer"></span><button type="button" class="btn btn-g btn-i" data-act="cancel" aria-label="Cancel">${ICON.close}</button></div>
3367
+ <div class="bd">
3368
+ <label class="field"><span>Type</span><select class="input" data-field="type">${this._errorTypes.map((t) => `<option value="${esc(t.id)}"${t.id === this.draft.type ? ' selected' : ''}>${esc(t.name)}</option>`).join('')}</select></label>
3369
+ <label class="field"><span>What’s wrong</span><textarea class="input" rows="2" data-field="message" placeholder="${esc(ex.why)}">${esc(this.draft.message)}</textarea></label>
3370
+ <label class="field"><span>What it should be</span><input class="input" data-field="expected" placeholder="${esc(ex.expected)}" value="${esc(this.draft.expected)}"></label>
3371
+ <label style="display:flex;gap:8px;align-items:center;font-size:13px"><input type="checkbox" data-field="change"${this.draft.change ? ' checked' : ''}>Also change the cell to this value</label>
3372
+ <div class="seg" role="group" aria-label="Severity">${sevs}</div><span class="xs faint">${esc(SEVERITY[this.draft.severity].help)}</span>
3373
+ </div>
3374
+ <div class="ft"><span class="xs faint">⌘↵ adds</span><span class="spacer"></span><button type="button" class="btn btn-p" data-act="add" data-sp="add-error">Add error</button></div></div>`;
3375
+ }
3376
+ }
3377
+ function fmtSize(n) {
3378
+ return n < 1024 ? `${n} B` : n < 2 ** 20 ? `${Math.round(n / 1024)} KB` : `${(n / 2 ** 20).toFixed(1)} MB`;
3379
+ }
3380
+ const delimName = (d) => (d === ';' ? 'semicolon' : d === '\t' ? 'tab' : 'comma');
3381
+ export { COL_VIRTUAL_AFTER, colAt };
3382
+ //# sourceMappingURL=element.js.map