@sheetpane/grid 0.3.0 → 0.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +294 -13
- package/custom-elements.json +292 -27
- package/dist/check.d.ts +75 -0
- package/dist/check.d.ts.map +1 -0
- package/dist/check.js +151 -0
- package/dist/check.js.map +1 -0
- package/dist/errors/element.d.ts +90 -12
- package/dist/errors/element.d.ts.map +1 -1
- package/dist/errors/element.js +572 -50
- package/dist/errors/element.js.map +1 -1
- package/dist/excel/element.d.ts +168 -15
- package/dist/excel/element.d.ts.map +1 -1
- package/dist/excel/element.js +752 -90
- package/dist/excel/element.js.map +1 -1
- package/dist/excel/link.d.ts +40 -0
- package/dist/excel/link.d.ts.map +1 -0
- package/dist/excel/link.js +90 -0
- package/dist/excel/link.js.map +1 -0
- package/dist/excel/styles.d.ts.map +1 -1
- package/dist/excel/styles.js +37 -5
- package/dist/excel/styles.js.map +1 -1
- package/dist/frameworks/elements.d.ts +57 -17
- package/dist/frameworks/jsx.d.ts +15 -9
- package/dist/frameworks/solid.d.ts +51 -11
- package/dist/frameworks/svelte.d.ts +28 -16
- package/dist/frameworks/vue.d.ts +15 -9
- package/dist/index.d.ts +9 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +8 -2
- package/dist/index.js.map +1 -1
- package/dist/llms-full.txt +3042 -246
- package/dist/llms.txt +92 -86
- package/dist/pane/protection.d.ts +61 -0
- package/dist/pane/protection.d.ts.map +1 -0
- package/dist/pane/protection.js +89 -0
- package/dist/pane/protection.js.map +1 -0
- package/dist/pane/rules.d.ts +1 -1
- package/dist/pane/rules.d.ts.map +1 -1
- package/dist/pane/store.d.ts +336 -15
- package/dist/pane/store.d.ts.map +1 -1
- package/dist/pane/store.js +891 -44
- package/dist/pane/store.js.map +1 -1
- package/dist/pane/structure.d.ts +58 -0
- package/dist/pane/structure.d.ts.map +1 -0
- package/dist/pane/structure.js +238 -0
- package/dist/pane/structure.js.map +1 -0
- package/dist/pane/types.d.ts +195 -6
- package/dist/pane/types.d.ts.map +1 -1
- package/dist/pane/types.js +57 -0
- package/dist/pane/types.js.map +1 -1
- package/dist/plugins/types.d.ts +34 -6
- package/dist/plugins/types.d.ts.map +1 -1
- package/dist/plugins/types.js.map +1 -1
- package/dist/shared/detail.d.ts +24 -0
- package/dist/shared/detail.d.ts.map +1 -0
- package/dist/shared/detail.js +75 -0
- package/dist/shared/detail.js.map +1 -0
- package/dist/shared/dom.d.ts +3 -0
- package/dist/shared/dom.d.ts.map +1 -1
- package/dist/shared/dom.js +3 -0
- package/dist/shared/dom.js.map +1 -1
- package/dist/static.d.ts +81 -0
- package/dist/static.d.ts.map +1 -0
- package/dist/static.js +352 -0
- package/dist/static.js.map +1 -0
- package/dist/style.css +3 -3
- package/dist/tokens.d.ts +1 -0
- package/dist/tokens.d.ts.map +1 -1
- package/dist/tokens.js +2 -2
- package/dist/tokens.js.map +1 -1
- package/dist/vscode.css-custom-data.json +11 -1
- package/dist/vscode.html-custom-data.json +89 -2
- package/dist/web-types.json +204 -21
- package/package.json +12 -4
package/dist/excel/element.js
CHANGED
|
@@ -1,16 +1,19 @@
|
|
|
1
1
|
// <sheetpane-excel> — the sheet: the view and edit components as two modes of one
|
|
2
2
|
// element (W-01). Plain DOM in an open shadow root (grid verdict decisions 2, 6);
|
|
3
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';
|
|
4
|
+
import { cellRef, codeOf, colName, detailOf, download, parseRange, parseRef, shiftIndex, shiftRangePos, spError, spMessage } from '@sheetpane/export';
|
|
5
5
|
import { controlsFor } from '../plugins/host.js';
|
|
6
6
|
import { acquirePane, attach, detach, isPrimary, releasePane } from '../pane/registry.js';
|
|
7
|
+
import { LOCKED_MESSAGE, STRUCTURE_LOCKED_MESSAGE, protectionModeOf } from '../pane/protection.js';
|
|
7
8
|
import { createWorkbook, parseInput, splitKey } from '../pane/store.js';
|
|
8
|
-
import { BUILTIN_TYPE_NAMES, DEFAULT_ERROR_TYPES, SEVERITY, machineAuthored } from '../pane/types.js';
|
|
9
|
-
import {
|
|
9
|
+
import { BUILTIN_TYPE_NAMES, DEFAULT_ERROR_TYPES, SEVERITY, SEVERITY_ORDER, errorScope, isResult, machineAuthored, resultsLevelOf } from '../pane/types.js';
|
|
10
|
+
import { DETAIL_CSS, DetailRegion, acceptedHtml, inDetail } from '../shared/detail.js';
|
|
11
|
+
import { ElementBase, ICON, esc, fire, layersAttr } from '../shared/dom.js';
|
|
10
12
|
import { pluginsFromIds } from '../plugins/host.js';
|
|
11
13
|
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
14
|
import { cellCount, clipOf, dataEdge, growForMerges, rangeText, refLabel, spanOf, splitByPanes, totalsOf, usedRange } from './selection.js';
|
|
13
15
|
import { EXCEL_CSS } from './styles.js';
|
|
16
|
+
import { parseTarget, readLink, splitSheetRef, writeLink } from './link.js';
|
|
14
17
|
const ZOOMS = [0.75, 1, 1.25, 1.5];
|
|
15
18
|
const MAX_BYTES = 50 * 2 ** 20;
|
|
16
19
|
/** A size kept inside the limits (W-47 DoD 1): columns 16–1,200 px, rows 12–600 px, at 100 %. */
|
|
@@ -37,8 +40,9 @@ export async function bytesFromSrc(src) {
|
|
|
37
40
|
}
|
|
38
41
|
/** The text the cell bar shows for a value: raw, as Excel's formula bar does. */
|
|
39
42
|
const rawText = (v) => (v === null || v === undefined ? '' : typeof v === 'boolean' ? (v ? 'TRUE' : 'FALSE') : String(v));
|
|
40
|
-
const sevOrder =
|
|
43
|
+
const sevOrder = SEVERITY_ORDER;
|
|
41
44
|
const errNum = (e) => Number(/(\d+)$/.exec(e.id)?.[1] ?? 0);
|
|
45
|
+
const TONES = ['info', 'pass', 'fail'];
|
|
42
46
|
/**
|
|
43
47
|
* `<sheetpane-excel>` — the sheet: views and edits a `.xlsx` or `.csv` file held in the pane's
|
|
44
48
|
* in-memory workbook (SR-GRID decisions 9–22e). Every change stays in memory and `export()`
|
|
@@ -58,30 +62,43 @@ const errNum = (e) => Number(/(\d+)$/.exec(e.id)?.[1] ?? 0);
|
|
|
58
62
|
* - `on-invalid` — `flag` (the default) or `block`: what a failing column rule does.
|
|
59
63
|
* - `rules-src` — a URL to a rules file (read with a GET).
|
|
60
64
|
* - `flagging` — `on` (the default) or `off`: no Flag issue anywhere.
|
|
65
|
+
* - `ref` — the cell or range selected once a file is read, scrolled into view: `B4`, `Data!B4`,
|
|
66
|
+
* `'Q3 data'!B4:D8`; setting it later selects it then. One that names no cell is warned (SP-3019) and
|
|
67
|
+
* the sheet opens at A1. React, Vue and Solid keep `ref` for their own refs — set it with `setAttribute`.
|
|
68
|
+
* - `link` — `none` (the default) or `hash`: the selection is kept in the URL fragment as
|
|
69
|
+
* `#sheetpane=<pane>:<Sheet>!<ref>`, read when a file is read and on back and forward, and *Copy link*
|
|
70
|
+
* joins the cell menu. The rest of the fragment is the host's and is kept as it was.
|
|
71
|
+
* - `results` — `summary` (the default), `marks` or `off`: how `pass` and `skipped` results show, pane-wide (W-50).
|
|
72
|
+
* - `layers` — space-separated review layers to show (W-51); none shows every layer. Shared by the pane's elements.
|
|
73
|
+
* - `protection` — `honour` (the default) or `ignore`: whether a protected sheet's locked cells refuse edits and a
|
|
74
|
+
* protected structure keeps its sheets, pane-wide (W-62). `ignore` is the host's decision; the file is unchanged.
|
|
61
75
|
*
|
|
62
76
|
* Properties: `workbook`, `errors`, `errorTypes`, `file`, `mode`, `theme`, `open`, `src`, `sizes`,
|
|
63
|
-
* `rules`, `flagging`, `columns`, `validate`, `onInvalid`.
|
|
77
|
+
* `rules`, `flagging`, `results`, `protection`, `columns`, `validate`, `onInvalid`, `renderDetail`, `ref`, `link`, `layers`.
|
|
64
78
|
*
|
|
65
79
|
* Methods: `load()`, `export()`, `select()`, `getErrors()`, `setErrors()`, `undo()`, `redo()`,
|
|
66
|
-
* `shortcuts()`, `column()`, `renderNow()`, `geometryFor()`, `reveal()`, `unreveal()
|
|
80
|
+
* `shortcuts()`, `column()`, `renderNow()`, `geometryFor()`, `reveal()`, `unreveal()`, `highlight()`,
|
|
81
|
+
* `clearHighlight()`.
|
|
67
82
|
*
|
|
68
83
|
* Every event bubbles and is composed; an action's event fires once, on the element where it
|
|
69
84
|
* happened. Cell payloads also carry `column: { index, letter, header }`.
|
|
70
85
|
*
|
|
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.
|
|
86
|
+
* @fires sheetpane-load `{ status: 'ready', name, kind, sheets, readMs, sha256 }` when a file is read (`sha256`: the file's SHA-256, W-65), or the refusal `{ status: 'error', code, spCode, message, name, detail? }`.
|
|
87
|
+
* @fires sheetpane-change `{ sheet, ref, kind, was, now, op?, at?, count? }` for each change, undo and redo included; rows or columns inserted or deleted (W-52) are one event, `kind: 'structure'`, with `op`, `at` (0-based) and `count`.
|
|
88
|
+
* @fires sheetpane-errors `{ v, errors, plugins, layers, changed }` when the pane's errors change — the envelope to save a review (`v: 2`, W-51); `changed` names the layers whose errors changed.
|
|
89
|
+
* @fires sheetpane-layers `{ visible, layers }` when the visible review layers change (W-51).
|
|
74
90
|
* @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
91
|
* @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
92
|
* @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).
|
|
93
|
+
* @fires sheetpane-hover `{ sheet, ref, column }` when a mouse or pen pointer moves onto another cell (a merge's top-left), at most once per frame, or `null` when it leaves the cells; never for a finger (W-56).
|
|
77
94
|
* @fires sheetpane-sheet `{ sheet }` when the selection moves to another sheet.
|
|
78
95
|
* @fires sheetpane-mode `{ mode }` when the pane switches between View and Edit.
|
|
79
96
|
* @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.
|
|
97
|
+
* @fires sheetpane-editstart `{ sheet, ref, column, value, richText?: true }` before a cell edit starts (`richText: true` on a rich-text cell, whose edit saves plain text — W-53); cancellable — the cell stays read-only.
|
|
81
98
|
* @fires sheetpane-input `{ sheet, ref, column, draft, was }` as the cell-bar draft is typed.
|
|
82
99
|
* @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,
|
|
84
|
-
* @fires sheetpane-export `{ blob, kind, name, guarded, format, converted, original? }` on `export()` and Download; cancellable — the Download control then saves nothing.
|
|
100
|
+
* @fires sheetpane-editcancel `{ sheet, ref }` when Esc restores a draft, `{ sheet, ref, reason: 'blocked', code: 'SP-6003', message }` when a `block` rule refuses it, or `{ sheet, ref, reason: 'locked', code: 'SP-3026', message }` when the cell is locked on a protected sheet (W-62).
|
|
101
|
+
* @fires sheetpane-export `{ blob, kind, name, guarded, format, converted, sha256, original? }` on `export()` and Download (`sha256`: the blob's SHA-256, W-65); cancellable — the Download control then saves nothing.
|
|
85
102
|
* @fires sheetpane-close `{}` when the pop-up closes.
|
|
86
103
|
*
|
|
87
104
|
* @slot format-row - Host controls for the format row (Edit only); the row shows when a plugin or this slot puts something in it.
|
|
@@ -91,6 +108,7 @@ const errNum = (e) => Number(/(\d+)$/.exec(e.id)?.[1] ?? 0);
|
|
|
91
108
|
* @csspart format-row - The format row (Edit): plugin controls and the `format-row` slot.
|
|
92
109
|
* @csspart status - The status line under the sheet tabs.
|
|
93
110
|
* @csspart dialog - The modal `<dialog>` the root moves into when `display="popup"`.
|
|
111
|
+
* @csspart detail - The host's region in the error card, after the message: what `renderDetail` returns (W-68).
|
|
94
112
|
*
|
|
95
113
|
* @example
|
|
96
114
|
* ```ts
|
|
@@ -108,7 +126,7 @@ const errNum = (e) => Number(/(\d+)$/.exec(e.id)?.[1] ?? 0);
|
|
|
108
126
|
*/
|
|
109
127
|
export class SheetpaneExcel extends ElementBase {
|
|
110
128
|
/** The attributes the element reacts to. */
|
|
111
|
-
static observedAttributes = ['pane', 'src', 'mode', 'display', 'open', 'author', 'plugins', 'header-row', 'on-invalid', 'rules-src', 'flagging'];
|
|
129
|
+
static observedAttributes = ['pane', 'src', 'mode', 'display', 'open', 'author', 'plugins', 'header-row', 'on-invalid', 'rules-src', 'flagging', 'ref', 'link', 'results', 'layers', 'protection'];
|
|
112
130
|
_store;
|
|
113
131
|
_explicitStore;
|
|
114
132
|
_paneId;
|
|
@@ -116,6 +134,7 @@ export class SheetpaneExcel extends ElementBase {
|
|
|
116
134
|
unsub = [];
|
|
117
135
|
shadow;
|
|
118
136
|
q;
|
|
137
|
+
detailRegion = new DetailRegion();
|
|
119
138
|
built = false;
|
|
120
139
|
raf = 0;
|
|
121
140
|
zoom = 1;
|
|
@@ -161,6 +180,14 @@ export class SheetpaneExcel extends ElementBase {
|
|
|
161
180
|
lastBody = '';
|
|
162
181
|
cellFocusStart;
|
|
163
182
|
overlayCache = new Map();
|
|
183
|
+
/** A mouse or pen pointer over the grid (W-56); `card` when it is over a menu or callout, not a cell. */
|
|
184
|
+
hoverAt;
|
|
185
|
+
hoverRaf = 0;
|
|
186
|
+
/** The cell last told in `sheetpane-hover` (`sheet!ref`), `''` for none. */
|
|
187
|
+
hovered = '';
|
|
188
|
+
/** The host's highlight (W-56): outlines on the overlay — a view setting, never a change or the selection; `shown` is the sheet shown when it was set. */
|
|
189
|
+
hl;
|
|
190
|
+
scrollToHl;
|
|
164
191
|
onUnload = (e) => { if (this._store && this._store.changeCount > 0) {
|
|
165
192
|
e.preventDefault();
|
|
166
193
|
e.returnValue = '';
|
|
@@ -177,6 +204,8 @@ export class SheetpaneExcel extends ElementBase {
|
|
|
177
204
|
const m = this.getAttribute('mode'); // the element's mode applies to the store it is given, as on connect
|
|
178
205
|
if (m === 'edit' || m === 'view')
|
|
179
206
|
this._store.setMode(m, this);
|
|
207
|
+
if (this.hasAttribute('layers'))
|
|
208
|
+
this._store.setVisibleLayers(layersAttr(this.getAttribute('layers')), this);
|
|
180
209
|
this.schedule();
|
|
181
210
|
}
|
|
182
211
|
/** A copy of the pane's errors; assigning restores a saved review, like `setErrors()`. */
|
|
@@ -185,9 +214,26 @@ export class SheetpaneExcel extends ElementBase {
|
|
|
185
214
|
/** The types offered in the Flag issue form; an empty list falls back to `DEFAULT_ERROR_TYPES`. */
|
|
186
215
|
get errorTypes() { return this._errorTypes; }
|
|
187
216
|
set errorTypes(t) { this._errorTypes = t.length ? t : DEFAULT_ERROR_TYPES; this.schedule(); }
|
|
217
|
+
/**
|
|
218
|
+
* The host's extra detail for an error (W-68): called with a copy of the error when its card opens in the sheet, and
|
|
219
|
+
* drawn after the message in a `part="detail"` region. A string is text, never HTML; a Node is the host's own. Called
|
|
220
|
+
* again only when the error changes (its `meta` included) or this is replaced; a throw is logged as SP-3017. Set it on
|
|
221
|
+
* each element that should show it — `<sheetpane-errors>` has its own.
|
|
222
|
+
*/
|
|
223
|
+
get renderDetail() { return this.detailRegion.render; }
|
|
224
|
+
set renderDetail(f) { this.detailRegion.render = f; this.schedule(); }
|
|
188
225
|
/** Loads a file, like `load()`; `null` does nothing. */
|
|
189
226
|
set file(f) { if (f)
|
|
190
227
|
void this.load(f); }
|
|
228
|
+
/**
|
|
229
|
+
* The review layers shown (W-51) — the pane's, shared with `<sheetpane-errors>`: every layer until the `layers`
|
|
230
|
+
* attribute or the switch names some. Setting it sets the attribute; `null` or `[]` shows every layer.
|
|
231
|
+
*/
|
|
232
|
+
get layers() { return [...(this._store?.getSnapshot().visibleLayers ?? layersAttr(this.getAttribute('layers')) ?? [])]; }
|
|
233
|
+
set layers(v) { if (v && v.length)
|
|
234
|
+
this.setAttribute('layers', v.join(' '));
|
|
235
|
+
else
|
|
236
|
+
this.removeAttribute('layers'); this._store?.setVisibleLayers(v, this); }
|
|
191
237
|
/** The pane's mode, `view` or `edit`; setting it sets the `mode` attribute. */
|
|
192
238
|
get mode() { return this._store?.getSnapshot().mode ?? (this.getAttribute('mode') || 'view'); }
|
|
193
239
|
set mode(m) { this.setAttribute('mode', m); }
|
|
@@ -225,6 +271,12 @@ export class SheetpaneExcel extends ElementBase {
|
|
|
225
271
|
/** `flagging="off"`: no Flag issue anywhere, no composer, no callout card — the errors panel is the one place errors are read. */
|
|
226
272
|
get flagging() { return this.getAttribute('flagging') === 'off' ? 'off' : 'on'; }
|
|
227
273
|
set flagging(v) { this.setAttribute('flagging', v); }
|
|
274
|
+
/** The pane's results level (W-50): `summary` (the default), `marks` or `off`; setting it sets the `results` attribute. */
|
|
275
|
+
get results() { return this._store?.results ?? resultsLevelOf(this.getAttribute('results')); }
|
|
276
|
+
set results(v) { this.setAttribute('results', v); }
|
|
277
|
+
/** The pane's protection mode (W-62): `honour` (the default) keeps the file's protection; `ignore` is the host's decision. Setting it sets the `protection` attribute. */
|
|
278
|
+
get protection() { return this._store?.protection ?? protectionModeOf(this.getAttribute('protection')); }
|
|
279
|
+
set protection(v) { this.setAttribute('protection', v); }
|
|
228
280
|
/** Rules by column title, as plain JSON (`required`, `type`, `min`, `max`, `minLength`, `maxLength`, `pattern`, `oneOf`, `unique`, `message`, `severity`, `onInvalid`). */
|
|
229
281
|
get columns() { return this.rulesCfg?.columns; }
|
|
230
282
|
set columns(c) { this.rulesCfg = { ...this.rulesCfg, columns: c ?? {} }; this.applyRules(); }
|
|
@@ -287,6 +339,15 @@ export class SheetpaneExcel extends ElementBase {
|
|
|
287
339
|
this.removeAttribute('src');
|
|
288
340
|
else
|
|
289
341
|
this.setAttribute('src', v); }
|
|
342
|
+
/** The `ref` attribute: the cell or range selected once a file is read (W-66); `null`, `undefined` or `''` removes it. */
|
|
343
|
+
get ref() { return this.getAttribute('ref'); }
|
|
344
|
+
set ref(v) { if (v === null || v === undefined || v === '')
|
|
345
|
+
this.removeAttribute('ref');
|
|
346
|
+
else
|
|
347
|
+
this.setAttribute('ref', v); }
|
|
348
|
+
/** `none` (the default) or `hash`: the selection kept in the URL fragment, and *Copy link* in the cell menu (W-66). */
|
|
349
|
+
get link() { return this.getAttribute('link') === 'hash' ? 'hash' : 'none'; }
|
|
350
|
+
set link(v) { this.setAttribute('link', v); }
|
|
290
351
|
// ── methods ──────────────────────────────────────────────────────────────
|
|
291
352
|
/** Reads a file into the pane: a File, Blob, bytes, a URL, or base64. */
|
|
292
353
|
async load(src, name) {
|
|
@@ -317,21 +378,141 @@ export class SheetpaneExcel extends ElementBase {
|
|
|
317
378
|
this.showEmpty = false;
|
|
318
379
|
await store.load(data, nm, this);
|
|
319
380
|
}
|
|
320
|
-
/** Every change applied; the upload untouched. Also dispatches `sheetpane-export
|
|
381
|
+
/** Every change applied; the upload untouched. Also dispatches `sheetpane-export`, with the blob's SHA-256 (W-65). */
|
|
321
382
|
async export(options = {}, o = {}) {
|
|
322
383
|
const store = this.ensureStore();
|
|
323
384
|
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 };
|
|
385
|
+
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, sha256: r.sha256 }, true), name: r.name };
|
|
386
|
+
this.schedule(); // a plugin's status may report on the export (W-58: rules not written to Excel)
|
|
325
387
|
return r.blob;
|
|
326
388
|
}
|
|
327
389
|
lastExport;
|
|
328
390
|
lastSheet = '';
|
|
329
391
|
/** Selects a cell and scrolls to it. */
|
|
330
392
|
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.
|
|
333
|
-
/** Restores a saved review
|
|
334
|
-
setErrors(list) { this.ensureStore().setErrors(list, this); }
|
|
393
|
+
/** A copy of the pane's errors, to save a review; `{ layer }` gives one layer's (W-51). */
|
|
394
|
+
getErrors(o = {}) { return this._store?.getErrors(o) ?? []; }
|
|
395
|
+
/** Restores a saved review — a list or the `sheetpane-errors` envelope — clearing the undo stack; `{ layer }` replaces that layer only (W-51). */
|
|
396
|
+
setErrors(list, o = {}) { this.ensureStore().setErrors(list, o, this); }
|
|
397
|
+
/**
|
|
398
|
+
* Outlines cells from outside without selecting them (W-56): a pass-through outline on the overlay —
|
|
399
|
+
* not a change, not undone, not in the download, and the selection stays where it is. Each call
|
|
400
|
+
* replaces the last; the outline goes on a sheet switch unless `pinned`, and with a new file. A range
|
|
401
|
+
* that is not A1 (`B3`, `B3:D8`, `Data!B3`, `'Q3 data'!B3:D8`) or names a sheet the workbook lacks is
|
|
402
|
+
* skipped. A view setting of this element, like zoom.
|
|
403
|
+
* @param ranges - One range or several; an object gives one its own sheet or tone.
|
|
404
|
+
* @param options - `tone`, `label` (said once by the status line), `sheet`, `pinned`, `reveal`.
|
|
405
|
+
* @returns How many ranges are outlined; 0 before a file is loaded.
|
|
406
|
+
*/
|
|
407
|
+
highlight(ranges, options = {}) {
|
|
408
|
+
const s = this._store, snap = s?.getSnapshot();
|
|
409
|
+
if (!s || !snap?.book) {
|
|
410
|
+
this.clearHighlight();
|
|
411
|
+
return 0;
|
|
412
|
+
}
|
|
413
|
+
const shown = snap.selection.sheet, tone = TONES.includes(options.tone) ? options.tone : 'info';
|
|
414
|
+
const items = [];
|
|
415
|
+
for (const it of (Array.isArray(ranges) ? ranges : [ranges])) {
|
|
416
|
+
const o = typeof it === 'string' ? { ref: it } : it;
|
|
417
|
+
if (!o || typeof o.ref !== 'string')
|
|
418
|
+
continue;
|
|
419
|
+
const at = splitSheetRef(o.ref), sheet = o.sheet ?? at.sheet ?? options.sheet ?? shown, range = parseRange(at.ref);
|
|
420
|
+
if (range && s.sheet(sheet))
|
|
421
|
+
items.push({ sheet, range, tone: o.tone && TONES.includes(o.tone) ? o.tone : tone });
|
|
422
|
+
}
|
|
423
|
+
if (!items.length) {
|
|
424
|
+
this.clearHighlight();
|
|
425
|
+
return 0;
|
|
426
|
+
}
|
|
427
|
+
this.hl = { items, label: (options.label ?? '').trim(), pinned: !!options.pinned, shown };
|
|
428
|
+
const first = options.reveal ? items.find((x) => x.sheet === shown) : undefined;
|
|
429
|
+
this.scrollToHl = first;
|
|
430
|
+
if (first) { // a hidden row or column under its top-left cell is shown for viewing, as a selection there would be (W-49)
|
|
431
|
+
const hid = s.hidden(shown), r = first.range;
|
|
432
|
+
if (hid.rows.includes(r.top) && !this.isRevealed(shown, 'rows', r.top))
|
|
433
|
+
this.reveal(shown, 'rows', [r.top]);
|
|
434
|
+
if (hid.cols.includes(r.left) && !this.isRevealed(shown, 'cols', r.left))
|
|
435
|
+
this.reveal(shown, 'cols', [r.left]);
|
|
436
|
+
}
|
|
437
|
+
this.schedule();
|
|
438
|
+
return items.length;
|
|
439
|
+
}
|
|
440
|
+
/** Removes the outline `highlight()` drew (W-56). */
|
|
441
|
+
clearHighlight() {
|
|
442
|
+
if (!this.hl && !this.scrollToHl)
|
|
443
|
+
return;
|
|
444
|
+
this.hl = undefined;
|
|
445
|
+
this.scrollToHl = undefined;
|
|
446
|
+
this.schedule();
|
|
447
|
+
}
|
|
448
|
+
// ── W-66: open at a cell — `ref`, and the selection in the URL fragment (`link="hash"`) ──
|
|
449
|
+
/** This element's name in a link: its `pane`, `''` when it has none. */
|
|
450
|
+
get linkPane() { return this.getAttribute('pane') ?? ''; }
|
|
451
|
+
linkTimer;
|
|
452
|
+
/** The fragment last read or written, so a navigation that fires both `hashchange` and `popstate` is acted on once. */
|
|
453
|
+
seenHash = '';
|
|
454
|
+
/** Back, forward, a followed link or the host setting `location.hash`: go to the cell the fragment names for this pane. */
|
|
455
|
+
onHash = () => {
|
|
456
|
+
if (this.link !== 'hash' || location.hash === this.seenHash)
|
|
457
|
+
return;
|
|
458
|
+
this.seenHash = location.hash;
|
|
459
|
+
clearTimeout(this.linkTimer);
|
|
460
|
+
this.linkTimer = undefined;
|
|
461
|
+
const t = readLink(location.hash, this.linkPane);
|
|
462
|
+
if (t !== undefined)
|
|
463
|
+
this.goTo(t, 'link');
|
|
464
|
+
};
|
|
465
|
+
/** Selects a `ref` or a link's cell or range and scrolls to it; one that names no cell is warned (SP-3019). True when it selected. */
|
|
466
|
+
goTo(text, from) {
|
|
467
|
+
const s = this._store, snap = s?.getSnapshot();
|
|
468
|
+
if (!s || !snap?.book)
|
|
469
|
+
return false;
|
|
470
|
+
const t = parseTarget(text), sheet = t?.sheet ?? snap.selection.sheet;
|
|
471
|
+
if (!t || !s.sheet(sheet)) {
|
|
472
|
+
console.warn(spMessage('SP-3019', `sheetpane: ${from === 'ref' ? 'the ref attribute' : 'the link in the URL'} "${text}" names no cell in this workbook`));
|
|
473
|
+
return false;
|
|
474
|
+
}
|
|
475
|
+
const r = t.range, ref = cellRef(r.top, r.left);
|
|
476
|
+
this.anchor = { row: r.top, col: r.left };
|
|
477
|
+
s.select(r.top === r.bottom && r.left === r.right ? { sheet, ref } : { sheet, ref, range: rangeText(r) }, this);
|
|
478
|
+
this.scrollToSel = true;
|
|
479
|
+
this.schedule();
|
|
480
|
+
return true;
|
|
481
|
+
}
|
|
482
|
+
/** Once a file is read: the fragment's link (`link="hash"`), else `ref`, else A1 as the store left it. True when it selected. */
|
|
483
|
+
openAt() {
|
|
484
|
+
const link = this.link === 'hash' ? readLink(location.hash, this.linkPane) : undefined;
|
|
485
|
+
if (link !== undefined && this.goTo(link, 'link'))
|
|
486
|
+
return true;
|
|
487
|
+
const ref = this.getAttribute('ref');
|
|
488
|
+
return !!ref && this.goTo(ref, 'ref');
|
|
489
|
+
}
|
|
490
|
+
/** The fragment with this pane's selection in it (or without this pane's link). */
|
|
491
|
+
linkHash(off = false) {
|
|
492
|
+
const sel = this._store?.getSnapshot().selection;
|
|
493
|
+
return writeLink(location.hash, this.linkPane, off || !sel?.sheet ? undefined : { sheet: sel.sheet, ref: sel.range ?? sel.ref });
|
|
494
|
+
}
|
|
495
|
+
/** The page's URL with this pane's selection as its link — what *Copy link* copies. */
|
|
496
|
+
linkUrl() { const h = this.linkHash(); return `${location.href.split('#')[0]}${h ? `#${h}` : ''}`; }
|
|
497
|
+
/**
|
|
498
|
+
* Writes the fragment 300 ms after the selection settles (a held arrow key is one write; browsers throttle
|
|
499
|
+
* `replaceState`). `replaceState` keeps the host's `history.state`, adds no history entry and fires no
|
|
500
|
+
* `hashchange`, so a host's router hears nothing.
|
|
501
|
+
*/
|
|
502
|
+
queueLinkWrite() {
|
|
503
|
+
clearTimeout(this.linkTimer);
|
|
504
|
+
this.linkTimer = setTimeout(() => { this.linkTimer = undefined; if (this.link === 'hash' && this.isConnected)
|
|
505
|
+
this.writeHash(this.linkHash()); }, 300);
|
|
506
|
+
}
|
|
507
|
+
writeHash(h) {
|
|
508
|
+
if (h === location.hash.replace(/^#/, ''))
|
|
509
|
+
return;
|
|
510
|
+
try {
|
|
511
|
+
history.replaceState(history.state, '', `${location.href.split('#')[0]}${h ? `#${h}` : ''}`);
|
|
512
|
+
}
|
|
513
|
+
catch { /* a browser throttling replaceState: the next selection writes it */ }
|
|
514
|
+
this.seenHash = location.hash;
|
|
515
|
+
}
|
|
335
516
|
/** Undoes the pane's last step. */
|
|
336
517
|
undo() { this._store?.undo(this); }
|
|
337
518
|
/** Redoes the last undone step. */
|
|
@@ -345,6 +526,8 @@ export class SheetpaneExcel extends ElementBase {
|
|
|
345
526
|
const m = this.getAttribute('mode');
|
|
346
527
|
if (m === 'edit' || m === 'view')
|
|
347
528
|
this._store.setMode(m, this);
|
|
529
|
+
if (this.hasAttribute('layers'))
|
|
530
|
+
this._store.setVisibleLayers(layersAttr(this.getAttribute('layers')), this);
|
|
348
531
|
const src = this.getAttribute('src');
|
|
349
532
|
if (src && this._store.getSnapshot().load.status === 'empty')
|
|
350
533
|
void this.load(src);
|
|
@@ -352,14 +535,25 @@ export class SheetpaneExcel extends ElementBase {
|
|
|
352
535
|
if (rsrc && this.rules === undefined)
|
|
353
536
|
void this.loadRules(rsrc);
|
|
354
537
|
addEventListener('beforeunload', this.onUnload);
|
|
538
|
+
addEventListener('hashchange', this.onHash);
|
|
539
|
+
addEventListener('popstate', this.onHash);
|
|
540
|
+
this.seenHash = location.hash;
|
|
355
541
|
this.place();
|
|
356
542
|
this.schedule();
|
|
357
543
|
}
|
|
358
544
|
/** Releases the pane's store. */
|
|
359
545
|
disconnectedCallback() {
|
|
360
546
|
removeEventListener('beforeunload', this.onUnload);
|
|
547
|
+
removeEventListener('hashchange', this.onHash);
|
|
548
|
+
removeEventListener('popstate', this.onHash);
|
|
549
|
+
clearTimeout(this.linkTimer);
|
|
550
|
+
this.linkTimer = undefined;
|
|
361
551
|
this.unbind();
|
|
362
552
|
cancelAnimationFrame(this.raf);
|
|
553
|
+
cancelAnimationFrame(this.hoverRaf);
|
|
554
|
+
this.hoverRaf = 0;
|
|
555
|
+
this.hoverAt = undefined;
|
|
556
|
+
this.hovered = '';
|
|
363
557
|
}
|
|
364
558
|
/** Reacts to an observed attribute. */
|
|
365
559
|
attributeChangedCallback(name, old, val) {
|
|
@@ -385,6 +579,26 @@ export class SheetpaneExcel extends ElementBase {
|
|
|
385
579
|
}
|
|
386
580
|
else if (name === 'flagging')
|
|
387
581
|
this._store?.setFlagging(val !== 'off');
|
|
582
|
+
else if (name === 'ref' && val)
|
|
583
|
+
this.goTo(val, 'ref'); // before a file is read it waits for the load
|
|
584
|
+
else if (name === 'link') {
|
|
585
|
+
clearTimeout(this.linkTimer);
|
|
586
|
+
this.linkTimer = undefined;
|
|
587
|
+
if (val === 'hash') {
|
|
588
|
+
const t = readLink(location.hash, this.linkPane);
|
|
589
|
+
this.seenHash = location.hash;
|
|
590
|
+
if (t !== undefined)
|
|
591
|
+
this.goTo(t, 'link');
|
|
592
|
+
}
|
|
593
|
+
else if (old === 'hash')
|
|
594
|
+
this.writeHash(this.linkHash(true)); // off: this pane's link leaves the URL; the host's part stays
|
|
595
|
+
}
|
|
596
|
+
else if (name === 'results' && val !== null)
|
|
597
|
+
this._store?.setResults(resultsLevelOf(val));
|
|
598
|
+
else if (name === 'layers')
|
|
599
|
+
this._store?.setVisibleLayers(layersAttr(val), this);
|
|
600
|
+
else if (name === 'protection')
|
|
601
|
+
this._store?.setProtection(protectionModeOf(val));
|
|
388
602
|
this.schedule();
|
|
389
603
|
}
|
|
390
604
|
ensureStore() {
|
|
@@ -411,6 +625,10 @@ export class SheetpaneExcel extends ElementBase {
|
|
|
411
625
|
this.applyRules();
|
|
412
626
|
if (this.hasAttribute('flagging'))
|
|
413
627
|
s.setFlagging(this.getAttribute('flagging') !== 'off');
|
|
628
|
+
if (this.hasAttribute('results'))
|
|
629
|
+
s.setResults(resultsLevelOf(this.getAttribute('results')));
|
|
630
|
+
if (this.hasAttribute('protection'))
|
|
631
|
+
s.setProtection(protectionModeOf(this.getAttribute('protection')));
|
|
414
632
|
}
|
|
415
633
|
unbind() {
|
|
416
634
|
for (const u of this.unsub)
|
|
@@ -428,6 +646,16 @@ export class SheetpaneExcel extends ElementBase {
|
|
|
428
646
|
this.scrollToSel = true;
|
|
429
647
|
if (e.type === 'select')
|
|
430
648
|
this.revealFor(e.sheet, e.ref);
|
|
649
|
+
if (e.type === 'select' && this.link === 'hash')
|
|
650
|
+
this.queueLinkWrite(); // W-66: the URL follows the pane's selection
|
|
651
|
+
// W-56: a highlight belongs to the sheet it was set on, unless pinned; a new file clears it. A pointer
|
|
652
|
+
// that stays put may now be over another cell.
|
|
653
|
+
if ((e.type === 'select' && this.hl && !this.hl.pinned && e.sheet !== this.hl.shown) || e.type === 'load') {
|
|
654
|
+
this.hl = undefined;
|
|
655
|
+
this.scrollToHl = undefined;
|
|
656
|
+
}
|
|
657
|
+
if ((e.type === 'select' || e.type === 'load') && this.hoverAt)
|
|
658
|
+
this.queueHover();
|
|
431
659
|
if ((e.type === 'change' || e.type === 'load') && this.copied) {
|
|
432
660
|
this.copied = undefined;
|
|
433
661
|
this.selKey = '';
|
|
@@ -451,13 +679,20 @@ export class SheetpaneExcel extends ElementBase {
|
|
|
451
679
|
this.revealNote = undefined;
|
|
452
680
|
this.revealVer++;
|
|
453
681
|
this.hiddenMenuOpen = false;
|
|
454
|
-
//
|
|
682
|
+
// W-66: it opens at the link in the URL or `ref` (after every element has heard of the load); else on a
|
|
683
|
+
// visible sheet, even when the file's active tab is hidden
|
|
455
684
|
const st = this._store, snap0 = st.getSnapshot();
|
|
456
|
-
if (snap0.book
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
685
|
+
if (snap0.book)
|
|
686
|
+
queueMicrotask(() => {
|
|
687
|
+
if (this._store !== st || st.getSnapshot().book !== snap0.book || this.openAt())
|
|
688
|
+
return;
|
|
689
|
+
const sel = st.getSnapshot().selection;
|
|
690
|
+
if (sel.sheet !== snap0.selection.sheet || st.hidden(sel.sheet).state === 'visible')
|
|
691
|
+
return;
|
|
692
|
+
const first = snap0.book.sheets.find((x) => st.hidden(x.name).state === 'visible');
|
|
693
|
+
if (first)
|
|
694
|
+
st.select({ sheet: first.name, ref: 'A1' });
|
|
695
|
+
});
|
|
461
696
|
}
|
|
462
697
|
if (e.type === 'load') {
|
|
463
698
|
this._sizes = {};
|
|
@@ -471,6 +706,8 @@ export class SheetpaneExcel extends ElementBase {
|
|
|
471
706
|
el.remove();
|
|
472
707
|
this.overlayCache.clear();
|
|
473
708
|
}
|
|
709
|
+
if (e.type === 'change' && e.kind === 'structure')
|
|
710
|
+
this.followStructure(e);
|
|
474
711
|
const mine = e.origin === this || (e.origin === undefined && isPrimary(this._store, this));
|
|
475
712
|
if (!mine)
|
|
476
713
|
return;
|
|
@@ -479,17 +716,20 @@ export class SheetpaneExcel extends ElementBase {
|
|
|
479
716
|
case 'load': {
|
|
480
717
|
const snap = s.getSnapshot();
|
|
481
718
|
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 }
|
|
719
|
+
? { status: 'ready', name: snap.source.name, kind: snap.source.kind, sheets: snap.book.sheets.map((x) => x.name), readMs: snap.source.readMs, sha256: snap.source.sha256 }
|
|
483
720
|
: { ...snap.load });
|
|
484
721
|
break;
|
|
485
722
|
}
|
|
486
723
|
case 'change':
|
|
487
|
-
fire(this, 'sheetpane-change', { sheet: e.sheet, ref: e.ref, kind: e.kind, was: e.was, now: e.now });
|
|
724
|
+
fire(this, 'sheetpane-change', e.kind === 'structure' ? { sheet: e.sheet, ref: e.ref, kind: e.kind, was: e.was, now: e.now, op: e.op, at: e.at, count: e.count } : { sheet: e.sheet, ref: e.ref, kind: e.kind, was: e.was, now: e.now });
|
|
488
725
|
if (e.kind === 'hidden' || e.kind === 'sheetState')
|
|
489
726
|
this.visibilityEvent(e.sheet, e.ref, e.kind, e.was, e.now);
|
|
490
727
|
break;
|
|
491
728
|
case 'errors':
|
|
492
|
-
fire(this, 'sheetpane-errors',
|
|
729
|
+
fire(this, 'sheetpane-errors', s.envelope(e.layers));
|
|
730
|
+
break;
|
|
731
|
+
case 'layers':
|
|
732
|
+
fire(this, 'sheetpane-layers', { visible: [...s.getSnapshot().visibleLayers], layers: [...s.getSnapshot().layers] });
|
|
493
733
|
break;
|
|
494
734
|
case 'select':
|
|
495
735
|
if (e.sheet !== sheetBefore)
|
|
@@ -509,7 +749,7 @@ export class SheetpaneExcel extends ElementBase {
|
|
|
509
749
|
this.built = true;
|
|
510
750
|
this.shadow = this.attachShadow({ mode: 'open' });
|
|
511
751
|
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>
|
|
752
|
+
this.shadow.innerHTML = `<style>${EXCEL_CSS}${DETAIL_CSS}</style><dialog part="dialog" aria-label="Spreadsheet"></dialog>
|
|
513
753
|
<div class="sp" part="root">
|
|
514
754
|
<div class="bar" part="toolbar">
|
|
515
755
|
<span class="ico">${ICON.sheet}</span><span class="fname"></span><span class="chip meta"></span>
|
|
@@ -524,7 +764,7 @@ export class SheetpaneExcel extends ElementBase {
|
|
|
524
764
|
<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
765
|
<span class="xs faint changes"></span>
|
|
526
766
|
</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"
|
|
767
|
+
<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" title="Download as .xlsx or .csv">${ICON.chevron}</button><span class="dlslot"></span></span>
|
|
528
768
|
<button type="button" class="btn btn-g btn-i help" aria-label="Keyboard shortcuts" aria-expanded="false" title="Keyboard shortcuts">?</button>
|
|
529
769
|
<button type="button" class="btn btn-g btn-i close" aria-label="Close (Esc)" title="Close">${ICON.close}</button>
|
|
530
770
|
</div>
|
|
@@ -535,8 +775,11 @@ export class SheetpaneExcel extends ElementBase {
|
|
|
535
775
|
<span class="tag merge"></span>
|
|
536
776
|
<span class="chip chip-info changed"></span><button type="button" class="btn btn-g revert">Revert</button>
|
|
537
777
|
<span class="chip issue"></span>
|
|
778
|
+
<span class="chip result"></span>
|
|
538
779
|
<span class="chip chip-info hint">Describe it in the Errors panel</span>
|
|
539
780
|
<span class="chip rulemsg" role="status"></span>
|
|
781
|
+
<span class="chip chip-warn richmsg" data-sp="rich-text" role="status" hidden></span>
|
|
782
|
+
<span class="chip chip-info lockmsg" data-sp="locked" role="status" title="The file protects this sheet and locks this cell (SP-3026)" hidden></span>
|
|
540
783
|
<span class="plug-cellbar" style="display:contents"></span>
|
|
541
784
|
<span class="dock" role="toolbar" aria-label="Selection"></span>
|
|
542
785
|
</div>
|
|
@@ -558,7 +801,7 @@ export class SheetpaneExcel extends ElementBase {
|
|
|
558
801
|
this.q = {
|
|
559
802
|
root: $('.sp'), dialog: $('dialog'), fname: $('.fname'), meta: $('.meta'), counts: $('.counts'), findin: $('.findin'), findcnt: $('.find .cnt'), zoom: $('.zoom'),
|
|
560
803
|
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'),
|
|
804
|
+
help: $('.help'), ref: $('.ref'), cell: $('.cell'), cellname: $('.cellname'), val: $('.val'), merge: $('.merge'), changed: $('.changed'), revert: $('.revert'), issue: $('.issue'), result: $('.result'), hint: $('.hint'), rulemsg: $('.rulemsg'), richmsg: $('.richmsg'), lockmsg: $('.lockmsg'),
|
|
562
805
|
plugCellbar: $('.plug-cellbar'), frow: $('.frow'), plugFrow: $('.plug-frow'), body: $('.body'), scroller: $('.scroller'), sheet: $('.sheet'), hrow: $('.hrow'),
|
|
563
806
|
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
807
|
};
|
|
@@ -656,6 +899,11 @@ export class SheetpaneExcel extends ElementBase {
|
|
|
656
899
|
this.schedule();
|
|
657
900
|
} });
|
|
658
901
|
q.dock.addEventListener('click', (e) => this.onDockClick(e));
|
|
902
|
+
if (typeof ResizeObserver !== 'undefined')
|
|
903
|
+
new ResizeObserver(() => this.fitDock()).observe(q.dock.parentElement);
|
|
904
|
+
// the grid's focus ring shows for keyboard use only
|
|
905
|
+
q.root.addEventListener('keydown', () => q.root.setAttribute('data-kbd', ''), true);
|
|
906
|
+
q.root.addEventListener('pointerdown', () => q.root.removeAttribute('data-kbd'), true);
|
|
659
907
|
q.dock.addEventListener('keydown', (e) => this.onMenuKey(e, '.moremenu'));
|
|
660
908
|
q.help.addEventListener('click', () => { this.helpOpen = !this.helpOpen; q.help.setAttribute('aria-expanded', String(this.helpOpen)); this.schedule(); });
|
|
661
909
|
q.revert.addEventListener('click', () => { const sel = s().getSnapshot().selection; s().revert(sel.sheet, sel.ref, this); });
|
|
@@ -663,7 +911,8 @@ export class SheetpaneExcel extends ElementBase {
|
|
|
663
911
|
const cell = q.cell;
|
|
664
912
|
cell.addEventListener('focus', () => {
|
|
665
913
|
const sel = s().getSnapshot().selection;
|
|
666
|
-
const
|
|
914
|
+
const rich = !!s().runs(sel.sheet, sel.ref); // W-53: the edit writes plain text, the runs' formatting goes
|
|
915
|
+
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), ...(rich ? { richText: true } : {}) }, true);
|
|
667
916
|
if (!allowed) {
|
|
668
917
|
cell.readOnly = true;
|
|
669
918
|
this.editing = undefined;
|
|
@@ -761,7 +1010,13 @@ export class SheetpaneExcel extends ElementBase {
|
|
|
761
1010
|
this.schedule();
|
|
762
1011
|
this.q.tabs.querySelector('[data-act=hidden-sheets]')?.focus();
|
|
763
1012
|
} });
|
|
764
|
-
q.scroller.addEventListener('scroll', () => this.schedule()
|
|
1013
|
+
q.scroller.addEventListener('scroll', () => { this.schedule(); if (this.hoverAt)
|
|
1014
|
+
this.queueHover(); }, { passive: true });
|
|
1015
|
+
// W-56: the cell under a mouse or pen pointer, told once per frame and only when it changes; a finger never hovers
|
|
1016
|
+
q.scroller.addEventListener('pointermove', (e) => { if (e.pointerType === 'touch')
|
|
1017
|
+
return; this.hoverAt = { x: e.clientX, y: e.clientY, card: !!e.target.closest?.('.card') }; this.queueHover(); }, { passive: true });
|
|
1018
|
+
q.scroller.addEventListener('pointerleave', (e) => { if (e.pointerType === 'touch' || !this.hoverAt)
|
|
1019
|
+
return; this.hoverAt = undefined; this.queueHover(); });
|
|
765
1020
|
// W-46: mouse and pen drag with Pointer Events (captured, so the drag outlives the grid); a finger
|
|
766
1021
|
// pans, and its tap arrives as the compatibility mousedown — as do synthetic clicks
|
|
767
1022
|
q.scroller.addEventListener('pointerdown', (e) => this.onPointerDown(e));
|
|
@@ -885,7 +1140,35 @@ export class SheetpaneExcel extends ElementBase {
|
|
|
885
1140
|
});
|
|
886
1141
|
q.overlay.addEventListener('input', (e) => this.onComposerInput(e));
|
|
887
1142
|
q.overlay.addEventListener('change', (e) => this.onComposerInput(e));
|
|
888
|
-
q.plugFrow.addEventListener('click', (e) =>
|
|
1143
|
+
q.plugFrow.addEventListener('click', (e) => {
|
|
1144
|
+
if (e.target.closest('.fmore-btn')) {
|
|
1145
|
+
this.frowMoreOpen = !this.frowMoreOpen;
|
|
1146
|
+
const menu = q.plugFrow.querySelector('.fmenu'), btn = q.plugFrow.querySelector('.fmore-btn');
|
|
1147
|
+
if (menu)
|
|
1148
|
+
menu.hidden = !this.frowMoreOpen;
|
|
1149
|
+
btn?.setAttribute('aria-expanded', String(this.frowMoreOpen));
|
|
1150
|
+
return;
|
|
1151
|
+
}
|
|
1152
|
+
this.runControl(e);
|
|
1153
|
+
});
|
|
1154
|
+
q.root.addEventListener('pointerdown', (e) => { if (this.frowMoreOpen && !e.target.closest('.fmore')) {
|
|
1155
|
+
this.frowMoreOpen = false;
|
|
1156
|
+
const m = q.plugFrow.querySelector('.fmenu');
|
|
1157
|
+
if (m)
|
|
1158
|
+
m.hidden = true;
|
|
1159
|
+
q.plugFrow.querySelector('.fmore-btn')?.setAttribute('aria-expanded', 'false');
|
|
1160
|
+
} });
|
|
1161
|
+
q.frow.addEventListener('keydown', (e) => { if (e.key === 'Escape' && this.frowMoreOpen) {
|
|
1162
|
+
e.stopPropagation();
|
|
1163
|
+
this.frowMoreOpen = false;
|
|
1164
|
+
const m = q.plugFrow.querySelector('.fmenu');
|
|
1165
|
+
if (m)
|
|
1166
|
+
m.hidden = true;
|
|
1167
|
+
q.plugFrow.querySelector('.fmore-btn')?.focus();
|
|
1168
|
+
} });
|
|
1169
|
+
if (typeof ResizeObserver !== 'undefined')
|
|
1170
|
+
new ResizeObserver(() => { if (!q.frow.hidden)
|
|
1171
|
+
this.fitFrow(); }).observe(q.frow);
|
|
889
1172
|
q.plugCellbar.addEventListener('click', (e) => this.runControl(e));
|
|
890
1173
|
for (const box of [q.plugFrow, q.plugCellbar, q.overlay])
|
|
891
1174
|
box.addEventListener('change', (e) => this.chooseControl(e));
|
|
@@ -933,6 +1216,10 @@ export class SheetpaneExcel extends ElementBase {
|
|
|
933
1216
|
/** `sheetpane-beforechange` (cancellable) for one cell; the value it would store is `now`. Then a column rule set to `block` (W-27). */
|
|
934
1217
|
allowChange(sheet, ref, now, text = now === null || now === undefined ? '' : String(now)) {
|
|
935
1218
|
const s = this._store;
|
|
1219
|
+
if (s.isLocked(sheet, ref)) {
|
|
1220
|
+
this.refuseLocked(sheet, ref);
|
|
1221
|
+
return false;
|
|
1222
|
+
} // W-62: before the host is asked
|
|
936
1223
|
if (!fire(this, 'sheetpane-beforechange', { sheet, ref, column: this.column(sheet, ref), was: s.value(sheet, ref), now }, true))
|
|
937
1224
|
return false;
|
|
938
1225
|
const b = s.ruleBlocks(sheet, ref, now, text);
|
|
@@ -944,6 +1231,19 @@ export class SheetpaneExcel extends ElementBase {
|
|
|
944
1231
|
this.schedule();
|
|
945
1232
|
return false;
|
|
946
1233
|
}
|
|
1234
|
+
/** A locked cell of a protected sheet refused an edit (W-62): `sheetpane-editcancel` with SP-3026; nothing is stored. */
|
|
1235
|
+
lockedRefused = 0;
|
|
1236
|
+
refuseLocked(sheet, ref) {
|
|
1237
|
+
this.lockedRefused++;
|
|
1238
|
+
fire(this, 'sheetpane-editcancel', { sheet, ref, reason: 'locked', code: 'SP-3026', message: LOCKED_MESSAGE });
|
|
1239
|
+
}
|
|
1240
|
+
/** After a paste or a clear: says how many locked cells were left as they were. */
|
|
1241
|
+
sayLocked() {
|
|
1242
|
+
const n = this.lockedRefused;
|
|
1243
|
+
this.lockedRefused = 0;
|
|
1244
|
+
if (n)
|
|
1245
|
+
this.say0(`${n} locked cell${n === 1 ? '' : 's'} left as ${n === 1 ? 'it was' : 'they were'} — ${LOCKED_MESSAGE}`);
|
|
1246
|
+
}
|
|
947
1247
|
// ── column rules: the input phase (an outline and a tooltip, nothing stored) ──
|
|
948
1248
|
ruleMsg;
|
|
949
1249
|
ruleTimer;
|
|
@@ -999,6 +1299,7 @@ export class SheetpaneExcel extends ElementBase {
|
|
|
999
1299
|
if (text === undefined)
|
|
1000
1300
|
return;
|
|
1001
1301
|
e.preventDefault();
|
|
1302
|
+
this.lockedRefused = 0;
|
|
1002
1303
|
const rows = text.replace(/\r\n?/g, '\n').replace(/\n$/, '').split('\n').map((r) => r.split('\t'));
|
|
1003
1304
|
const p = parseRef(snap.selection.ref);
|
|
1004
1305
|
const book = s.book;
|
|
@@ -1021,6 +1322,7 @@ export class SheetpaneExcel extends ElementBase {
|
|
|
1021
1322
|
cells.push({ sheet: snap.selection.sheet, ref, input: v });
|
|
1022
1323
|
}
|
|
1023
1324
|
s.setCells(cells, { origin: this });
|
|
1325
|
+
this.sayLocked();
|
|
1024
1326
|
return;
|
|
1025
1327
|
}
|
|
1026
1328
|
rows.forEach((row, dr) => row.forEach((v, dc) => {
|
|
@@ -1029,10 +1331,13 @@ export class SheetpaneExcel extends ElementBase {
|
|
|
1029
1331
|
cells.push({ sheet: snap.selection.sheet, ref, input: v });
|
|
1030
1332
|
}));
|
|
1031
1333
|
s.setCells(cells, { origin: this });
|
|
1334
|
+
this.sayLocked();
|
|
1032
1335
|
}
|
|
1033
1336
|
// ── selection actions (W-31): docked in the cell bar, and on right-click ──
|
|
1034
1337
|
ctxOpen = false;
|
|
1035
1338
|
moreOpen = false;
|
|
1339
|
+
/** The format row's More ▾ (W-14 narrow widths) is open. */
|
|
1340
|
+
frowMoreOpen = false;
|
|
1036
1341
|
notice = '';
|
|
1037
1342
|
noticeTimer;
|
|
1038
1343
|
/** Copies to the clipboard; without a secure context it says so in the status line. */
|
|
@@ -1060,16 +1365,68 @@ export class SheetpaneExcel extends ElementBase {
|
|
|
1060
1365
|
requestAnimationFrame(() => this.q.overlay.querySelector('.ctxmenu [role=menuitem]:not([disabled])')?.focus({ preventScroll: true }));
|
|
1061
1366
|
}
|
|
1062
1367
|
/** The actions as menu items: Flag issue (Edit), Copy ref, Copy value, then the `selbar` plugin controls. */
|
|
1368
|
+
/** The format row's controls, one wrapper per group, then a More ▾ that holds the groups that do not fit (W-14). */
|
|
1369
|
+
frowHtml(list) {
|
|
1370
|
+
const groups = [];
|
|
1371
|
+
for (const c of list) {
|
|
1372
|
+
const g = groups[groups.length - 1];
|
|
1373
|
+
if (g && g[0].group === c.group)
|
|
1374
|
+
g.push(c);
|
|
1375
|
+
else
|
|
1376
|
+
groups.push([c]);
|
|
1377
|
+
}
|
|
1378
|
+
const body = groups.map((g, i) => `<span class="fgrp" data-g="${esc(g[0].group)}">${i ? '<span class="sep" aria-hidden="true"></span>' : ''}${this.controlsHtml(g, 'btn btn-g')}</span>`).join('');
|
|
1379
|
+
return `${body}<span class="fmore" hidden><button type="button" class="btn btn-g fmore-btn" aria-haspopup="true" aria-expanded="${this.frowMoreOpen}">More<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M6 9l6 6 6-6"/></svg></button><span class="card fmenu" role="group" aria-label="More formatting"${this.frowMoreOpen ? '' : ' hidden'}></span></span>`;
|
|
1380
|
+
}
|
|
1381
|
+
/** Narrow widths: format-row groups move into More ▾ from the right; Number format and Text never move (W-14). */
|
|
1382
|
+
fitFrow() {
|
|
1383
|
+
const box = this.q.frow, more = this.q.plugFrow.querySelector('.fmore'), menu = more?.querySelector('.fmenu');
|
|
1384
|
+
if (!more || !menu)
|
|
1385
|
+
return;
|
|
1386
|
+
for (const g of Array.from(menu.children))
|
|
1387
|
+
more.before(g);
|
|
1388
|
+
more.hidden = true;
|
|
1389
|
+
if (box.scrollWidth <= box.clientWidth + 1) {
|
|
1390
|
+
this.frowMoreOpen = false;
|
|
1391
|
+
return;
|
|
1392
|
+
}
|
|
1393
|
+
more.hidden = false;
|
|
1394
|
+
const movable = Array.from(this.q.plugFrow.querySelectorAll(':scope > .fgrp')).filter((g) => g.dataset.g !== 'number' && g.dataset.g !== 'text').reverse();
|
|
1395
|
+
for (const g of movable) {
|
|
1396
|
+
if (box.scrollWidth <= box.clientWidth + 1)
|
|
1397
|
+
break;
|
|
1398
|
+
menu.prepend(g);
|
|
1399
|
+
}
|
|
1400
|
+
if (!menu.children.length)
|
|
1401
|
+
more.hidden = true;
|
|
1402
|
+
}
|
|
1403
|
+
/** Fold the docked plugin buttons into ⋯ when they would squeeze the value field below 200 px (SR-GRID 13). */
|
|
1404
|
+
fitDock() {
|
|
1405
|
+
const q = this.q, fx = q.dock.parentElement;
|
|
1406
|
+
if (!fx)
|
|
1407
|
+
return;
|
|
1408
|
+
fx.classList.remove('folded');
|
|
1409
|
+
if (q.dock.hidden || !q.dock.querySelector('.plug'))
|
|
1410
|
+
return;
|
|
1411
|
+
const field = q.cell.hidden ? q.val : q.cell;
|
|
1412
|
+
if (field.getBoundingClientRect().width < 200)
|
|
1413
|
+
fx.classList.add('folded');
|
|
1414
|
+
}
|
|
1063
1415
|
actionItems(edit, err, o) {
|
|
1064
1416
|
const s = this._store, snap = s.getSnapshot();
|
|
1065
1417
|
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
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1418
|
+
const flag = o.flag && edit && !err && s.flagging;
|
|
1419
|
+
const out = [flag ? item('flag', 'Flag issue', 'F') + '<div role="separator" class="msep"></div>' : '', item('copy-ref', 'Copy ref'), this.link === 'hash' ? item('copy-link', 'Copy link') : '', item('copy-value', snap.selection.range ? 'Copy values' : 'Copy value', 'Ctrl/⌘+C')];
|
|
1420
|
+
if (edit)
|
|
1421
|
+
out.push(this.structureItems('cell'));
|
|
1422
|
+
const plugs = o.plugins && edit ? controlsFor(s.plugins, 'selbar', 'edit').filter((x) => !x.menus || x.menus.includes('cell')) : [];
|
|
1423
|
+
if (plugs.length)
|
|
1424
|
+
out.push('<div role="separator" class="msep fold"></div>');
|
|
1425
|
+
for (const c of plugs) {
|
|
1426
|
+
const label = c.labelFor?.(snap, snap.selection) ?? c.label;
|
|
1427
|
+
const off = c.disabled?.(snap, snap.selection) ?? false;
|
|
1428
|
+
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>`);
|
|
1429
|
+
}
|
|
1073
1430
|
return out.join('');
|
|
1074
1431
|
}
|
|
1075
1432
|
contextMenuHtml(x, y, edit, err) {
|
|
@@ -1126,16 +1483,30 @@ export class SheetpaneExcel extends ElementBase {
|
|
|
1126
1483
|
this.flag();
|
|
1127
1484
|
else if (act === 'copy-ref')
|
|
1128
1485
|
this.copy(this.refOf(sel));
|
|
1486
|
+
else if (act === 'copy-link')
|
|
1487
|
+
this.copy(this.linkUrl());
|
|
1129
1488
|
else if (act === 'copy-value')
|
|
1130
1489
|
this.copySelection();
|
|
1490
|
+
else if (act && /^(ins-(above|below|left|right)|del-(rows|cols))$/.test(act))
|
|
1491
|
+
this.runStructure(act);
|
|
1131
1492
|
else if (t.closest('[data-control]'))
|
|
1132
1493
|
this.runControl(e);
|
|
1133
1494
|
this.schedule();
|
|
1134
1495
|
}
|
|
1135
|
-
/**
|
|
1496
|
+
/**
|
|
1497
|
+
* Untouched: no change, and nothing a plugin would write — a review exported as comments, or a switch that applies
|
|
1498
|
+
* and is on (W-58). A plugin whose every switch says when it applies is judged by its switches, not by the errors.
|
|
1499
|
+
*/
|
|
1136
1500
|
untouched() {
|
|
1137
1501
|
const s = this._store;
|
|
1138
|
-
|
|
1502
|
+
if (s.changeCount !== 0)
|
|
1503
|
+
return false;
|
|
1504
|
+
const judged = (p) => !!p.exportOptions?.length && p.exportOptions.every((o) => o.applies);
|
|
1505
|
+
// results never make a review (W-50)
|
|
1506
|
+
if (s.resultCounts().problems && s.plugins.some((p) => p.provides.includes('exportPatch') && !judged(p)))
|
|
1507
|
+
return false;
|
|
1508
|
+
const on = this.exportChoices();
|
|
1509
|
+
return !s.plugins.flatMap((p) => p.exportOptions ?? []).some((o) => on[o.key] && o.applies?.(s));
|
|
1139
1510
|
}
|
|
1140
1511
|
// ── Download (W-31, W-34): the main part in the upload's format; ▾ for either format and plugin options ──
|
|
1141
1512
|
dlOpen = false;
|
|
@@ -1155,7 +1526,7 @@ export class SheetpaneExcel extends ElementBase {
|
|
|
1155
1526
|
const fmt = format ?? src.kind;
|
|
1156
1527
|
if (fmt === src.kind && this.untouched()) {
|
|
1157
1528
|
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))
|
|
1529
|
+
if (fire(this, 'sheetpane-export', { blob, kind: src.kind, name: src.name, guarded: false, original: true, format: fmt, converted: false, sha256: src.sha256 }, true))
|
|
1159
1530
|
download(src.bytes, src.name);
|
|
1160
1531
|
return;
|
|
1161
1532
|
}
|
|
@@ -1168,7 +1539,8 @@ export class SheetpaneExcel extends ElementBase {
|
|
|
1168
1539
|
const note = (f) => (f === kind ? '' : f === 'csv' ? 'The current sheet only — values as shown now, no formatting' : 'One sheet with the latest values');
|
|
1169
1540
|
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
1541
|
const choices = this.exportChoices();
|
|
1171
|
-
const
|
|
1542
|
+
const store = this._store;
|
|
1543
|
+
const opts = (store?.plugins ?? []).flatMap((p) => p.exportOptions ?? []).filter((o) => !o.applies || (store && o.applies(store)));
|
|
1172
1544
|
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
1545
|
return `<div class="card dlmenu" role="menu" aria-label="Download format">${items}${optHtml}</div>`;
|
|
1174
1546
|
}
|
|
@@ -1182,6 +1554,7 @@ export class SheetpaneExcel extends ElementBase {
|
|
|
1182
1554
|
['Home / End', 'Row start / end'], ['Ctrl + Home / End', 'First / last cell'],
|
|
1183
1555
|
['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
1556
|
['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'],
|
|
1557
|
+
['Ctrl/⌘ + Shift + =', 'Insert rows above (whole columns: columns left) (Edit)'], ['Ctrl/⌘ + −', 'Delete the selected rows or columns (Edit)'],
|
|
1185
1558
|
].map(([keys, what]) => ({ keys: keys, what: what }));
|
|
1186
1559
|
const plug = (this._store?.plugins ?? []).flatMap((p) => p.controls ?? []).filter((c) => c.shortcut).map((c) => ({ keys: c.shortcut, what: c.label }));
|
|
1187
1560
|
return [...(this._store && !this._store.flagging ? own.filter((x) => x.keys !== 'F') : own), ...plug];
|
|
@@ -1314,6 +1687,37 @@ export class SheetpaneExcel extends ElementBase {
|
|
|
1314
1687
|
if (!this.dragRaf)
|
|
1315
1688
|
this.dragRaf = requestAnimationFrame(() => { this.dragRaf = 0; this.dragStep(); });
|
|
1316
1689
|
}
|
|
1690
|
+
// ── hover (W-56): the cell under a mouse or pen pointer, once per frame ──
|
|
1691
|
+
queueHover() {
|
|
1692
|
+
if (!this.hoverRaf)
|
|
1693
|
+
this.hoverRaf = requestAnimationFrame(() => { this.hoverRaf = 0; this.hoverStep(); });
|
|
1694
|
+
}
|
|
1695
|
+
/** One frame of hover: the cell under the pointer, told only when it changed — two binary searches, nothing rendered. */
|
|
1696
|
+
hoverStep() {
|
|
1697
|
+
const h = this.hoverAt, g = h && !h.card && this.built && this._store?.getSnapshot().load.status === 'ready' ? this.geometry() : undefined;
|
|
1698
|
+
const p = g ? this.cellAtPoint(g, h.x, h.y) : undefined;
|
|
1699
|
+
const sheet = g?.sheet.name ?? '', ref = p ? cellRef(p.row, p.col) : '';
|
|
1700
|
+
const key = ref ? `${sheet}!${ref}` : '';
|
|
1701
|
+
if (key === this.hovered)
|
|
1702
|
+
return;
|
|
1703
|
+
this.hovered = key;
|
|
1704
|
+
fire(this, 'sheetpane-hover', ref ? { sheet, ref, column: this.column(sheet, ref) } : null);
|
|
1705
|
+
}
|
|
1706
|
+
/** The cell at a viewport point (a merge's top-left); none over the headers, a scrollbar, or past the sheet's cells. Frozen panes do not scroll. */
|
|
1707
|
+
cellAtPoint(g, x, y) {
|
|
1708
|
+
const sc = this.q.scroller, z = this.zoom, gg = g.g, HH = this.headH() * z, RW = this.rowHeadW() * z;
|
|
1709
|
+
const rect = sc.getBoundingClientRect();
|
|
1710
|
+
const vx = x - rect.left - sc.clientLeft, vy = y - rect.top - sc.clientTop;
|
|
1711
|
+
if (vx < RW || vy < HH || vx >= sc.clientWidth || vy >= sc.clientHeight)
|
|
1712
|
+
return undefined;
|
|
1713
|
+
const fz = this._store.freeze(g.sheet.name), fr = Math.min(fz.rows, gg.rows), fc = Math.min(fz.cols, gg.cols);
|
|
1714
|
+
const paneTop = HH + gg.rowY[fr] * z, paneLeft = RW + gg.colX[fc] * z;
|
|
1715
|
+
const ly = ((vy < paneTop && fr > 0 ? vy : vy + sc.scrollTop) - HH) / z, lx = ((vx < paneLeft && fc > 0 ? vx : vx + sc.scrollLeft) - RW) / z;
|
|
1716
|
+
if (ly >= gg.rowY[gg.rows] || lx >= gg.colX[gg.cols])
|
|
1717
|
+
return undefined;
|
|
1718
|
+
const row = rowAt(gg, ly), col = colAt(gg, lx), m = mergeAt(gg, row, col);
|
|
1719
|
+
return m ? { row: m.top, col: m.left } : { row, col };
|
|
1720
|
+
}
|
|
1317
1721
|
/** One frame of a drag: autoscroll at an edge, the cell under the pointer, the layer redrawn. */
|
|
1318
1722
|
dragStep() {
|
|
1319
1723
|
const d = this.drag, g = this.geometry();
|
|
@@ -1573,7 +1977,7 @@ export class SheetpaneExcel extends ElementBase {
|
|
|
1573
1977
|
hiddenMarks(sheet, g, win, fr, fc) {
|
|
1574
1978
|
const z = this.zoom, HH = this.headH() * z, RW = this.rowHeadW() * z, s = this._store;
|
|
1575
1979
|
const X = (c) => g.colX[Math.min(c, g.cols)] * z, Y = (r) => rowTop(g, r) * z;
|
|
1576
|
-
const errs = s.getSnapshot().
|
|
1980
|
+
const errs = s.getSnapshot().visibleErrors.filter((e) => e.sheet === sheet && e.status === 'open' && !isResult(e));
|
|
1577
1981
|
const inSpan = (axis, a, b) => {
|
|
1578
1982
|
let worst, n = 0;
|
|
1579
1983
|
for (const e of errs) {
|
|
@@ -1730,7 +2134,7 @@ export class SheetpaneExcel extends ElementBase {
|
|
|
1730
2134
|
const hidden = book.sheets.filter((x) => s.hidden(x.name).state !== 'visible');
|
|
1731
2135
|
if (!hidden.length)
|
|
1732
2136
|
return '';
|
|
1733
|
-
const errs = snap.mode === 'edit' ? snap.
|
|
2137
|
+
const errs = snap.mode === 'edit' ? snap.visibleErrors.filter((e) => e.status === 'open' && !isResult(e) && hidden.some((h) => h.name === e.sheet)).length : 0;
|
|
1734
2138
|
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
2139
|
return btn;
|
|
1736
2140
|
}
|
|
@@ -1956,6 +2360,132 @@ export class SheetpaneExcel extends ElementBase {
|
|
|
1956
2360
|
this.schedule();
|
|
1957
2361
|
this.q.grid.focus({ preventScroll: true });
|
|
1958
2362
|
}
|
|
2363
|
+
// ── rows and columns (W-52): the cell menu, the header menu and Ctrl+Shift+= / Ctrl+− ──
|
|
2364
|
+
/** The rows and columns the selection spans, and whether it is whole rows or whole columns. */
|
|
2365
|
+
selSpan() {
|
|
2366
|
+
const s = this._store, g = this.geometry();
|
|
2367
|
+
if (!s || !g)
|
|
2368
|
+
return undefined;
|
|
2369
|
+
const sel = s.getSnapshot().selection, p = parseRef(sel.ref);
|
|
2370
|
+
const r = (sel.range ? parseRange(sel.range) : undefined) ?? (p && { top: p.row, bottom: p.row, left: p.col, right: p.col });
|
|
2371
|
+
if (!r)
|
|
2372
|
+
return undefined;
|
|
2373
|
+
return { sheet: sel.sheet, ...r, wholeRows: r.left === 0 && r.right >= g.g.cols - 1, wholeCols: r.top === 0 && r.bottom >= g.g.rows - 1 };
|
|
2374
|
+
}
|
|
2375
|
+
/** The operation a menu item or key names, over the selection (or the header menu's indexes). */
|
|
2376
|
+
structureOp(act, indexes) {
|
|
2377
|
+
const sp = this.selSpan();
|
|
2378
|
+
if (!sp)
|
|
2379
|
+
return undefined;
|
|
2380
|
+
const from = indexes?.length ? Math.min(...indexes) : undefined, to = indexes?.length ? Math.max(...indexes) : undefined;
|
|
2381
|
+
const top = from ?? sp.top, bottom = to ?? sp.bottom, left = from ?? sp.left, right = to ?? sp.right;
|
|
2382
|
+
const rows = bottom - top + 1, cols = right - left + 1;
|
|
2383
|
+
switch (act) {
|
|
2384
|
+
case 'ins-above': return { sheet: sp.sheet, op: 'insertRows', at: top, count: rows };
|
|
2385
|
+
case 'ins-below': return { sheet: sp.sheet, op: 'insertRows', at: bottom + 1, count: rows };
|
|
2386
|
+
case 'ins-left': return { sheet: sp.sheet, op: 'insertCols', at: left, count: cols };
|
|
2387
|
+
case 'ins-right': return { sheet: sp.sheet, op: 'insertCols', at: right + 1, count: cols };
|
|
2388
|
+
case 'del-rows': return { sheet: sp.sheet, op: 'deleteRows', at: top, count: rows };
|
|
2389
|
+
case 'del-cols': return { sheet: sp.sheet, op: 'deleteCols', at: left, count: cols };
|
|
2390
|
+
}
|
|
2391
|
+
return undefined;
|
|
2392
|
+
}
|
|
2393
|
+
/** Insert and delete as menu items: greyed, with the reason in the tooltip, when refused. `rows` / `cols`: the header menu's axis only. */
|
|
2394
|
+
structureItems(which, indexes) {
|
|
2395
|
+
const s = this._store;
|
|
2396
|
+
if (!s || s.getSnapshot().load.status !== 'ready')
|
|
2397
|
+
return '';
|
|
2398
|
+
const sp = this.selSpan();
|
|
2399
|
+
if (!sp)
|
|
2400
|
+
return '';
|
|
2401
|
+
const n = (a, b) => (indexes?.length ? Math.max(...indexes) - Math.min(...indexes) : b - a) + 1;
|
|
2402
|
+
const nr = n(sp.top, sp.bottom), nc = n(sp.left, sp.right);
|
|
2403
|
+
const rowsWord = (k) => (k === 1 ? 'row' : `${k} rows`), colsWord = (k) => (k === 1 ? 'column' : `${k} columns`);
|
|
2404
|
+
const item = (act, label, kbd = '') => {
|
|
2405
|
+
const o = this.structureOp(act, indexes), no = o && s.structureRefusal(o);
|
|
2406
|
+
return `<button type="button" role="menuitem" class="mi" data-act="${act}"${no ? ` disabled title="${esc(no.message)}"` : ''}>${esc(label)}${kbd ? `<span class="kbd">${esc(kbd)}</span>` : ''}</button>`;
|
|
2407
|
+
};
|
|
2408
|
+
const rows = [item('ins-above', `Insert ${rowsWord(nr)} above`, which === 'cols' ? '' : 'Ctrl/⌘+Shift+='), item('ins-below', `Insert ${rowsWord(nr)} below`), item('del-rows', `Delete ${rowsWord(nr)}`, which === 'rows' ? 'Ctrl/⌘+−' : '')];
|
|
2409
|
+
const cols = [item('ins-left', `Insert ${colsWord(nc)} left`, which === 'cols' ? 'Ctrl/⌘+Shift+=' : ''), item('ins-right', `Insert ${colsWord(nc)} right`), item('del-cols', `Delete ${colsWord(nc)}`, which === 'cols' ? 'Ctrl/⌘+−' : '')];
|
|
2410
|
+
const list = which === 'rows' ? rows : which === 'cols' ? cols : [rows[0], rows[1], cols[0], cols[1], rows[2], cols[2]]; // as Google Sheets lists them: inserts, then deletes
|
|
2411
|
+
return `<div role="separator" class="msep"></div>${list.join('')}`;
|
|
2412
|
+
}
|
|
2413
|
+
/** Runs one insert or delete; a refusal is said in the status line, never thrown. The selection keeps its address, as in Excel. */
|
|
2414
|
+
runStructure(act, indexes) {
|
|
2415
|
+
const s = this._store, o = this.structureOp(act, indexes);
|
|
2416
|
+
this.ctxOpen = false;
|
|
2417
|
+
this.moreOpen = false;
|
|
2418
|
+
if (!s || !o || s.getSnapshot().mode !== 'edit') {
|
|
2419
|
+
this.schedule();
|
|
2420
|
+
return;
|
|
2421
|
+
}
|
|
2422
|
+
const no = s.structureRefusal(o);
|
|
2423
|
+
if (no) {
|
|
2424
|
+
this.say0(no.message);
|
|
2425
|
+
return;
|
|
2426
|
+
}
|
|
2427
|
+
const sel = s.getSnapshot().selection;
|
|
2428
|
+
const done = o.op === 'insertRows' ? s.insertRows(o.sheet, o.at, o.count, this) : o.op === 'deleteRows' ? s.deleteRows(o.sheet, o.at, o.count, this) : o.op === 'insertCols' ? s.insertCols(o.sheet, o.at, o.count, this) : s.deleteCols(o.sheet, o.at, o.count, this);
|
|
2429
|
+
if (!done) {
|
|
2430
|
+
this.schedule();
|
|
2431
|
+
return;
|
|
2432
|
+
}
|
|
2433
|
+
const g = this.geometry();
|
|
2434
|
+
const clamp = (ref) => { const p = parseRef(ref); return p && g ? cellRef(Math.min(p.row, g.g.rows - 1), Math.min(p.col, g.g.cols - 1)) : ref; };
|
|
2435
|
+
const r = sel.range ? parseRange(sel.range) : undefined;
|
|
2436
|
+
const range = r && g ? rangeText({ top: Math.min(r.top, g.g.rows - 1), left: Math.min(r.left, g.g.cols - 1), bottom: Math.min(r.bottom, g.g.rows - 1), right: Math.min(r.right, g.g.cols - 1) }) : undefined;
|
|
2437
|
+
s.select(range ? { sheet: sel.sheet, ref: clamp(sel.ref), range } : { sheet: sel.sheet, ref: clamp(sel.ref) }, this);
|
|
2438
|
+
const what = `${o.count} ${o.op.endsWith('Rows') ? (o.count === 1 ? 'row' : 'rows') : o.count === 1 ? 'column' : 'columns'}`;
|
|
2439
|
+
this.say0(`${o.op.startsWith('insert') ? 'Inserted' : 'Deleted'} ${what}`);
|
|
2440
|
+
}
|
|
2441
|
+
/** Ctrl/⌘+Shift+= inserts above (whole columns: left); Ctrl/⌘+− deletes whole rows or columns, and asks for them otherwise. */
|
|
2442
|
+
structureKey(kind) {
|
|
2443
|
+
const sp = this.selSpan();
|
|
2444
|
+
if (!sp)
|
|
2445
|
+
return;
|
|
2446
|
+
if (kind === 'insert') {
|
|
2447
|
+
this.runStructure(sp.wholeCols && !sp.wholeRows ? 'ins-left' : 'ins-above');
|
|
2448
|
+
return;
|
|
2449
|
+
}
|
|
2450
|
+
if (sp.wholeRows)
|
|
2451
|
+
this.runStructure('del-rows');
|
|
2452
|
+
else if (sp.wholeCols)
|
|
2453
|
+
this.runStructure('del-cols');
|
|
2454
|
+
else
|
|
2455
|
+
this.say0('Select whole rows (Shift+Space) or columns (Ctrl+Space) to delete them');
|
|
2456
|
+
}
|
|
2457
|
+
/** What this element keeps per row or column follows an insert or delete: sizes, what it shows of hidden rows, the highlight. */
|
|
2458
|
+
followStructure(e) {
|
|
2459
|
+
const o = { sheet: e.sheet, op: e.op, at: e.at, count: e.count };
|
|
2460
|
+
const rows = o.op.endsWith('Rows');
|
|
2461
|
+
const move = (rec) => { const out = {}; for (const [k, v] of Object.entries(rec)) {
|
|
2462
|
+
const j = shiftIndex(Number(k), o);
|
|
2463
|
+
if (j !== undefined)
|
|
2464
|
+
out[j] = v;
|
|
2465
|
+
} return out; };
|
|
2466
|
+
const z = this._sizes[e.sheet];
|
|
2467
|
+
if (z)
|
|
2468
|
+
this._sizes[e.sheet] = rows ? { ...z, rows: move(z.rows) } : { ...z, cols: move(z.cols) };
|
|
2469
|
+
const rv = this.revealed[e.sheet];
|
|
2470
|
+
if (rv) {
|
|
2471
|
+
const set = (x) => new Set([...x].flatMap((i) => { const j = shiftIndex(i, o); return j === undefined ? [] : [j]; }));
|
|
2472
|
+
this.revealed[e.sheet] = rows ? { ...rv, rows: set(rv.rows) } : { ...rv, cols: set(rv.cols) };
|
|
2473
|
+
}
|
|
2474
|
+
if (this.hl)
|
|
2475
|
+
this.hl = { ...this.hl, items: this.hl.items.flatMap((h) => { if (h.sheet !== e.sheet)
|
|
2476
|
+
return [h]; const m = shiftRangePos(h.range, o); return m ? [{ ...h, range: m }] : []; }) };
|
|
2477
|
+
this.sizesVer++;
|
|
2478
|
+
this.revealVer++;
|
|
2479
|
+
this.anchor = undefined;
|
|
2480
|
+
this.hdrMenu = undefined;
|
|
2481
|
+
this.rz = undefined;
|
|
2482
|
+
this.geom = undefined;
|
|
2483
|
+
this.findHits = [];
|
|
2484
|
+
this.findVersion = -1;
|
|
2485
|
+
for (const el of this.overlayCache.values())
|
|
2486
|
+
el.remove();
|
|
2487
|
+
this.overlayCache.clear();
|
|
2488
|
+
}
|
|
1959
2489
|
/** The `selbar` controls that join a menu (W-49 `menus`), as menu items under a separator. */
|
|
1960
2490
|
pluginMenuItems(menu, sel) {
|
|
1961
2491
|
const s = this._store, snap = s.getSnapshot();
|
|
@@ -1990,7 +2520,7 @@ export class SheetpaneExcel extends ElementBase {
|
|
|
1990
2520
|
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
2521
|
}
|
|
1992
2522
|
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>`;
|
|
2523
|
+
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._store.getSnapshot().mode === 'edit' ? this.structureItems(m.axis === 'col' ? 'cols' : 'rows', m.indexes) : ''}${this.pluginMenuItems('header')}</div>`;
|
|
1994
2524
|
}
|
|
1995
2525
|
/** A header click: the whole column, row or sheet; with Shift, out to it from the anchor. */
|
|
1996
2526
|
selectHeader(h, extend) {
|
|
@@ -2086,7 +2616,7 @@ export class SheetpaneExcel extends ElementBase {
|
|
|
2086
2616
|
return;
|
|
2087
2617
|
}
|
|
2088
2618
|
}
|
|
2089
|
-
if (e.altKey && !e.shiftKey && k === 'ArrowDown' && edit) {
|
|
2619
|
+
if (e.altKey && !e.shiftKey && k === 'ArrowDown' && edit && !s.isLocked(snap.selection.sheet, snap.selection.ref)) {
|
|
2090
2620
|
const ctx = s.cellContext(snap.selection.sheet, snap.selection.ref);
|
|
2091
2621
|
const mc = ctx && controlsFor(s.plugins, 'cell', 'edit').find((c) => c.cellOptions && c.choose && c.appliesTo?.(ctx));
|
|
2092
2622
|
if (mc && ctx) {
|
|
@@ -2152,6 +2682,10 @@ export class SheetpaneExcel extends ElementBase {
|
|
|
2152
2682
|
s.redo(this);
|
|
2153
2683
|
else if (mod && (k === 'c' || k === 'C'))
|
|
2154
2684
|
this.copySelection();
|
|
2685
|
+
else if (edit && mod && !e.altKey && (k === '+' || (e.shiftKey && k === '=')))
|
|
2686
|
+
this.structureKey('insert');
|
|
2687
|
+
else if (edit && mod && !e.altKey && !e.shiftKey && (k === '-' || k === '_'))
|
|
2688
|
+
this.structureKey('delete');
|
|
2155
2689
|
else if ((k === 'F10' && e.shiftKey) || k === 'ContextMenu') {
|
|
2156
2690
|
// a whole column or row selected: its size menu (W-47); otherwise the selection actions
|
|
2157
2691
|
const whole = range && (range.top === 0 && range.bottom >= g.g.rows - 1 ? 'col' : range.left === 0 && range.right >= g.g.cols - 1 ? 'row' : undefined);
|
|
@@ -2327,6 +2861,7 @@ export class SheetpaneExcel extends ElementBase {
|
|
|
2327
2861
|
if (!g)
|
|
2328
2862
|
return;
|
|
2329
2863
|
const cells = [];
|
|
2864
|
+
this.lockedRefused = 0;
|
|
2330
2865
|
const left = this.hiddenIn(g.g, r);
|
|
2331
2866
|
if (left)
|
|
2332
2867
|
this.say0(`Cleared the shown cells — ${left} hidden cell${left === 1 ? '' : 's'} left out`);
|
|
@@ -2342,6 +2877,7 @@ export class SheetpaneExcel extends ElementBase {
|
|
|
2342
2877
|
}
|
|
2343
2878
|
if (cells.length)
|
|
2344
2879
|
s.setCells(cells, { origin: this });
|
|
2880
|
+
this.sayLocked();
|
|
2345
2881
|
}
|
|
2346
2882
|
rangeEnd(sel) {
|
|
2347
2883
|
const r = parseRange(sel.range), a = this.anchor;
|
|
@@ -2349,6 +2885,11 @@ export class SheetpaneExcel extends ElementBase {
|
|
|
2349
2885
|
}
|
|
2350
2886
|
editCell(first) {
|
|
2351
2887
|
const cell = this.q.cell;
|
|
2888
|
+
const sel = this._store?.getSnapshot().selection;
|
|
2889
|
+
if (sel && this._store.isLocked(sel.sheet, sel.ref)) {
|
|
2890
|
+
this.refuseLocked(sel.sheet, sel.ref);
|
|
2891
|
+
return;
|
|
2892
|
+
} // W-62: the cell bar's chip says why
|
|
2352
2893
|
if (cell.hidden)
|
|
2353
2894
|
return;
|
|
2354
2895
|
cell.focus();
|
|
@@ -2421,11 +2962,13 @@ export class SheetpaneExcel extends ElementBase {
|
|
|
2421
2962
|
}
|
|
2422
2963
|
}
|
|
2423
2964
|
onOverlayClick(e) {
|
|
2965
|
+
if (inDetail(e.target))
|
|
2966
|
+
return; // the host's region in the card: its clicks are the host's (W-68)
|
|
2424
2967
|
const t = e.target, s = this._store, snap = s.getSnapshot();
|
|
2425
2968
|
const act = t.closest('[data-act]')?.dataset.act;
|
|
2426
2969
|
const pin = t.closest('[data-pin]');
|
|
2427
2970
|
if (pin) {
|
|
2428
|
-
const err = snap.
|
|
2971
|
+
const err = snap.visibleErrors.find((x) => x.id === pin.dataset.pin);
|
|
2429
2972
|
if (err) {
|
|
2430
2973
|
const r = parseRange(err.range);
|
|
2431
2974
|
this.anchor = { row: r.top, col: r.left };
|
|
@@ -2485,12 +3028,27 @@ export class SheetpaneExcel extends ElementBase {
|
|
|
2485
3028
|
this.schedule();
|
|
2486
3029
|
this.q.grid.focus({ preventScroll: true });
|
|
2487
3030
|
break;
|
|
3031
|
+
case 'ins-above':
|
|
3032
|
+
case 'ins-below':
|
|
3033
|
+
case 'ins-left':
|
|
3034
|
+
case 'ins-right':
|
|
3035
|
+
case 'del-rows':
|
|
3036
|
+
case 'del-cols': {
|
|
3037
|
+
const ix = this.hdrMenu?.indexes;
|
|
3038
|
+
this.hdrMenu = undefined;
|
|
3039
|
+
this.runStructure(act, ix);
|
|
3040
|
+
this.q.grid.focus({ preventScroll: true });
|
|
3041
|
+
break;
|
|
3042
|
+
}
|
|
2488
3043
|
case 'flag':
|
|
2489
3044
|
this.flag();
|
|
2490
3045
|
break;
|
|
2491
3046
|
case 'copy-ref':
|
|
2492
3047
|
this.copy(this.refOf(sel));
|
|
2493
3048
|
break;
|
|
3049
|
+
case 'copy-link':
|
|
3050
|
+
this.copy(this.linkUrl());
|
|
3051
|
+
break;
|
|
2494
3052
|
case 'copy-value':
|
|
2495
3053
|
this.copySelection();
|
|
2496
3054
|
break;
|
|
@@ -2530,6 +3088,8 @@ export class SheetpaneExcel extends ElementBase {
|
|
|
2530
3088
|
}
|
|
2531
3089
|
}
|
|
2532
3090
|
onComposerInput(e) {
|
|
3091
|
+
if (inDetail(e.target))
|
|
3092
|
+
return;
|
|
2533
3093
|
const t = e.target;
|
|
2534
3094
|
const f = t.dataset.field;
|
|
2535
3095
|
if (!f)
|
|
@@ -2564,7 +3124,7 @@ export class SheetpaneExcel extends ElementBase {
|
|
|
2564
3124
|
}
|
|
2565
3125
|
stepError(dir) {
|
|
2566
3126
|
const s = this._store, snap = s.getSnapshot();
|
|
2567
|
-
const list = snap.
|
|
3127
|
+
const list = snap.visibleErrors.filter((e) => e.sheet === snap.selection.sheet && errorScope(e) === 'cell' && !isResult(e)); // a sheet-level error has no cell to step to (W-55); a result is not a problem (W-50)
|
|
2568
3128
|
if (!list.length)
|
|
2569
3129
|
return;
|
|
2570
3130
|
const cur = this.errorAt(snap.selection.sheet, snap.selection.ref);
|
|
@@ -2574,13 +3134,15 @@ export class SheetpaneExcel extends ElementBase {
|
|
|
2574
3134
|
this.anchor = { row: r.top, col: r.left };
|
|
2575
3135
|
s.select({ sheet: nx.sheet, ref: cellRef(r.top, r.left) }, this);
|
|
2576
3136
|
}
|
|
3137
|
+
/** A result's check — its message, else its type's name: what its mark, chip and group say (W-50). */
|
|
3138
|
+
checkOf(e) { return e.message || (this._errorTypes.find((t) => t.id === e.type)?.name ?? BUILTIN_TYPE_NAMES[e.type] ?? e.type); }
|
|
2577
3139
|
errorAt(sheet, ref) {
|
|
2578
3140
|
const p = parseRef(ref);
|
|
2579
3141
|
if (!p)
|
|
2580
3142
|
return undefined;
|
|
2581
|
-
const hits = this._store.getSnapshot().
|
|
2582
|
-
if (e.sheet !== sheet)
|
|
2583
|
-
return false;
|
|
3143
|
+
const hits = this._store.getSnapshot().visibleErrors.filter((e) => {
|
|
3144
|
+
if (e.sheet !== sheet || isResult(e))
|
|
3145
|
+
return false; // a result is never the cell's error (W-50)
|
|
2584
3146
|
const r = parseRange(e.range);
|
|
2585
3147
|
return !!r && p.row >= r.top && p.row <= r.bottom && p.col >= r.left && p.col <= r.right;
|
|
2586
3148
|
});
|
|
@@ -2669,6 +3231,7 @@ export class SheetpaneExcel extends ElementBase {
|
|
|
2669
3231
|
this.renderState();
|
|
2670
3232
|
q.frow.hidden = true;
|
|
2671
3233
|
q.status.textContent = 'Read in this tab — nothing is uploaded';
|
|
3234
|
+
delete q.status.dataset.html;
|
|
2672
3235
|
return;
|
|
2673
3236
|
}
|
|
2674
3237
|
const book = snap.book, src = snap.source;
|
|
@@ -2678,11 +3241,12 @@ export class SheetpaneExcel extends ElementBase {
|
|
|
2678
3241
|
// toolbar
|
|
2679
3242
|
q.fname.textContent = src.name;
|
|
2680
3243
|
q.meta.textContent = `${src.kind} · ${fmtSize(src.size)} · ${src.kind === 'csv' ? delimName(book.delimiter) : `${book.sheets.length} sheet${book.sheets.length === 1 ? '' : 's'}`}`;
|
|
3244
|
+
q.fname.title = `${src.name} · ${q.meta.textContent}`; // the meta chip is shed at narrow widths
|
|
2681
3245
|
for (const b of Array.from(q.modeseg.querySelectorAll('button')))
|
|
2682
3246
|
b.setAttribute('aria-pressed', String(b.dataset.mode === snap.mode));
|
|
2683
3247
|
const open = { error: 0, warning: 0, note: 0 };
|
|
2684
|
-
for (const e of snap.
|
|
2685
|
-
if (e.status === 'open')
|
|
3248
|
+
for (const e of snap.visibleErrors)
|
|
3249
|
+
if (e.status === 'open' && !isResult(e))
|
|
2686
3250
|
open[e.severity]++;
|
|
2687
3251
|
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
3252
|
q.editonly.style.display = edit ? 'contents' : 'none';
|
|
@@ -2716,7 +3280,8 @@ export class SheetpaneExcel extends ElementBase {
|
|
|
2716
3280
|
const merge = mergeAt(g.g, p.row, p.col);
|
|
2717
3281
|
q.ref.textContent = sel.range ?? (merge ? `${cellRef(merge.top, merge.left)}:${cellRef(merge.bottom, merge.right)}` : sel.ref);
|
|
2718
3282
|
const composing = !!snap.composing;
|
|
2719
|
-
const
|
|
3283
|
+
const locked = edit && s.isLocked(sel.sheet, sel.ref); // W-62: a locked cell shows its value, not an input
|
|
3284
|
+
const editable = edit && !composing && !locked;
|
|
2720
3285
|
const cell = q.cell;
|
|
2721
3286
|
cell.hidden = !editable;
|
|
2722
3287
|
q.val.hidden = editable;
|
|
@@ -2740,6 +3305,13 @@ export class SheetpaneExcel extends ElementBase {
|
|
|
2740
3305
|
q.issue.className = `chip ${err.status === 'resolved' ? 'chip-pass' : err.severity === 'error' ? 'chip-fail' : err.severity === 'warning' ? 'chip-warn' : 'chip-info'}`;
|
|
2741
3306
|
q.issue.textContent = `${err.status === 'resolved' ? '✓' : SEVERITY[err.severity].glyph} Error ${errNum(err)}`;
|
|
2742
3307
|
}
|
|
3308
|
+
// a result cell (W-50): the one place its mark is explained in words — "✓ Passed · Fee rate 0.00%–2.00%"
|
|
3309
|
+
const res = s.results !== 'off' ? s.resultAt(sel.sheet, p.row, p.col) : undefined;
|
|
3310
|
+
q.result.hidden = !res;
|
|
3311
|
+
if (res) {
|
|
3312
|
+
q.result.className = `chip result ${res.severity === 'pass' ? 'chip-pass' : 'chip-skip'}`;
|
|
3313
|
+
q.result.textContent = `${SEVERITY[res.severity].glyph} ${SEVERITY[res.severity].name} · ${this.checkOf(res)}`;
|
|
3314
|
+
}
|
|
2743
3315
|
q.hint.hidden = !(composing && s.hasErrorsView);
|
|
2744
3316
|
// column rules: the draft's failure as typed, or a refused value (W-27)
|
|
2745
3317
|
const rm = this.ruleMsg && this.ruleMsg.sheet === sel.sheet && this.ruleMsg.ref === sel.ref ? this.ruleMsg : undefined;
|
|
@@ -2755,13 +3327,23 @@ export class SheetpaneExcel extends ElementBase {
|
|
|
2755
3327
|
}
|
|
2756
3328
|
else
|
|
2757
3329
|
cell.removeAttribute('title');
|
|
3330
|
+
// a rich-text cell being edited (W-53): saying so before the edit lands, since it writes plain text
|
|
3331
|
+
const richEdit = edit && !!this.editing && this.editing.sheet === sel.sheet && this.editing.ref === sel.ref && !!s.runs(sel.sheet, sel.ref);
|
|
3332
|
+
q.richmsg.hidden = !richEdit;
|
|
3333
|
+
if (richEdit)
|
|
3334
|
+
q.richmsg.textContent = `${SEVERITY.warning.glyph} Mixed formatting — an edit saves plain text`;
|
|
3335
|
+
q.lockmsg.hidden = !locked;
|
|
3336
|
+
if (locked)
|
|
3337
|
+
q.lockmsg.textContent = `🔒 ${LOCKED_MESSAGE}`;
|
|
2758
3338
|
q.plugCellbar.innerHTML = this.controlsHtml(controlsFor(s.plugins, 'cellbar', snap.mode), 'btn btn-g');
|
|
2759
3339
|
// docked selection actions (W-31): Flag issue · selbar plugin controls · ⋯ — hidden while typing or composing
|
|
2760
3340
|
const typing = !!this.editing && this.shadow.activeElement === cell;
|
|
2761
3341
|
q.dock.hidden = typing || composing;
|
|
2762
3342
|
if (!q.dock.hidden) {
|
|
2763
3343
|
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
|
-
|
|
3344
|
+
// the bar holds the cell's quick actions (Bold · Fill · Merge, greyed when they do not apply); a menu action (W-49 `menus`)
|
|
3345
|
+
// shows here only when it joins the cell menu and applies now (Unhide with nothing hidden, Group, Hide sheet stay in their menus)
|
|
3346
|
+
const plug = edit ? this.controlsHtml(controlsFor(s.plugins, 'selbar', 'edit').filter((c) => !c.menus || (c.menus.includes('cell') && !c.disabled?.(snap, snap.selection))), 'btn btn-g') : '';
|
|
2765
3347
|
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
3348
|
// the ⋯ menu: Copy ref, Copy value, and the plugin buttons, which show here only once they fold out of the bar
|
|
2767
3349
|
const menu = this.moreOpen ? `<div class="card moremenu" role="menu" aria-label="More actions">${this.actionItems(edit, err, { flag: false, plugins: true })}</div>` : '';
|
|
@@ -2770,6 +3352,7 @@ export class SheetpaneExcel extends ElementBase {
|
|
|
2770
3352
|
q.dock.innerHTML = html;
|
|
2771
3353
|
q.dock.dataset.html = html;
|
|
2772
3354
|
}
|
|
3355
|
+
this.fitDock();
|
|
2773
3356
|
}
|
|
2774
3357
|
else if (this.moreOpen)
|
|
2775
3358
|
this.moreOpen = false;
|
|
@@ -2777,7 +3360,9 @@ export class SheetpaneExcel extends ElementBase {
|
|
|
2777
3360
|
const frow = controlsFor(s.plugins, 'format-row', 'edit');
|
|
2778
3361
|
const hostSlot = q.frow.querySelector('slot').assignedElements().length > 0;
|
|
2779
3362
|
q.frow.hidden = !edit || (!frow.length && !hostSlot);
|
|
2780
|
-
q.plugFrow.innerHTML = this.
|
|
3363
|
+
q.plugFrow.innerHTML = this.frowHtml(frow);
|
|
3364
|
+
if (!q.frow.hidden)
|
|
3365
|
+
this.fitFrow();
|
|
2781
3366
|
// find
|
|
2782
3367
|
if (this.findText)
|
|
2783
3368
|
this.computeHits();
|
|
@@ -2788,9 +3373,10 @@ export class SheetpaneExcel extends ElementBase {
|
|
|
2788
3373
|
q.tabs.hidden = src.kind === 'csv';
|
|
2789
3374
|
// W-49: hidden sheets are not tabs — one ⊘ button lists them; one opened for viewing shows dashed
|
|
2790
3375
|
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
|
|
3376
|
+
const mine = edit ? snap.visibleErrors.filter((e) => e.sheet === sh.name && e.status === 'open' && !isResult(e)) : [];
|
|
3377
|
+
const cnt = mine.length, whole = mine.filter((e) => e.range === '').length; // W-55: a whole-sheet error is counted on its tab; results are not (W-50)
|
|
2792
3378
|
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>`;
|
|
3379
|
+
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${whole ? `, ${whole} about the whole sheet` : ''}">${cnt}</span>` : ''}</button>`;
|
|
2794
3380
|
}).join('');
|
|
2795
3381
|
const tabsHtml2 = `<div class="tablist" role="tablist" aria-label="Sheets">${tabsHtml}</div>${this.hiddenSheetsHtml()}`;
|
|
2796
3382
|
if (q.tabs.dataset.html !== tabsHtml2) {
|
|
@@ -2806,21 +3392,32 @@ export class SheetpaneExcel extends ElementBase {
|
|
|
2806
3392
|
const curHidden = s.hidden(sheet.name).state !== 'visible';
|
|
2807
3393
|
const note = this.revealNote?.sheet === sheet.name ? this.revealNote : undefined;
|
|
2808
3394
|
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>' : ''}`
|
|
3395
|
+
? `<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() ? (s.structureLocked() ? `<button type="button" class="btn btn-g" data-act="unhide-sheet" disabled title="${esc(STRUCTURE_LOCKED_MESSAGE)}">Unhide</button>` : '<button type="button" class="btn btn-g" data-act="unhide-sheet">Unhide</button>') : ''}`
|
|
2810
3396
|
: 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
3397
|
q.hidbar.hidden = !barHtml;
|
|
2812
3398
|
if (q.hidbar.dataset.html !== barHtml) {
|
|
2813
3399
|
q.hidbar.innerHTML = barHtml;
|
|
2814
3400
|
q.hidbar.dataset.html = barHtml;
|
|
2815
3401
|
}
|
|
2816
|
-
|
|
2817
|
-
//
|
|
3402
|
+
// status line — before the grid, so a scroll to the selection measures the grid's final height (W-66: a
|
|
3403
|
+
// file opened at a ref; the line's first content after a load would otherwise shrink it afterwards)
|
|
2818
3404
|
const statusPlug = controlsFor(s.plugins, 'status', snap.mode).map((c) => c.text?.(snap, sel) ?? '').filter(Boolean);
|
|
2819
3405
|
const dataRows = sheet.rows.length, dataCols = sheet.rows.reduce((m, r) => Math.max(m, r.length), 0);
|
|
2820
|
-
|
|
3406
|
+
// W-56: the host's highlight label, while an outline shows on this sheet
|
|
3407
|
+
const hlLabel = this.hl?.label && this.hl.items.some((x) => x.sheet === sheet.name) ? this.hl.label : '';
|
|
3408
|
+
// the line is a live region: its nodes are replaced only when its text changes, so a label is said once
|
|
3409
|
+
const statusHtml = [
|
|
2821
3410
|
`<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
3411
|
n ? `<span>· ${n} cell change${n === 1 ? '' : 's'} kept in memory until you export</span>` : '',
|
|
2823
3412
|
(() => { const t = this.totalsFor(g.g); return t ? `<span class="totals" data-sp="totals">· ${esc(t)}</span>` : ''; })(),
|
|
3413
|
+
// the host's coverage line (W-50): "186 of 214 checked · 6 problems" — only when the pane holds results
|
|
3414
|
+
(() => {
|
|
3415
|
+
const rc = s.resultCounts();
|
|
3416
|
+
if (s.results === 'off' || !(rc.pass + rc.skipped))
|
|
3417
|
+
return '';
|
|
3418
|
+
const checked = rc.pass + rc.problems;
|
|
3419
|
+
return `<span class="checked">· ${checked} of ${checked + rc.skipped} checked · ${rc.open} problem${rc.open === 1 ? '' : 's'}</span>`;
|
|
3420
|
+
})(),
|
|
2824
3421
|
(() => {
|
|
2825
3422
|
const h = s.hidden(sheet.name);
|
|
2826
3423
|
if (!h.rows.length && !h.cols.length)
|
|
@@ -2830,11 +3427,17 @@ export class SheetpaneExcel extends ElementBase {
|
|
|
2830
3427
|
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
3428
|
})(),
|
|
2832
3429
|
...statusPlug.map((t) => `<span>${esc(t)}</span>`),
|
|
3430
|
+
hlLabel ? `<span class="hlnote">· ${esc(hlLabel)}</span>` : '',
|
|
2833
3431
|
this.notice ? `<span role="status" class="warn-t">· ${esc(this.notice)}</span>` : '',
|
|
2834
3432
|
s.ruleProgress ? `<span role="status">· Checking column rules · ${Math.floor((100 * s.ruleProgress.done) / Math.max(1, s.ruleProgress.total))} %</span>` : '',
|
|
2835
3433
|
`<span class="spacer"></span><span>Read in this tab — nothing uploaded</span>`,
|
|
2836
3434
|
this.getAttribute('display') === 'popup' ? '<span>Esc to close</span>' : '',
|
|
2837
3435
|
].join('');
|
|
3436
|
+
if (q.status.dataset.html !== statusHtml) {
|
|
3437
|
+
q.status.innerHTML = statusHtml;
|
|
3438
|
+
q.status.dataset.html = statusHtml;
|
|
3439
|
+
}
|
|
3440
|
+
this.renderGrid(g.g, sheet, snap.mode);
|
|
2838
3441
|
}
|
|
2839
3442
|
/** Count · Sum · Average of a range of two or more cells (W-46 DoD 8), cached until the range or the cells change. */
|
|
2840
3443
|
totalsFor(g) {
|
|
@@ -2914,6 +3517,12 @@ export class SheetpaneExcel extends ElementBase {
|
|
|
2914
3517
|
this.scrollToSel = false;
|
|
2915
3518
|
this.scrollSelIntoView(g, z, fr, fc);
|
|
2916
3519
|
}
|
|
3520
|
+
if (this.scrollToHl && sc.clientWidth > 0) {
|
|
3521
|
+
const t = this.scrollToHl;
|
|
3522
|
+
this.scrollToHl = undefined;
|
|
3523
|
+
if (t.sheet === sheet.name)
|
|
3524
|
+
this.scrollRangeIntoView(g, z, fr, fc, t.range, 4);
|
|
3525
|
+
} // W-56 `reveal`: the outline and its halo show too
|
|
2917
3526
|
const win = visibleWindow(g, z, sc.scrollTop, sc.scrollLeft, sc.clientHeight || 600, sc.clientWidth || 900, { rows: fr, cols: fc });
|
|
2918
3527
|
const grid = q.grid;
|
|
2919
3528
|
grid.style.cssText = `position:relative;width:${totalW}px;height:${totalH}px`;
|
|
@@ -2928,13 +3537,13 @@ export class SheetpaneExcel extends ElementBase {
|
|
|
2928
3537
|
const marks = new Map();
|
|
2929
3538
|
const rowWorst = new Map();
|
|
2930
3539
|
if (edit)
|
|
2931
|
-
for (const e of snap.
|
|
2932
|
-
if (e.sheet !== sheet.name)
|
|
2933
|
-
continue;
|
|
3540
|
+
for (const e of snap.visibleErrors) {
|
|
3541
|
+
if (e.sheet !== sheet.name || isResult(e))
|
|
3542
|
+
continue; // results: a corner glyph, never a fill or a row glyph (W-50)
|
|
2934
3543
|
const r = parseRange(e.range);
|
|
2935
3544
|
if (!r)
|
|
2936
3545
|
continue;
|
|
2937
|
-
const m = e.status === 'resolved' ? 'm-pass' : `m-${e.severity}`;
|
|
3546
|
+
const m = e.status === 'resolved' ? 'm-pass' : e.status === 'accepted' ? 'm-accepted' : `m-${e.severity}`; // an accepted deviation is marked quietly (W-69)
|
|
2938
3547
|
for (let rr = r.top; rr <= Math.min(r.bottom, r.top + 2000); rr++)
|
|
2939
3548
|
for (let cc = r.left; cc <= r.right; cc++)
|
|
2940
3549
|
marks.set(`${rr},${cc}`, m);
|
|
@@ -2945,6 +3554,8 @@ export class SheetpaneExcel extends ElementBase {
|
|
|
2945
3554
|
rowWorst.set(rr, e.severity);
|
|
2946
3555
|
}
|
|
2947
3556
|
}
|
|
3557
|
+
// result marks (W-50): every result cell at `marks`; at `summary` only the range Show ▸ asked for
|
|
3558
|
+
const markAll = s.results === 'marks', focus = s.results !== 'off' && s.resultFocus?.sheet === sheet.name ? parseRange(s.resultFocus.range) : undefined;
|
|
2948
3559
|
const cellControls = controlsFor(s.plugins, 'cell', mode);
|
|
2949
3560
|
const decorating = s.plugins.some((p) => p.provides.includes('decorate'));
|
|
2950
3561
|
const covered = (r, c) => { const m = mergeAt(g, r, c); return m && !(m.top === r && m.left === c) ? m : undefined; };
|
|
@@ -2994,12 +3605,21 @@ export class SheetpaneExcel extends ElementBase {
|
|
|
2994
3605
|
let extra = '';
|
|
2995
3606
|
if (cellControls.length) {
|
|
2996
3607
|
const ctx = s.cellContext(sheet.name, ref);
|
|
3608
|
+
const locked = cellControls.some((cc) => cc.choose) && s.isLocked(sheet.name, ref); // W-62: no picker that would write a locked cell
|
|
2997
3609
|
for (const cc of cellControls)
|
|
2998
|
-
if (ctx && cc.appliesTo?.(ctx))
|
|
3610
|
+
if (ctx && !(locked && cc.choose) && cc.appliesTo?.(ctx))
|
|
2999
3611
|
extra += `<button type="button" class="mk" data-control="${esc(cc.id)}" aria-label="${esc(cc.label)}" tabindex="-1">${cc.icon ?? '▾'}</button>`;
|
|
3000
3612
|
}
|
|
3613
|
+
if (markAll || (focus && r >= focus.top && r <= focus.bottom && c >= focus.left && c <= focus.right)) {
|
|
3614
|
+
const rs = s.resultAt(sheet.name, r, c); // a problem always wins: none there
|
|
3615
|
+
if (rs)
|
|
3616
|
+
extra += `<span class="rmk ${rs.severity}" role="img" aria-label="${SEVERITY[rs.severity].name}: ${esc(this.checkOf(rs))}">${SEVERITY[rs.severity].glyph}</span>`;
|
|
3617
|
+
}
|
|
3001
3618
|
const title = stale ? ' title="May be stale — Excel recalculates on open"' : text.length > 24 ? ` title="${esc(text)}"` : '';
|
|
3002
|
-
|
|
3619
|
+
// a rich-text cell (W-53): its runs as spans when a decorate plugin draws them, else the plain text
|
|
3620
|
+
const pieces = decorating ? s.decorateRuns(sheet.name, ref) : undefined;
|
|
3621
|
+
const inner = pieces ? pieces.map((x) => (x.style ? `<span style="${esc(Object.entries(x.style).map(([k2, v2]) => `${k2}:${v2}`).join(';'))}">${esc(x.text)}</span>` : esc(x.text))).join('') : esc(text);
|
|
3622
|
+
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">${inner}</span>${extra}</div>`;
|
|
3003
3623
|
};
|
|
3004
3624
|
const rowHead = (r) => {
|
|
3005
3625
|
const w = rowWorst.get(r);
|
|
@@ -3164,22 +3784,27 @@ export class SheetpaneExcel extends ElementBase {
|
|
|
3164
3784
|
this.ariaOn = next;
|
|
3165
3785
|
}
|
|
3166
3786
|
scrollSelIntoView(g, z, fr, fc) {
|
|
3167
|
-
const
|
|
3168
|
-
|
|
3169
|
-
|
|
3787
|
+
const p = parseRef(this._store.getSnapshot().selection.ref);
|
|
3788
|
+
this.scrollRangeIntoView(g, z, fr, fc, { top: p.row, left: p.col, bottom: p.row, right: p.col });
|
|
3789
|
+
}
|
|
3790
|
+
/** Scrolls so a range shows, `pad` px around it — its top-left first when it does not fit; frozen rows and columns never scroll. */
|
|
3791
|
+
scrollRangeIntoView(g, z, fr, fc, r, pad = 0) {
|
|
3792
|
+
const sc = this.q.scroller, HH = this.headH() * z, RW = this.rowHeadW() * z;
|
|
3170
3793
|
const stickTop = HH + rowTop(g, fr) * z, stickLeft = RW + g.colX[fc] * z;
|
|
3171
|
-
if (
|
|
3172
|
-
|
|
3173
|
-
|
|
3174
|
-
|
|
3175
|
-
|
|
3176
|
-
|
|
3177
|
-
|
|
3178
|
-
|
|
3794
|
+
if (r.top >= fr) {
|
|
3795
|
+
const top = rowTop(g, r.top) * z + HH - pad, bottom = rowTop(g, r.bottom + 1) * z + HH + pad;
|
|
3796
|
+
if (top < sc.scrollTop + stickTop)
|
|
3797
|
+
sc.scrollTop = Math.max(0, top - stickTop);
|
|
3798
|
+
else if (bottom > sc.scrollTop + sc.clientHeight)
|
|
3799
|
+
sc.scrollTop = Math.min(top - stickTop, bottom - sc.clientHeight);
|
|
3800
|
+
}
|
|
3801
|
+
const left0 = Math.min(r.left, g.cols - 1), right0 = Math.min(r.right, g.cols - 1);
|
|
3802
|
+
if (left0 >= fc) {
|
|
3803
|
+
const left = g.colX[left0] * z + RW - pad, right = (g.colX[right0 + 1] ?? g.colX[right0]) * z + RW + pad;
|
|
3179
3804
|
if (left < sc.scrollLeft + stickLeft)
|
|
3180
3805
|
sc.scrollLeft = Math.max(0, left - stickLeft);
|
|
3181
3806
|
else if (right > sc.scrollLeft + sc.clientWidth)
|
|
3182
|
-
sc.scrollLeft = right - sc.clientWidth;
|
|
3807
|
+
sc.scrollLeft = Math.min(left - stickLeft, right - sc.clientWidth);
|
|
3183
3808
|
}
|
|
3184
3809
|
}
|
|
3185
3810
|
renderOverlay(g, sheet, z, RW, HH, Y, H, X, W, win) {
|
|
@@ -3196,9 +3821,9 @@ export class SheetpaneExcel extends ElementBase {
|
|
|
3196
3821
|
}
|
|
3197
3822
|
const sel = snap.selection, selP = parseRef(sel.ref);
|
|
3198
3823
|
if (edit) {
|
|
3199
|
-
for (const e of snap.
|
|
3200
|
-
if (e.sheet !== sheet.name)
|
|
3201
|
-
continue;
|
|
3824
|
+
for (const e of snap.visibleErrors) {
|
|
3825
|
+
if (e.sheet !== sheet.name || isResult(e))
|
|
3826
|
+
continue; // a result has no pin (W-50)
|
|
3202
3827
|
const r = parseRange(e.range);
|
|
3203
3828
|
if (!r || r.top > win.r1 + 40)
|
|
3204
3829
|
continue;
|
|
@@ -3206,7 +3831,7 @@ export class SheetpaneExcel extends ElementBase {
|
|
|
3206
3831
|
const on = r.top === selP.row && selP.col >= r.left && selP.col <= r.right;
|
|
3207
3832
|
const mach = machineAuthored(e.author);
|
|
3208
3833
|
const left = mach ? RW + X(r.right + 1) - 18 : p.x - 6;
|
|
3209
|
-
parts.push(`<button type="button" class="pin ${e.status === '
|
|
3834
|
+
parts.push(`<button type="button" class="pin ${e.status === 'open' ? e.severity : e.status}${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 === 'open' ? '' : `, ${e.status}`}" style="top:${p.y - 8}px;left:${left}px">${e.status === 'resolved' ? '✓' : e.status === 'accepted' ? 'A' : mach ? '⚠' : errNum(e)}</button>`);
|
|
3210
3835
|
}
|
|
3211
3836
|
}
|
|
3212
3837
|
const composing = snap.composing;
|
|
@@ -3291,8 +3916,45 @@ export class SheetpaneExcel extends ElementBase {
|
|
|
3291
3916
|
}
|
|
3292
3917
|
}
|
|
3293
3918
|
}
|
|
3919
|
+
this.detailRegion.sync(cardEl, card.startsWith('<div class="card callout"') ? err : undefined); // the host's region in the card (W-68)
|
|
3920
|
+
this.renderHighlight(g, sheet, RW, HH, Y, X, ov, fixed);
|
|
3294
3921
|
this.renderPluginOverlays(sheet, z, RW, HH, ov);
|
|
3295
3922
|
}
|
|
3923
|
+
/**
|
|
3924
|
+
* The host's highlight (W-56): one box per range on this sheet, 2 px outside the cells so the
|
|
3925
|
+
* selection ring stays visible, in the overlay under the pins and cards; `pointer-events: none`, so
|
|
3926
|
+
* the pointer and clicks reach the cells below. Hidden rows and columns are 0 px, so a range wholly in
|
|
3927
|
+
* them draws nothing.
|
|
3928
|
+
*/
|
|
3929
|
+
renderHighlight(g, sheet, RW, HH, Y, X, ov, before) {
|
|
3930
|
+
let html = '';
|
|
3931
|
+
for (const it of this.hl?.items ?? []) {
|
|
3932
|
+
const r = it.range;
|
|
3933
|
+
if (it.sheet !== sheet.name || r.top >= g.rows || r.left >= g.cols)
|
|
3934
|
+
continue;
|
|
3935
|
+
const b = Math.min(r.bottom, g.rows - 1), rt = Math.min(r.right, g.cols - 1);
|
|
3936
|
+
const x = RW + X(r.left), y = HH + Y(r.top), w = X(rt + 1) - X(r.left), h = Y(b + 1) - Y(r.top);
|
|
3937
|
+
if (w > 0 && h > 0)
|
|
3938
|
+
html += `<div class="hlbox ${it.tone}" style="left:${x - 2}px;top:${y - 2}px;width:${w + 4}px;height:${h + 4}px"></div>`;
|
|
3939
|
+
}
|
|
3940
|
+
let layer = ov.querySelector(':scope > .hl');
|
|
3941
|
+
if (!html) {
|
|
3942
|
+
layer?.remove();
|
|
3943
|
+
return;
|
|
3944
|
+
}
|
|
3945
|
+
if (!layer) {
|
|
3946
|
+
layer = document.createElement('div');
|
|
3947
|
+
layer.className = 'hl';
|
|
3948
|
+
layer.setAttribute('aria-hidden', 'true');
|
|
3949
|
+
layer.style.cssText = 'position:absolute;inset:0;pointer-events:none';
|
|
3950
|
+
}
|
|
3951
|
+
if (layer.nextSibling !== before)
|
|
3952
|
+
ov.insertBefore(layer, before);
|
|
3953
|
+
if (layer.dataset.html !== html) {
|
|
3954
|
+
layer.innerHTML = html;
|
|
3955
|
+
layer.dataset.html = html;
|
|
3956
|
+
}
|
|
3957
|
+
}
|
|
3296
3958
|
renderPluginOverlays(sheet, z, RW, HH, ov) {
|
|
3297
3959
|
const s = this._store;
|
|
3298
3960
|
const providers = s.plugins.filter((p) => p.provides.includes('overlay'));
|
|
@@ -3353,9 +4015,9 @@ export class SheetpaneExcel extends ElementBase {
|
|
|
3353
4015
|
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
4016
|
const ro = machineAuthored(e.author);
|
|
3355
4017
|
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 === '
|
|
4018
|
+
<div class="hd"><span class="g ${e.status === 'resolved' ? 'g-pass' : `g-${e.status === 'accepted' ? 'accepted' : e.severity}`}" aria-hidden="true">${e.status === 'resolved' ? '✓' : e.status === 'accepted' ? 'A' : 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>
|
|
4019
|
+
<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>` : ''}${this.detailRegion.html()}${acceptedHtml(e)}${tags ? `<div style="display:flex;gap:6px">${tags}</div>` : ''}<span class="xs faint">${esc(meta)}</span></div>
|
|
4020
|
+
${ro ? '' : `<div class="ft"><button type="button" class="btn" data-act="resolve">${e.status === 'open' ? '✓ Resolve' : 'Reopen'}</button><span class="spacer"></span><button type="button" class="btn btn-g btn-d" data-act="delete">Delete</button></div>`}</div>`;
|
|
3359
4021
|
}
|
|
3360
4022
|
composerHtml(left, top, ref) {
|
|
3361
4023
|
const s = this._store, snap = s.getSnapshot(), c = snap.composing;
|