@sheetpane/grid 0.0.0-stage → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +429 -2
- package/custom-elements.json +1246 -0
- package/dist/errors/element.d.ts +108 -0
- package/dist/errors/element.d.ts.map +1 -0
- package/dist/errors/element.js +456 -0
- package/dist/errors/element.js.map +1 -0
- package/dist/excel/element.d.ts +434 -0
- package/dist/excel/element.d.ts.map +1 -0
- package/dist/excel/element.js +3382 -0
- package/dist/excel/element.js.map +1 -0
- package/dist/excel/geometry.d.ts +81 -0
- package/dist/excel/geometry.d.ts.map +1 -0
- package/dist/excel/geometry.js +137 -0
- package/dist/excel/geometry.js.map +1 -0
- package/dist/excel/selection.d.ts +103 -0
- package/dist/excel/selection.d.ts.map +1 -0
- package/dist/excel/selection.js +189 -0
- package/dist/excel/selection.js.map +1 -0
- package/dist/excel/styles.d.ts +3 -0
- package/dist/excel/styles.d.ts.map +1 -0
- package/dist/excel/styles.js +206 -0
- package/dist/excel/styles.js.map +1 -0
- package/dist/frameworks/elements.d.ts +136 -0
- package/dist/frameworks/jsx.d.ts +64 -0
- package/dist/frameworks/jsx.js +2 -0
- package/dist/frameworks/solid.d.ts +133 -0
- package/dist/frameworks/solid.js +2 -0
- package/dist/frameworks/svelte.d.ts +102 -0
- package/dist/frameworks/svelte.js +2 -0
- package/dist/frameworks/vue.d.ts +69 -0
- package/dist/frameworks/vue.js +2 -0
- package/dist/index.d.ts +23 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +25 -0
- package/dist/index.js.map +1 -0
- package/dist/llms-full.txt +7986 -0
- package/dist/llms.txt +108 -0
- package/dist/pane/registry.d.ts +33 -0
- package/dist/pane/registry.d.ts.map +1 -0
- package/dist/pane/registry.js +73 -0
- package/dist/pane/registry.js.map +1 -0
- package/dist/pane/rules.d.ts +231 -0
- package/dist/pane/rules.d.ts.map +1 -0
- package/dist/pane/rules.js +792 -0
- package/dist/pane/rules.js.map +1 -0
- package/dist/pane/store.d.ts +385 -0
- package/dist/pane/store.d.ts.map +1 -0
- package/dist/pane/store.js +1028 -0
- package/dist/pane/store.js.map +1 -0
- package/dist/pane/types.d.ts +130 -0
- package/dist/pane/types.d.ts.map +1 -0
- package/dist/pane/types.js +22 -0
- package/dist/pane/types.js.map +1 -0
- package/dist/plugins/host.d.ts +56 -0
- package/dist/plugins/host.d.ts.map +1 -0
- package/dist/plugins/host.js +124 -0
- package/dist/plugins/host.js.map +1 -0
- package/dist/plugins/types.d.ts +159 -0
- package/dist/plugins/types.d.ts.map +1 -0
- package/dist/plugins/types.js +12 -0
- package/dist/plugins/types.js.map +1 -0
- package/dist/rules.schema.json +52 -0
- package/dist/shared/dom.d.ts +23 -0
- package/dist/shared/dom.d.ts.map +1 -0
- package/dist/shared/dom.js +32 -0
- package/dist/shared/dom.js.map +1 -0
- package/dist/skills-bin.d.ts +3 -0
- package/dist/skills-bin.d.ts.map +1 -0
- package/dist/skills-bin.js +9 -0
- package/dist/skills-bin.js.map +1 -0
- package/dist/style.css +3 -0
- package/dist/tokens.d.ts +49 -0
- package/dist/tokens.d.ts.map +1 -0
- package/dist/tokens.js +43 -0
- package/dist/tokens.js.map +1 -0
- package/dist/vscode.css-custom-data.json +365 -0
- package/dist/vscode.html-custom-data.json +218 -0
- package/dist/web-types.json +914 -0
- package/package.json +70 -3
|
@@ -0,0 +1,1028 @@
|
|
|
1
|
+
// The in-memory workbook one pane shares (contract verdict decisions 1–9; SR-LAW-6,
|
|
2
|
+
// SR-LAW-7, SR-LAW-9). Framework-free: subscribe / getSnapshot, and mutators.
|
|
3
|
+
// Every mutation goes through commit(): undo (W-23), derive and validate
|
|
4
|
+
// (W-14), "matches suggestion" (B-022) and one notification.
|
|
5
|
+
import { cellRef, detailOf, parseRange, parseRef, patchXlsx, rangesIntersect, spError, spMessage, toCsv, toXlsx } from '@sheetpane/export';
|
|
6
|
+
import { formatCode, general, ImportError, read, subsetCode } from '@sheetpane/import';
|
|
7
|
+
import { checkPlugins, mergeDecorations } from '../plugins/host.js';
|
|
8
|
+
import { HostRules } from './rules.js';
|
|
9
|
+
import { machineAuthored } from './types.js';
|
|
10
|
+
/** The undo stack's depth per pane; the oldest entry goes first (W-23, SR-GRID decision 7). */
|
|
11
|
+
export const HISTORY_CAP = 500;
|
|
12
|
+
const BLANK = '(blank)';
|
|
13
|
+
/**
|
|
14
|
+
* The change-map key of a cell: `Sheet1!B3` (exported as `cellKey`).
|
|
15
|
+
* @param sheet - The sheet name.
|
|
16
|
+
* @param ref - The A1 ref, or a slotted ref such as `#style:A1`.
|
|
17
|
+
* @returns `sheet!ref`.
|
|
18
|
+
*/
|
|
19
|
+
export const key = (sheet, ref) => `${sheet}!${ref}`;
|
|
20
|
+
/** Kinds that sit beside a cell's value in their own slot: `sheet!#style:A1`, `sheet!#numFmt:A1`. */
|
|
21
|
+
export const SLOTTED_KINDS = ['style', 'numFmt'];
|
|
22
|
+
/** The change-map ref of a kind for a cell: `A1` for value and formula, `#style:A1` for style … */
|
|
23
|
+
export const kindRef = (ref, kind) => (ref.startsWith('#') || !SLOTTED_KINDS.includes(kind) ? ref : `#${kind}:${ref}`);
|
|
24
|
+
/** The cell a change-map ref belongs to: `#style:A1` → `A1`. */
|
|
25
|
+
export const baseRef = (ref) => ref.replace(/^#(?:style|numFmt):/, '');
|
|
26
|
+
/**
|
|
27
|
+
* Splits a change-map key at its last `!`: `Sheet1!B3` → `{ sheet: 'Sheet1', ref: 'B3' }`.
|
|
28
|
+
* @param k - A key made by `cellKey`.
|
|
29
|
+
* @returns The sheet name and the ref.
|
|
30
|
+
*/
|
|
31
|
+
export function splitKey(k) {
|
|
32
|
+
const i = k.lastIndexOf('!');
|
|
33
|
+
return { sheet: k.slice(0, i), ref: k.slice(i + 1) };
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* A value typed into the cell bar, as the cell will store it (SR-GRID decision 3): in a
|
|
37
|
+
* `.csv` it stays text; in an `.xlsx` a number or blank cell takes a number, a boolean cell
|
|
38
|
+
* `TRUE`/`FALSE`.
|
|
39
|
+
* @param text - What was typed; `''` clears the cell.
|
|
40
|
+
* @param original - The cell's uploaded value, which decides the parse.
|
|
41
|
+
* @param source - The workbook's kind.
|
|
42
|
+
* @returns The value to store.
|
|
43
|
+
*/
|
|
44
|
+
export function parseInput(text, original, source) {
|
|
45
|
+
if (text === '')
|
|
46
|
+
return null;
|
|
47
|
+
if (source === 'csv')
|
|
48
|
+
return text;
|
|
49
|
+
if (typeof original === 'boolean' && /^(true|false)$/i.test(text))
|
|
50
|
+
return /^true$/i.test(text);
|
|
51
|
+
if (typeof original === 'number' || original === null) {
|
|
52
|
+
const t = text.trim().replace(/(\d),(?=\d{3}\b)/g, '$1');
|
|
53
|
+
if (/^[+-]?(\d+\.?\d*|\.\d+)([eE][+-]?\d+)?$/.test(t))
|
|
54
|
+
return Number(t);
|
|
55
|
+
}
|
|
56
|
+
return text;
|
|
57
|
+
}
|
|
58
|
+
/** A sheet's state as read: `state` (W-49), else the older `hidden` flag. */
|
|
59
|
+
export const sheetStateOf = (s) => (s.state ?? (s.hidden ? 'hidden' : 'visible'));
|
|
60
|
+
const same = (a, b) => a === b || (typeof a === 'number' && typeof b === 'number' && Object.is(a, b)) || JSON.stringify(a) === JSON.stringify(b);
|
|
61
|
+
/**
|
|
62
|
+
* The in-memory workbook one pane shares (SR-GRID decisions 1–8): the uploaded file, every
|
|
63
|
+
* change, every error, the selection, the mode and one undo stack. Framework-free and
|
|
64
|
+
* `useSyncExternalStore`-shaped (`subscribe`, `getSnapshot`); every mutation goes through one
|
|
65
|
+
* commit (undo entry, one notification, then `validate` and `derive`). The upload is never
|
|
66
|
+
* modified; {@link WorkbookStore.exportFile} writes a copy with the changes (SR-LAW-6).
|
|
67
|
+
*
|
|
68
|
+
* Elements get one per `pane` id from the registry ({@link paneStore}); a host may make its own
|
|
69
|
+
* with {@link createWorkbook} and assign it to `el.workbook`.
|
|
70
|
+
*
|
|
71
|
+
* @example
|
|
72
|
+
* ```ts
|
|
73
|
+
* import { createWorkbook } from '@sheetpane/grid';
|
|
74
|
+
* const book = createWorkbook();
|
|
75
|
+
* await book.load(bytes, 'accounts.xlsx');
|
|
76
|
+
* book.setMode('edit');
|
|
77
|
+
* book.setCell('Sheet1', 'B3', '42');
|
|
78
|
+
* const off = book.subscribe(() => console.log(book.getSnapshot().changes.size));
|
|
79
|
+
* const { blob, name } = await book.exportFile();
|
|
80
|
+
* off();
|
|
81
|
+
* ```
|
|
82
|
+
* @see https://github.com/arpitarya/sheetpane/blob/main/records/0105_GRID.md
|
|
83
|
+
*/
|
|
84
|
+
export class WorkbookStore {
|
|
85
|
+
/** The pane's plugins, checked and in `decorate` order; fixed for the store's life. */
|
|
86
|
+
plugins;
|
|
87
|
+
snap;
|
|
88
|
+
listeners = new Set();
|
|
89
|
+
eventListeners = new Set();
|
|
90
|
+
undoStack = [];
|
|
91
|
+
redoStack = [];
|
|
92
|
+
nextError = 1;
|
|
93
|
+
loadSeq = 0;
|
|
94
|
+
textCache = new Map();
|
|
95
|
+
/** Plugin errors by `plugin|sheet|range|type` → error id. */
|
|
96
|
+
pluginErrors = new Map();
|
|
97
|
+
errorsViews = 0;
|
|
98
|
+
/** The host's column rules (W-27): a built-in `validate` provider, never part of the frozen plugin set. */
|
|
99
|
+
rules = new HostRules({
|
|
100
|
+
bookSheets: () => this.snap.book?.sheets,
|
|
101
|
+
date1904: () => this.snap.book?.date1904 ?? false,
|
|
102
|
+
value: (sheet, ref) => this.value(sheet, ref),
|
|
103
|
+
valueAt: (sheet, row, col) => (this.snap.changes.size || this.snap.derived.size ? this.value(sheet, cellRef(row, col)) : (this.sheet(sheet)?.rows[row]?.[col] ?? null)),
|
|
104
|
+
text: (sheet, ref) => this.text(sheet, ref),
|
|
105
|
+
original: (sheet, ref) => this.original(sheet, ref),
|
|
106
|
+
revalidate: (sheets) => this.runValidate(sheets, undefined),
|
|
107
|
+
prune: (id, produced) => this.prune(id, produced),
|
|
108
|
+
progress: () => this.set({}),
|
|
109
|
+
});
|
|
110
|
+
/** An empty store; prefer {@link createWorkbook}. Throws when the plugin set breaks a rule. */
|
|
111
|
+
constructor(o = {}) {
|
|
112
|
+
this.plugins = checkPlugins(o.plugins ?? []);
|
|
113
|
+
this.snap = {
|
|
114
|
+
load: { status: 'empty' }, source: undefined, book: undefined,
|
|
115
|
+
changes: new Map(), derived: new Map(), errors: [],
|
|
116
|
+
selection: { sheet: '', ref: 'A1' }, mode: 'view', composing: null,
|
|
117
|
+
canUndo: false, canRedo: false, version: 0,
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
// ── subscription (useSyncExternalStore-shaped) ───────────────────────────
|
|
121
|
+
/** Calls `fn` after every change of state; returns the unsubscribe function. */
|
|
122
|
+
subscribe = (fn) => { this.listeners.add(fn); return () => this.listeners.delete(fn); };
|
|
123
|
+
/** The current immutable snapshot — a new object per change, with a `version`. */
|
|
124
|
+
getSnapshot = () => this.snap;
|
|
125
|
+
/** Store events, for the elements to turn into DOM events. */
|
|
126
|
+
on(fn) { this.eventListeners.add(fn); return () => this.eventListeners.delete(fn); }
|
|
127
|
+
set(patch, events = []) {
|
|
128
|
+
this.snap = { ...this.snap, ...patch, canUndo: this.undoStack.length > 0, canRedo: this.redoStack.length > 0, version: this.snap.version + 1 };
|
|
129
|
+
for (const fn of [...this.listeners])
|
|
130
|
+
fn();
|
|
131
|
+
for (const e of events)
|
|
132
|
+
for (const fn of [...this.eventListeners])
|
|
133
|
+
fn(e);
|
|
134
|
+
}
|
|
135
|
+
/** The ids of the pane's plugins, in order. */
|
|
136
|
+
get pluginIds() { return this.plugins.map((p) => p.id); }
|
|
137
|
+
/** `<sheetpane-errors>` elements connected to this pane: the store decides which form opens (contract decision 4). */
|
|
138
|
+
attachErrorsView() { this.errorsViews++; this.set({}); return () => { this.errorsViews--; this.set({}); }; }
|
|
139
|
+
/** Whether a `<sheetpane-errors>` is connected to this pane. */
|
|
140
|
+
get hasErrorsView() { return this.errorsViews > 0; }
|
|
141
|
+
// ── loading ──────────────────────────────────────────────────────────────
|
|
142
|
+
/** Reads a file into the pane. Changes, errors and history start empty; the host restores a review with setErrors(). */
|
|
143
|
+
async load(data, name, origin) {
|
|
144
|
+
const seq = ++this.loadSeq;
|
|
145
|
+
this.set({ load: { status: 'loading', name } });
|
|
146
|
+
const bytes = data instanceof Uint8Array ? data : data instanceof ArrayBuffer ? new Uint8Array(data) : new Uint8Array(await data.arrayBuffer());
|
|
147
|
+
const t0 = performance.now();
|
|
148
|
+
let book;
|
|
149
|
+
try {
|
|
150
|
+
book = await read(bytes, { name });
|
|
151
|
+
}
|
|
152
|
+
catch (e) {
|
|
153
|
+
if (seq !== this.loadSeq)
|
|
154
|
+
return;
|
|
155
|
+
const err = e instanceof ImportError ? e : new ImportError('corrupt', detailOf(e));
|
|
156
|
+
const load = { status: 'error', code: err.code, spCode: err.spCode, message: err.spDetail, name };
|
|
157
|
+
if (err.detail)
|
|
158
|
+
load.detail = err.detail;
|
|
159
|
+
this.set({ load, book: undefined, source: undefined }, [{ type: 'load', origin }]);
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
if (seq !== this.loadSeq)
|
|
163
|
+
return;
|
|
164
|
+
const source = { name, kind: book.source, size: bytes.length, bytes, readMs: Math.round(performance.now() - t0) };
|
|
165
|
+
this.undoStack = [];
|
|
166
|
+
this.redoStack = [];
|
|
167
|
+
this.textCache.clear();
|
|
168
|
+
this.pluginErrors.clear();
|
|
169
|
+
const first = book.sheets[book.activeSheet] ?? book.sheets[0];
|
|
170
|
+
this.set({ load: { status: 'ready' }, source, book, changes: new Map(), derived: new Map(), errors: [], composing: null, selection: { sheet: first.name, ref: 'A1' } }, [{ type: 'load', origin }]);
|
|
171
|
+
this.rules.reset(); // column rules: the `load` phase (time-sliced; it re-runs validate when done)
|
|
172
|
+
this.runValidate(book.sheets.map((s) => s.name), origin);
|
|
173
|
+
void this.runDerive({ kind: 'load' });
|
|
174
|
+
}
|
|
175
|
+
/** A source that could not even be fetched (a bad URL): the pane shows the refusal state. */
|
|
176
|
+
failLoad(name, message, origin, spCode = 'SP-3015') {
|
|
177
|
+
this.loadSeq++;
|
|
178
|
+
this.set({ load: { status: 'error', code: 'unreadable', spCode, message, name }, book: undefined, source: undefined }, [{ type: 'load', origin }]);
|
|
179
|
+
}
|
|
180
|
+
// ── reading ──────────────────────────────────────────────────────────────
|
|
181
|
+
/** The workbook as read, or undefined before a load. */
|
|
182
|
+
get book() { return this.snap.book; }
|
|
183
|
+
/** A sheet of the workbook by name. */
|
|
184
|
+
sheet(name) { return this.snap.book?.sheets.find((s) => s.name === name); }
|
|
185
|
+
/** The uploaded value and display text. */
|
|
186
|
+
original(sheet, ref) {
|
|
187
|
+
const s = this.sheet(sheet), p = parseRef(ref);
|
|
188
|
+
if (!s || !p)
|
|
189
|
+
return { value: null, text: '' };
|
|
190
|
+
return { value: s.rows[p.row]?.[p.col] ?? null, text: s.text[p.row]?.[p.col] ?? '' };
|
|
191
|
+
}
|
|
192
|
+
/** A cell's change of one kind (default: its value). */
|
|
193
|
+
changeOf(sheet, ref, kind) { return this.snap.changes.get(key(sheet, kind ? kindRef(ref, kind) : ref)); }
|
|
194
|
+
/** Every change a cell carries — its value and each slotted kind. */
|
|
195
|
+
changesAt(sheet, ref) {
|
|
196
|
+
const out = [];
|
|
197
|
+
for (const r of [ref, ...SLOTTED_KINDS.map((k) => kindRef(ref, k))]) {
|
|
198
|
+
const c = this.snap.changes.get(key(sheet, r));
|
|
199
|
+
if (c)
|
|
200
|
+
out.push(c);
|
|
201
|
+
}
|
|
202
|
+
return out;
|
|
203
|
+
}
|
|
204
|
+
/** The cell's formula with its `=`: a typed formula, else the file's (unless a value edit replaced it); undefined when none. */
|
|
205
|
+
formula(sheet, ref) {
|
|
206
|
+
const c = this.snap.changes.get(key(sheet, ref));
|
|
207
|
+
if (c)
|
|
208
|
+
return typeof c.now === 'string' && c.now.startsWith('=') ? c.now : undefined;
|
|
209
|
+
const f = this.sheet(sheet)?.formulas[ref];
|
|
210
|
+
return f === undefined ? undefined : `=${f}`;
|
|
211
|
+
}
|
|
212
|
+
/** What the cell bar edits: the formula when a `derive` provider (a formula engine) is installed, else the raw value. */
|
|
213
|
+
input(sheet, ref) {
|
|
214
|
+
const f = this.plugins.some((x) => x.provides.includes('derive')) ? this.formula(sheet, ref) : undefined;
|
|
215
|
+
if (f !== undefined)
|
|
216
|
+
return f;
|
|
217
|
+
const v = this.value(sheet, ref);
|
|
218
|
+
return v === null || v === undefined ? '' : typeof v === 'boolean' ? (v ? 'TRUE' : 'FALSE') : String(v);
|
|
219
|
+
}
|
|
220
|
+
/** The current value: a user change, else a derived value, else the upload. */
|
|
221
|
+
value(sheet, ref) {
|
|
222
|
+
const k = key(sheet, ref), c = this.snap.changes.get(k);
|
|
223
|
+
const d = this.snap.derived.get(k);
|
|
224
|
+
if (c && c.kind === 'value')
|
|
225
|
+
return c.now;
|
|
226
|
+
if (c && c.kind === 'formula')
|
|
227
|
+
return d ? d.value : c.now; // a typed formula reads as its result once derived
|
|
228
|
+
return d ? d.value : this.original(sheet, ref).value;
|
|
229
|
+
}
|
|
230
|
+
/** The current display text. */
|
|
231
|
+
text(sheet, ref) {
|
|
232
|
+
const k = key(sheet, ref);
|
|
233
|
+
const hit = this.textCache.get(k);
|
|
234
|
+
if (hit !== undefined)
|
|
235
|
+
return hit;
|
|
236
|
+
const c = this.snap.changes.get(k);
|
|
237
|
+
let t;
|
|
238
|
+
if (c && c.kind === 'value')
|
|
239
|
+
t = this.format(sheet, ref, c.now);
|
|
240
|
+
else if (c && c.kind === 'formula') {
|
|
241
|
+
const d = this.snap.derived.get(k);
|
|
242
|
+
t = d ? d.text : this.format(sheet, ref, c.now);
|
|
243
|
+
}
|
|
244
|
+
else {
|
|
245
|
+
const d = this.snap.derived.get(k);
|
|
246
|
+
if (d)
|
|
247
|
+
t = d.text;
|
|
248
|
+
else {
|
|
249
|
+
const o = this.original(sheet, ref);
|
|
250
|
+
t = typeof o.value === 'number' && this.formatter ? this.format(sheet, ref, o.value) : o.text;
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
this.textCache.set(k, t);
|
|
254
|
+
return t;
|
|
255
|
+
}
|
|
256
|
+
get formatter() { return this.plugins.find((p) => p.provides.includes('format')); }
|
|
257
|
+
/** Formats a value for one cell: the `format` plugin, else the core number-format subset. */
|
|
258
|
+
format(sheet, ref, v) {
|
|
259
|
+
if (v === null)
|
|
260
|
+
return '';
|
|
261
|
+
if (typeof v === 'boolean')
|
|
262
|
+
return v ? 'TRUE' : 'FALSE';
|
|
263
|
+
if (typeof v === 'string')
|
|
264
|
+
return v;
|
|
265
|
+
const f = this.formatter;
|
|
266
|
+
if (f?.format) {
|
|
267
|
+
const ctx = this.cellContext(sheet, ref, v);
|
|
268
|
+
const out = ctx && f.format(ctx);
|
|
269
|
+
if (out !== undefined)
|
|
270
|
+
return out;
|
|
271
|
+
}
|
|
272
|
+
const book = this.snap.book, s = this.sheet(sheet);
|
|
273
|
+
const si = s?.styleIndex[ref];
|
|
274
|
+
const xf = book && si !== undefined ? book.styles.cellXfs[si] : undefined;
|
|
275
|
+
const code = xf ? subsetCode(xf.numFmtId, book.styles.numFmts) : 'General';
|
|
276
|
+
return code === undefined || code === 'General' ? general(v) : formatCode(v, code, book?.date1904 ?? false);
|
|
277
|
+
}
|
|
278
|
+
/** One cell as a plugin hook sees it; undefined when the sheet is unknown. */
|
|
279
|
+
cellContext(sheet, ref, value) {
|
|
280
|
+
const book = this.snap.book, s = this.sheet(sheet), p = parseRef(ref);
|
|
281
|
+
if (!book || !s || !p)
|
|
282
|
+
return undefined;
|
|
283
|
+
return { store: this, book, sheet: s, ref, row: p.row, col: p.col, value: value === undefined ? this.value(sheet, ref) : value, styleIndex: s.styleIndex[ref] };
|
|
284
|
+
}
|
|
285
|
+
/** The merged `decorate` result for a cell; `{}` with no decorate plugin. */
|
|
286
|
+
decorate(sheet, ref) {
|
|
287
|
+
const deco = this.plugins.filter((p) => p.provides.includes('decorate'));
|
|
288
|
+
if (!deco.length)
|
|
289
|
+
return {};
|
|
290
|
+
const ctx = this.cellContext(sheet, ref);
|
|
291
|
+
if (!ctx)
|
|
292
|
+
return {};
|
|
293
|
+
return mergeDecorations(deco.map((p) => ({ id: p.id, style: p.decorate(ctx) })));
|
|
294
|
+
}
|
|
295
|
+
/** What a plugin hook is given: the store, the book and a snapshot reader. */
|
|
296
|
+
pluginContext() {
|
|
297
|
+
return { store: this, book: this.snap.book, snapshot: () => this.snap };
|
|
298
|
+
}
|
|
299
|
+
/** How many cells a user changed (derived cells are not changes). */
|
|
300
|
+
get changeCount() { return this.snap.changes.size; }
|
|
301
|
+
// ── the one commit path ──────────────────────────────────────────────────
|
|
302
|
+
commit(next, keys, origin, events, o = {}) {
|
|
303
|
+
const before = { changes: this.snap.changes, errors: this.snap.errors };
|
|
304
|
+
for (const k of keys) {
|
|
305
|
+
this.textCache.delete(k);
|
|
306
|
+
const { sheet, ref } = splitKey(k);
|
|
307
|
+
this.textCache.delete(key(sheet, baseRef(ref)));
|
|
308
|
+
}
|
|
309
|
+
const errors = this.withMatches(next.errors, next.changes);
|
|
310
|
+
if (o.record !== false) {
|
|
311
|
+
const last = this.undoStack[this.undoStack.length - 1];
|
|
312
|
+
if (o.coalesce && last && last.coalesce === o.coalesce) {
|
|
313
|
+
last.after = { changes: next.changes, errors };
|
|
314
|
+
}
|
|
315
|
+
else {
|
|
316
|
+
this.undoStack.push({ before, after: { changes: next.changes, errors }, keys, coalesce: o.coalesce });
|
|
317
|
+
if (this.undoStack.length > HISTORY_CAP)
|
|
318
|
+
this.undoStack.shift();
|
|
319
|
+
}
|
|
320
|
+
this.redoStack = [];
|
|
321
|
+
}
|
|
322
|
+
this.set({ ...o.extra, changes: next.changes, errors }, events);
|
|
323
|
+
const sheets = [...new Set(keys.map((k) => splitKey(k).sheet))];
|
|
324
|
+
if (sheets.length) {
|
|
325
|
+
this.rules.touch(keys.map(splitKey).filter((c) => !c.ref.startsWith('#')));
|
|
326
|
+
this.runValidate(this.allSheets(), origin);
|
|
327
|
+
const cells = keys.map(splitKey).filter((c) => !c.ref.startsWith('#')).map((c) => ({ ...c, value: this.value(c.sheet, c.ref) }));
|
|
328
|
+
if (cells.length)
|
|
329
|
+
void this.runDerive({ kind: 'change', cells });
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
/** Ends typing in one cell: the next keystroke there starts a new undo entry. */
|
|
333
|
+
endCoalesce() {
|
|
334
|
+
const last = this.undoStack[this.undoStack.length - 1];
|
|
335
|
+
if (last)
|
|
336
|
+
last.coalesce = undefined;
|
|
337
|
+
}
|
|
338
|
+
// ── cell changes ─────────────────────────────────────────────────────────
|
|
339
|
+
/**
|
|
340
|
+
* Sets a cell. Text is parsed as the cell would store it (a number stays a number).
|
|
341
|
+
* Typing the uploaded value back removes the change. Returns false when nothing changed.
|
|
342
|
+
*/
|
|
343
|
+
setCell(sheet, ref, input, o = {}) {
|
|
344
|
+
const step = this.cellStep(this.snap.changes, sheet, ref, input, o);
|
|
345
|
+
if (!step)
|
|
346
|
+
return false;
|
|
347
|
+
const commitOpts = {};
|
|
348
|
+
if (o.coalesce)
|
|
349
|
+
commitOpts.coalesce = `type:${step.key}`;
|
|
350
|
+
this.commit({ changes: step.changes, errors: this.snap.errors }, [step.key], o.origin, [step.event], commitOpts);
|
|
351
|
+
return true;
|
|
352
|
+
}
|
|
353
|
+
/** Sets many cells as one undo step (paste). Returns how many changed. */
|
|
354
|
+
setCells(cells, o = {}) {
|
|
355
|
+
let changes = this.snap.changes;
|
|
356
|
+
const keys = [], events = [];
|
|
357
|
+
for (const c of cells) {
|
|
358
|
+
const step = this.cellStep(changes, c.sheet, c.ref, c.input, o);
|
|
359
|
+
if (!step)
|
|
360
|
+
continue;
|
|
361
|
+
changes = step.changes;
|
|
362
|
+
keys.push(step.key);
|
|
363
|
+
events.push(step.event);
|
|
364
|
+
}
|
|
365
|
+
if (keys.length)
|
|
366
|
+
this.commit({ changes, errors: this.snap.errors }, keys, o.origin, events);
|
|
367
|
+
return keys.length;
|
|
368
|
+
}
|
|
369
|
+
/** What a cell write would do to the change map, or undefined when nothing changes. */
|
|
370
|
+
cellStep(base, sheet, ref, input, o) {
|
|
371
|
+
const book = this.snap.book;
|
|
372
|
+
const p = parseRef(ref);
|
|
373
|
+
if (!book || !p || !this.sheet(sheet))
|
|
374
|
+
return undefined;
|
|
375
|
+
const a1 = cellRef(p.row, p.col);
|
|
376
|
+
const orig = this.original(sheet, a1);
|
|
377
|
+
const now = typeof input === 'string' && o.parse !== false ? parseInput(input, orig.value, book.source) : input;
|
|
378
|
+
const k = key(sheet, a1);
|
|
379
|
+
const prev = base.get(k);
|
|
380
|
+
const fileFormula = this.sheet(sheet)?.formulas[a1];
|
|
381
|
+
const backToOriginal = fileFormula !== undefined
|
|
382
|
+
? typeof input === 'string' && input.trim() === `=${fileFormula}` // retyping the file's formula restores the cell
|
|
383
|
+
: same(now, orig.value) || (typeof input === 'string' && input === orig.text && orig.text !== '');
|
|
384
|
+
if (!prev && backToOriginal)
|
|
385
|
+
return undefined;
|
|
386
|
+
if (prev && same(prev.now, now))
|
|
387
|
+
return undefined;
|
|
388
|
+
const changes = new Map(base);
|
|
389
|
+
if (backToOriginal)
|
|
390
|
+
changes.delete(k);
|
|
391
|
+
else
|
|
392
|
+
changes.set(k, { kind: typeof now === 'string' && now.startsWith('=') && this.plugins.some((x) => x.provides.includes('derive')) ? 'formula' : 'value', was: orig.value, now });
|
|
393
|
+
return { key: k, changes, event: { type: 'change', origin: o.origin, sheet, ref: a1, kind: 'value', was: prev ? prev.now : orig.value, now: backToOriginal ? orig.value : now } };
|
|
394
|
+
}
|
|
395
|
+
/** Sets any change-map entry a plugin owns (`style`, `numFmt`, `merge`, `freeze`). `now === undefined` removes it. */
|
|
396
|
+
setChange(sheet, ref, kind, now, was, origin) {
|
|
397
|
+
const slot = kindRef(ref, kind);
|
|
398
|
+
ref = baseRef(slot);
|
|
399
|
+
const k = key(sheet, slot);
|
|
400
|
+
const changes = new Map(this.snap.changes);
|
|
401
|
+
const prev = changes.get(k);
|
|
402
|
+
if (now === undefined || same(now, was))
|
|
403
|
+
changes.delete(k);
|
|
404
|
+
else
|
|
405
|
+
changes.set(k, { kind, was: prev ? prev.was : was, now });
|
|
406
|
+
this.commit({ changes, errors: this.snap.errors }, [k], origin, [{ type: 'change', origin, sheet, ref, kind, was: prev ? prev.now : was, now: now ?? was }]);
|
|
407
|
+
}
|
|
408
|
+
/** Many plugin-kind changes as one undo step (a format applied to a range). `now === undefined` removes. */
|
|
409
|
+
setChanges(list, origin) {
|
|
410
|
+
const changes = new Map(this.snap.changes);
|
|
411
|
+
const keys = [], events = [];
|
|
412
|
+
for (const c of list) {
|
|
413
|
+
const k = key(c.sheet, kindRef(c.ref, c.kind)), prev = changes.get(k);
|
|
414
|
+
if (c.now === undefined || same(c.now, c.was)) {
|
|
415
|
+
if (!prev)
|
|
416
|
+
continue;
|
|
417
|
+
changes.delete(k);
|
|
418
|
+
}
|
|
419
|
+
else {
|
|
420
|
+
if (prev && same(prev.now, c.now))
|
|
421
|
+
continue;
|
|
422
|
+
changes.set(k, { kind: c.kind, was: prev ? prev.was : c.was, now: c.now });
|
|
423
|
+
}
|
|
424
|
+
keys.push(k);
|
|
425
|
+
events.push({ type: 'change', origin, sheet: c.sheet, ref: baseRef(c.ref), kind: c.kind, was: prev ? prev.now : c.was, now: c.now ?? c.was });
|
|
426
|
+
}
|
|
427
|
+
if (keys.length)
|
|
428
|
+
this.commit({ changes, errors: this.snap.errors }, keys, origin, events);
|
|
429
|
+
}
|
|
430
|
+
/** Reverts one cell (or any change-map key) to the upload; undoable. */
|
|
431
|
+
revert(sheet, ref, origin) {
|
|
432
|
+
const refs = ref.startsWith('#') ? [ref] : [ref, ...SLOTTED_KINDS.map((k) => kindRef(ref, k))];
|
|
433
|
+
const changes = new Map(this.snap.changes);
|
|
434
|
+
const keys = [], events = [];
|
|
435
|
+
for (const r of refs) {
|
|
436
|
+
const k = key(sheet, r), prev = changes.get(k);
|
|
437
|
+
if (!prev)
|
|
438
|
+
continue;
|
|
439
|
+
changes.delete(k);
|
|
440
|
+
keys.push(k);
|
|
441
|
+
events.push({ type: 'change', origin, sheet, ref: baseRef(r), kind: prev.kind, was: prev.now, now: prev.was });
|
|
442
|
+
}
|
|
443
|
+
if (keys.length)
|
|
444
|
+
this.commit({ changes, errors: this.snap.errors }, keys, origin, events);
|
|
445
|
+
}
|
|
446
|
+
// ── merged ranges: core, changed by plugins as kind `merge` (key `sheet!#merge:A1:C1`, now true | false) ──
|
|
447
|
+
/** The sheet's merged ranges: the file's with `merge` changes applied. */
|
|
448
|
+
merges(sheet) {
|
|
449
|
+
const s = this.sheet(sheet);
|
|
450
|
+
if (!s)
|
|
451
|
+
return [];
|
|
452
|
+
const out = new Set(s.merges ?? []);
|
|
453
|
+
for (const [k, c] of this.snap.changes) {
|
|
454
|
+
if (c.kind !== 'merge' || !k.startsWith(`${sheet}!#merge:`))
|
|
455
|
+
continue;
|
|
456
|
+
const r = k.slice(sheet.length + 8);
|
|
457
|
+
if (c.now)
|
|
458
|
+
out.add(r);
|
|
459
|
+
else
|
|
460
|
+
out.delete(r);
|
|
461
|
+
}
|
|
462
|
+
return [...out];
|
|
463
|
+
}
|
|
464
|
+
/** Merges (`true`) or unmerges (`false`) a range; undoable. */
|
|
465
|
+
setMerge(sheet, range, merged, origin) {
|
|
466
|
+
const was = (this.sheet(sheet)?.merges ?? []).includes(range);
|
|
467
|
+
this.setChange(sheet, `#merge:${range}`, 'merge', merged, was, origin);
|
|
468
|
+
}
|
|
469
|
+
// ── hidden rows, columns and sheets (W-49): the file's, then `hidden` / `sheetState` changes ──
|
|
470
|
+
/**
|
|
471
|
+
* What a sheet hides: rows and columns (0-based, sorted) and its state — the file's, with the
|
|
472
|
+
* `hidden` and `sheetState` changes applied.
|
|
473
|
+
* @param sheet - The sheet's name.
|
|
474
|
+
* @returns The hidden rows and columns and the sheet's state.
|
|
475
|
+
*/
|
|
476
|
+
hidden(sheet) {
|
|
477
|
+
const s = this.sheet(sheet);
|
|
478
|
+
if (!s)
|
|
479
|
+
return { rows: [], cols: [], state: 'visible' };
|
|
480
|
+
const ch = (k) => this.snap.changes.get(key(sheet, k))?.now;
|
|
481
|
+
return { rows: ch('#hiddenRows') ?? s.hiddenRows ?? [], cols: ch('#hiddenCols') ?? s.hiddenCols ?? [], state: ch('#sheetState') ?? sheetStateOf(s) };
|
|
482
|
+
}
|
|
483
|
+
/**
|
|
484
|
+
* Hides or unhides rows or columns: one undoable change per sheet and axis, written to the download.
|
|
485
|
+
* @param sheet - The sheet's name.
|
|
486
|
+
* @param axis - `rows` or `cols`.
|
|
487
|
+
* @param indexes - The 0-based rows or columns.
|
|
488
|
+
* @param hide - True hides, false unhides.
|
|
489
|
+
* @param origin - The element that asked.
|
|
490
|
+
*/
|
|
491
|
+
setHidden(sheet, axis, indexes, hide, origin) {
|
|
492
|
+
const s = this.sheet(sheet);
|
|
493
|
+
if (!s)
|
|
494
|
+
return;
|
|
495
|
+
const cur = new Set(this.hidden(sheet)[axis]);
|
|
496
|
+
for (const i of indexes)
|
|
497
|
+
if (hide)
|
|
498
|
+
cur.add(i);
|
|
499
|
+
else
|
|
500
|
+
cur.delete(i);
|
|
501
|
+
const file = [...((axis === 'rows' ? s.hiddenRows : s.hiddenCols) ?? [])].sort((a, b) => a - b);
|
|
502
|
+
this.setChange(sheet, axis === 'rows' ? '#hiddenRows' : '#hiddenCols', 'hidden', [...cur].sort((a, b) => a - b), file, origin);
|
|
503
|
+
}
|
|
504
|
+
/**
|
|
505
|
+
* Sets a sheet's state; the last visible sheet cannot be hidden (Excel's rule).
|
|
506
|
+
* @param sheet - The sheet's name.
|
|
507
|
+
* @param state - `visible`, `hidden` or `veryHidden`.
|
|
508
|
+
* @param origin - The element that asked.
|
|
509
|
+
* @returns False when refused.
|
|
510
|
+
*/
|
|
511
|
+
setSheetState(sheet, state, origin) {
|
|
512
|
+
const s = this.sheet(sheet), book = this.snap.book;
|
|
513
|
+
if (!s || !book)
|
|
514
|
+
return false;
|
|
515
|
+
if (state !== 'visible' && !book.sheets.some((x) => x.name !== sheet && this.hidden(x.name).state === 'visible'))
|
|
516
|
+
return false;
|
|
517
|
+
this.setChange(sheet, '#sheetState', 'sheetState', state, sheetStateOf(s), origin);
|
|
518
|
+
return true;
|
|
519
|
+
}
|
|
520
|
+
/**
|
|
521
|
+
* A sheet's outline levels (W-49 DoD 9): rows and columns (0-based) to a level 1–7, the file's with
|
|
522
|
+
* the `outline` changes applied, and where the summary sits.
|
|
523
|
+
* @param sheet - The sheet's name.
|
|
524
|
+
* @returns The levels per axis, the deepest level per axis, and `summaryBelow` / `summaryRight`.
|
|
525
|
+
*/
|
|
526
|
+
outline(sheet) {
|
|
527
|
+
const s = this.sheet(sheet);
|
|
528
|
+
const fromFile = (list) => { const m = {}; (list ?? []).forEach((e, i) => { if (e && e.level > 0)
|
|
529
|
+
m[i] = e.level; }); return m; };
|
|
530
|
+
const ch = (k) => this.snap.changes.get(key(sheet, k))?.now;
|
|
531
|
+
const rows = ch('#outlineRows') ?? fromFile(s?.rowOutline), cols = ch('#outlineCols') ?? fromFile(s?.colOutline);
|
|
532
|
+
const deepest = (m) => Object.values(m).reduce((a, b) => Math.max(a, b), 0);
|
|
533
|
+
return { rows, cols, levelRow: deepest(rows), levelCol: deepest(cols), summaryBelow: s?.outline?.summaryBelow ?? true, summaryRight: s?.outline?.summaryRight ?? true };
|
|
534
|
+
}
|
|
535
|
+
/**
|
|
536
|
+
* Groups (+1 level) or ungroups (−1) rows or columns — one undoable change per sheet and axis; seven levels at most.
|
|
537
|
+
* @param sheet - The sheet's name.
|
|
538
|
+
* @param axis - `rows` or `cols`.
|
|
539
|
+
* @param indexes - The 0-based rows or columns.
|
|
540
|
+
* @param delta - `1` groups, `-1` ungroups.
|
|
541
|
+
* @param origin - The element that asked.
|
|
542
|
+
*/
|
|
543
|
+
setOutline(sheet, axis, indexes, delta, origin) {
|
|
544
|
+
if (!this.sheet(sheet))
|
|
545
|
+
return;
|
|
546
|
+
const o = this.outline(sheet), cur = { ...(axis === 'rows' ? o.rows : o.cols) };
|
|
547
|
+
for (const i of indexes) {
|
|
548
|
+
const v = Math.max(0, Math.min(7, (cur[i] ?? 0) + delta));
|
|
549
|
+
if (v)
|
|
550
|
+
cur[i] = v;
|
|
551
|
+
else
|
|
552
|
+
delete cur[i];
|
|
553
|
+
}
|
|
554
|
+
const k = axis === 'rows' ? '#outlineRows' : '#outlineCols';
|
|
555
|
+
const was = this.snap.changes.get(key(sheet, k))?.was ?? (axis === 'rows' ? this.outlineFile(sheet).rows : this.outlineFile(sheet).cols);
|
|
556
|
+
this.setChange(sheet, k, 'outline', cur, was, origin);
|
|
557
|
+
}
|
|
558
|
+
outlineFile(sheet) {
|
|
559
|
+
const s = this.sheet(sheet);
|
|
560
|
+
const m = (list) => { const r = {}; (list ?? []).forEach((e, i) => { if (e && e.level > 0)
|
|
561
|
+
r[i] = e.level; }); return r; };
|
|
562
|
+
return { rows: m(s?.rowOutline), cols: m(s?.colOutline) };
|
|
563
|
+
}
|
|
564
|
+
// ── freeze panes (W-24): view state, a change of kind `freeze` ─────────
|
|
565
|
+
/** A sheet's frozen rows and columns: a `freeze` change, else the file's. */
|
|
566
|
+
freeze(sheet) {
|
|
567
|
+
const c = this.snap.changes.get(key(sheet, '#freeze'));
|
|
568
|
+
return c?.now ?? this.sheet(sheet)?.freeze ?? { rows: 0, cols: 0 };
|
|
569
|
+
}
|
|
570
|
+
/** Freezes rows and columns of a sheet, as an undoable change of kind `freeze` (W-24). */
|
|
571
|
+
setFreeze(sheet, f, origin) {
|
|
572
|
+
const s = this.sheet(sheet);
|
|
573
|
+
if (!s)
|
|
574
|
+
return;
|
|
575
|
+
this.setChange(sheet, '#freeze', 'freeze', { rows: Math.max(0, f.rows), cols: Math.max(0, f.cols) }, s.freeze, origin);
|
|
576
|
+
}
|
|
577
|
+
// ── selection, mode, composing ───────────────────────────────────────────
|
|
578
|
+
/** Moves the pane's shared selection; leaving the composed cell drops the draft error. */
|
|
579
|
+
select(sel, origin) {
|
|
580
|
+
const s = this.snap.selection;
|
|
581
|
+
if (s.sheet === sel.sheet && s.ref === sel.ref && s.range === sel.range)
|
|
582
|
+
return;
|
|
583
|
+
const next = { sheet: sel.sheet, ref: sel.ref };
|
|
584
|
+
if (sel.range)
|
|
585
|
+
next.range = sel.range;
|
|
586
|
+
const drop = !!this.snap.composing && (this.snap.composing.sheet !== sel.sheet || this.snap.composing.ref !== sel.ref);
|
|
587
|
+
this.set(drop ? { selection: next, composing: null } : { selection: next }, [{ type: 'select', origin, sheet: sel.sheet, ref: sel.ref }]);
|
|
588
|
+
}
|
|
589
|
+
/** Switches the pane between `view` and `edit`. */
|
|
590
|
+
setMode(mode, origin) {
|
|
591
|
+
if (this.snap.mode === mode)
|
|
592
|
+
return;
|
|
593
|
+
this.set({ mode, composing: mode === 'view' ? null : this.snap.composing }, [{ type: 'mode', origin, mode }]);
|
|
594
|
+
}
|
|
595
|
+
/** Opens (or, with `null`, closes) the Flag issue form for a cell. */
|
|
596
|
+
compose(c) { this.set({ composing: c }); }
|
|
597
|
+
// ── errors ───────────────────────────────────────────────────────────────
|
|
598
|
+
newId() { return `ERR-${String(this.nextError++).padStart(4, '0')}`; }
|
|
599
|
+
/** Recomputes `matchesExpected` (B-022: it marks, never resolves). */
|
|
600
|
+
withMatches(errors, changes) {
|
|
601
|
+
return errors.map((e) => {
|
|
602
|
+
const r = parseRange(e.range);
|
|
603
|
+
let m = false;
|
|
604
|
+
if (r && r.top === r.bottom && r.left === r.right && e.expected.trim() !== '') {
|
|
605
|
+
const ref = cellRef(r.top, r.left);
|
|
606
|
+
const c = changes.get(key(e.sheet, ref));
|
|
607
|
+
const t = c && (c.kind === 'value' || c.kind === 'formula') ? this.format(e.sheet, ref, c.now) : this.text(e.sheet, ref);
|
|
608
|
+
m = t.trim() === e.expected.trim();
|
|
609
|
+
}
|
|
610
|
+
return m === e.matchesExpected ? e : { ...e, matchesExpected: m };
|
|
611
|
+
});
|
|
612
|
+
}
|
|
613
|
+
/** The uploaded display text of a range's first cell, as `found` freezes it. */
|
|
614
|
+
foundFor(sheet, range) {
|
|
615
|
+
const r = parseRange(range);
|
|
616
|
+
if (!r)
|
|
617
|
+
return BLANK;
|
|
618
|
+
if (r.top === r.bottom && r.left !== r.right)
|
|
619
|
+
return `row ${r.top + 1}`;
|
|
620
|
+
return this.original(sheet, cellRef(r.top, r.left)).text || BLANK;
|
|
621
|
+
}
|
|
622
|
+
/** A saved error whose cell no longer holds `found` (contract decision 8). */
|
|
623
|
+
isStale(e) {
|
|
624
|
+
const r = parseRange(e.range);
|
|
625
|
+
if (!r || e.author === 'workbook' || e.author === 'file' || !this.sheet(e.sheet))
|
|
626
|
+
return false;
|
|
627
|
+
if (r.left !== r.right)
|
|
628
|
+
return false;
|
|
629
|
+
return (this.original(e.sheet, cellRef(r.top, r.left)).text || BLANK) !== e.found;
|
|
630
|
+
}
|
|
631
|
+
/** Adds a person's error with a new id; `cellChanged` also writes `expected` into the cell in the same undo step. */
|
|
632
|
+
addError(d, origin) {
|
|
633
|
+
const e = {
|
|
634
|
+
v: 1, id: this.newId(), sheet: d.sheet, range: d.range, type: d.type, severity: d.severity,
|
|
635
|
+
found: this.foundFor(d.sheet, d.range), expected: d.expected ?? '', message: d.message ?? '',
|
|
636
|
+
cellChanged: !!d.cellChanged, status: 'open', matchesExpected: false, author: d.author ?? '', createdAt: new Date().toISOString(),
|
|
637
|
+
};
|
|
638
|
+
let changes = this.snap.changes;
|
|
639
|
+
const keys = [];
|
|
640
|
+
const events = [{ type: 'errors', origin }];
|
|
641
|
+
const r = parseRange(d.range);
|
|
642
|
+
if (d.cellChanged && r && e.expected !== '') {
|
|
643
|
+
const ref = cellRef(r.top, r.left), k = key(d.sheet, ref), orig = this.original(d.sheet, ref);
|
|
644
|
+
const now = parseInput(e.expected, orig.value, this.snap.book?.source ?? 'xlsx');
|
|
645
|
+
const m = new Map(changes);
|
|
646
|
+
if (same(now, orig.value))
|
|
647
|
+
m.delete(k);
|
|
648
|
+
else
|
|
649
|
+
m.set(k, { kind: 'value', was: orig.value, now });
|
|
650
|
+
changes = m;
|
|
651
|
+
keys.push(k);
|
|
652
|
+
events.push({ type: 'change', origin, sheet: d.sheet, ref, kind: 'value', was: this.value(d.sheet, ref), now });
|
|
653
|
+
}
|
|
654
|
+
this.commit({ changes, errors: [...this.snap.errors, e] }, keys, origin, events, { extra: { composing: null } });
|
|
655
|
+
return this.snap.errors.find((x) => x.id === e.id);
|
|
656
|
+
}
|
|
657
|
+
/** Changes an error's status, severity, expected value, message or type; undoable. */
|
|
658
|
+
updateError(id, patch, origin) {
|
|
659
|
+
if (!this.snap.errors.some((e) => e.id === id))
|
|
660
|
+
return;
|
|
661
|
+
this.commit({ changes: this.snap.changes, errors: this.snap.errors.map((e) => (e.id === id ? { ...e, ...patch } : e)) }, [], origin, [{ type: 'errors', origin }]);
|
|
662
|
+
}
|
|
663
|
+
/** Deletes an error; a cell it changed keeps the change (contract decision 6). */
|
|
664
|
+
deleteError(id, origin) {
|
|
665
|
+
if (!this.snap.errors.some((e) => e.id === id))
|
|
666
|
+
return;
|
|
667
|
+
this.commit({ changes: this.snap.changes, errors: this.snap.errors.filter((e) => e.id !== id) }, [], origin, [{ type: 'errors', origin }]);
|
|
668
|
+
}
|
|
669
|
+
/** A copy of every error in the pane. */
|
|
670
|
+
getErrors() { return this.snap.errors.map((e) => ({ ...e })); }
|
|
671
|
+
/** The host restores a saved review. Not a user action: the undo stack is cleared (W-23). */
|
|
672
|
+
setErrors(list, origin) {
|
|
673
|
+
const errors = list.map((e) => ({ ...e, v: 1, matchesExpected: false }));
|
|
674
|
+
for (const e of errors) {
|
|
675
|
+
const n = /^ERR-(\d+)$/.exec(e.id);
|
|
676
|
+
if (n)
|
|
677
|
+
this.nextError = Math.max(this.nextError, Number(n[1]) + 1);
|
|
678
|
+
}
|
|
679
|
+
this.undoStack = [];
|
|
680
|
+
this.redoStack = [];
|
|
681
|
+
this.pluginErrors.clear(); // ids now name the restored list's errors; validate re-adopts its own by what they say, never by id
|
|
682
|
+
this.set({ errors: this.withMatches(errors, this.snap.changes) }, [{ type: 'errors', origin }, { type: 'history', origin, reason: 'cleared' }]);
|
|
683
|
+
this.runValidate(this.allSheets(), origin);
|
|
684
|
+
}
|
|
685
|
+
// ── undo / redo (W-23): one stack per pane ──────────────────────────────
|
|
686
|
+
/** Undoes the pane's last step (changes and errors; never selection or mode). */
|
|
687
|
+
undo(origin) { this.travel(this.undoStack, this.redoStack, 'before', origin, 'undo'); }
|
|
688
|
+
/** Redoes the last undone step. */
|
|
689
|
+
redo(origin) { this.travel(this.redoStack, this.undoStack, 'after', origin, 'redo'); }
|
|
690
|
+
travel(from, to, side, origin, reason) {
|
|
691
|
+
const h = from.pop();
|
|
692
|
+
if (!h)
|
|
693
|
+
return;
|
|
694
|
+
h.coalesce = undefined;
|
|
695
|
+
to.push(h);
|
|
696
|
+
const state = h[side];
|
|
697
|
+
const events = [{ type: 'history', origin, reason }];
|
|
698
|
+
const cur = this.snap.changes;
|
|
699
|
+
for (const k of new Set([...h.keys, ...[...cur.keys()].filter((x) => cur.get(x) !== state.changes.get(x))])) {
|
|
700
|
+
const { sheet, ref } = splitKey(k);
|
|
701
|
+
this.textCache.delete(k);
|
|
702
|
+
this.textCache.delete(key(sheet, baseRef(ref)));
|
|
703
|
+
const a = cur.get(k), b = state.changes.get(k);
|
|
704
|
+
if (a !== b)
|
|
705
|
+
events.push({ type: 'change', origin, sheet, ref, kind: (b ?? a).kind, was: a ? a.now : b.was, now: b ? b.now : a.was });
|
|
706
|
+
}
|
|
707
|
+
if (this.snap.errors !== state.errors)
|
|
708
|
+
events.push({ type: 'errors', origin });
|
|
709
|
+
this.set({ changes: state.changes, errors: this.withMatches(state.errors, state.changes) }, events);
|
|
710
|
+
const sheets = [...new Set(h.keys.map((k) => splitKey(k).sheet))];
|
|
711
|
+
if (sheets.length) {
|
|
712
|
+
this.rules.touch(h.keys.map(splitKey).filter((c) => !c.ref.startsWith('#')));
|
|
713
|
+
this.runValidate(this.allSheets(), origin);
|
|
714
|
+
const cells = h.keys.map(splitKey).filter((c) => !c.ref.startsWith('#')).map((c) => ({ ...c, value: this.value(c.sheet, c.ref) }));
|
|
715
|
+
if (cells.length)
|
|
716
|
+
void this.runDerive({ kind: 'change', cells });
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
// ── plugin hooks: derive, validate ───────────────────────────────────────
|
|
720
|
+
async runDerive(input) {
|
|
721
|
+
const p = this.plugins.find((x) => x.provides.includes('derive'));
|
|
722
|
+
if (!p?.derive || !this.snap.book)
|
|
723
|
+
return;
|
|
724
|
+
const load = this.loadSeq;
|
|
725
|
+
let out;
|
|
726
|
+
try {
|
|
727
|
+
out = await p.derive(input, this.pluginContext());
|
|
728
|
+
}
|
|
729
|
+
catch (e) {
|
|
730
|
+
console.error(spMessage('SP-3012', `sheetpane: plugin "${p.id}" derive failed`), e);
|
|
731
|
+
return;
|
|
732
|
+
}
|
|
733
|
+
if (load !== this.loadSeq)
|
|
734
|
+
return; // a newer workbook replaced this one
|
|
735
|
+
const derived = new Map(input.kind === 'load' ? [] : this.snap.derived);
|
|
736
|
+
for (const d of out) {
|
|
737
|
+
const k = key(d.sheet, d.ref);
|
|
738
|
+
if (d.clear)
|
|
739
|
+
derived.delete(k);
|
|
740
|
+
else
|
|
741
|
+
derived.set(k, { value: d.value, text: d.text });
|
|
742
|
+
this.textCache.delete(k);
|
|
743
|
+
}
|
|
744
|
+
this.set({ derived, errors: this.withMatches(this.snap.errors, this.snap.changes) });
|
|
745
|
+
this.runValidate(this.allSheets(), undefined); // rules may read the recalculated cells
|
|
746
|
+
}
|
|
747
|
+
/** A rule may read another sheet (a list range, an operand), so validation re-runs on every sheet. */
|
|
748
|
+
allSheets() { return this.snap.book?.sheets.map((x) => x.name) ?? []; }
|
|
749
|
+
runValidate(sheets, origin) {
|
|
750
|
+
const vs = this.plugins.filter((p) => p.provides.includes('validate'));
|
|
751
|
+
if (this.rules.active)
|
|
752
|
+
vs.push(this.rules.provider);
|
|
753
|
+
if (!vs.length || !this.snap.book)
|
|
754
|
+
return;
|
|
755
|
+
let errors = [...this.snap.errors];
|
|
756
|
+
let changed = false;
|
|
757
|
+
const at = new Map(errors.map((e, i) => [e.id, i]));
|
|
758
|
+
// restored errors a provider may adopt (setErrors): by what they say, built once
|
|
759
|
+
let adoptable;
|
|
760
|
+
const contentKey = (e) => `${e.sheet}|${e.range}|${e.type}|${e.author}`;
|
|
761
|
+
const adopt = (d) => {
|
|
762
|
+
if (!adoptable) {
|
|
763
|
+
adoptable = new Map();
|
|
764
|
+
const owned = new Set(this.pluginErrors.values());
|
|
765
|
+
errors.forEach((e, i) => { if (!owned.has(e.id) && machineAuthored(e.author)) {
|
|
766
|
+
const k = contentKey(e);
|
|
767
|
+
const l = adoptable.get(k);
|
|
768
|
+
if (l)
|
|
769
|
+
l.push(i);
|
|
770
|
+
else
|
|
771
|
+
adoptable.set(k, [i]);
|
|
772
|
+
} });
|
|
773
|
+
}
|
|
774
|
+
return adoptable.get(contentKey(d))?.shift() ?? -1;
|
|
775
|
+
};
|
|
776
|
+
for (const p of vs) {
|
|
777
|
+
for (const sheet of sheets) {
|
|
778
|
+
let drafts;
|
|
779
|
+
try {
|
|
780
|
+
drafts = p.validate(sheet, this.pluginContext());
|
|
781
|
+
}
|
|
782
|
+
catch (e) {
|
|
783
|
+
console.error(spMessage('SP-3013', `sheetpane: plugin "${p.id}" validate failed`), e);
|
|
784
|
+
continue;
|
|
785
|
+
}
|
|
786
|
+
const seen = new Set();
|
|
787
|
+
for (const d of drafts) {
|
|
788
|
+
const k = `${p.id}|${d.sheet}|${d.range}|${d.type}${d.key ? `|${d.key}` : ''}`;
|
|
789
|
+
seen.add(k);
|
|
790
|
+
const id = this.pluginErrors.get(k);
|
|
791
|
+
const mine = (e) => !!e && e.sheet === d.sheet && e.range === d.range && e.type === d.type && e.author === d.author;
|
|
792
|
+
let i = id !== undefined ? at.get(id) ?? -1 : -1;
|
|
793
|
+
if (i >= 0 && !mine(errors[i]))
|
|
794
|
+
i = -1;
|
|
795
|
+
if (i < 0) { // a restored review may already hold this error: adopt it rather than add a second
|
|
796
|
+
i = adopt(d);
|
|
797
|
+
if (i >= 0)
|
|
798
|
+
this.pluginErrors.set(k, errors[i].id);
|
|
799
|
+
}
|
|
800
|
+
if (i < 0) {
|
|
801
|
+
const e = { v: 1, id: this.newId(), sheet: d.sheet, range: d.range, type: d.type, severity: d.severity, found: d.found, expected: d.expected, message: d.message, cellChanged: !!d.cellChanged, status: d.status ?? 'open', matchesExpected: false, author: d.author, createdAt: new Date().toISOString() }; // `key` stays out of the error
|
|
802
|
+
if (d.code)
|
|
803
|
+
e.code = d.code;
|
|
804
|
+
this.pluginErrors.set(k, e.id);
|
|
805
|
+
at.set(e.id, errors.length);
|
|
806
|
+
errors.push(e);
|
|
807
|
+
changed = true;
|
|
808
|
+
}
|
|
809
|
+
else if (errors[i].author !== 'file' && (errors[i].status === 'resolved' || errors[i].found !== d.found || errors[i].message !== d.message || errors[i].severity !== d.severity || errors[i].code !== d.code)) {
|
|
810
|
+
// a machine-authored error follows the cell: reopened, and its found / message refreshed
|
|
811
|
+
const { code: _was, ...rest } = errors[i];
|
|
812
|
+
errors[i] = { ...rest, status: d.status ?? 'open', message: d.message, expected: d.expected, found: d.found, severity: d.severity, ...(d.code ? { code: d.code } : {}) };
|
|
813
|
+
changed = true;
|
|
814
|
+
}
|
|
815
|
+
}
|
|
816
|
+
// A workbook-authored error whose cell now passes resolves itself — the one auto-resolve (W-18 DoD 3).
|
|
817
|
+
for (const [k, id] of this.pluginErrors) {
|
|
818
|
+
if (!k.startsWith(`${p.id}|${sheet}|`) || seen.has(k))
|
|
819
|
+
continue;
|
|
820
|
+
const i = at.get(id) ?? -1;
|
|
821
|
+
if (i >= 0 && (errors[i].author === 'workbook' || errors[i].author === 'host') && errors[i].status === 'open') {
|
|
822
|
+
errors[i] = { ...errors[i], status: 'resolved' };
|
|
823
|
+
changed = true;
|
|
824
|
+
}
|
|
825
|
+
}
|
|
826
|
+
}
|
|
827
|
+
}
|
|
828
|
+
if (changed)
|
|
829
|
+
this.set({ errors: this.withMatches(errors, this.snap.changes) }, [{ type: 'errors', origin }]);
|
|
830
|
+
}
|
|
831
|
+
// ── column rules (W-27) ──────────────────────────────────────────────────
|
|
832
|
+
/** The host's column rules for this pane. Setting them checks every row again; host errors no longer produced go. */
|
|
833
|
+
setColumnRules(c) { this.rules.configure(c); }
|
|
834
|
+
/** The column rules in force. */
|
|
835
|
+
get columnRules() { return this.rules.config; }
|
|
836
|
+
/** The `load` phase's progress while it runs (rows checked of rows to check). */
|
|
837
|
+
get ruleProgress() { return this.rules.progress; }
|
|
838
|
+
/** Whether a rules file (W-33) is in force for this pane. */
|
|
839
|
+
get hasRulesDocument() { return this.rules.hasDocument; }
|
|
840
|
+
// ── flagging (W-33): `off` leaves the errors panel as the one place errors are read ──
|
|
841
|
+
flaggingOn = true;
|
|
842
|
+
/** Whether Flag issue is on for this pane (SR-GRID decision 22e). */
|
|
843
|
+
get flagging() { return this.flaggingOn; }
|
|
844
|
+
/** Turns Flag issue on or off; off cancels an open form. */
|
|
845
|
+
setFlagging(on) { if (on !== this.flaggingOn) {
|
|
846
|
+
this.flaggingOn = on;
|
|
847
|
+
if (!on && this.snap.composing)
|
|
848
|
+
this.compose(null);
|
|
849
|
+
this.set({});
|
|
850
|
+
} }
|
|
851
|
+
/** The draft as typed, checked without storing anything (`input` phase). */
|
|
852
|
+
checkInput(sheet, ref, input, signal) {
|
|
853
|
+
const now = this.snap.book ? parseInput(input, this.original(sheet, ref).value, this.snap.book.source) : input;
|
|
854
|
+
return this.rules.checkInput(sheet, ref, now, input, signal);
|
|
855
|
+
}
|
|
856
|
+
/** A failing rule set to `block` for this value, if any (the element refuses it). */
|
|
857
|
+
ruleBlocks(sheet, ref, now, text) { return this.rules.blocked(sheet, ref, now, text); }
|
|
858
|
+
/** Removes a provider's errors whose key it no longer produces (rules changed, decision 12). */
|
|
859
|
+
prune(providerId, produced) {
|
|
860
|
+
const drop = new Set();
|
|
861
|
+
for (const [k, id] of this.pluginErrors)
|
|
862
|
+
if (k.startsWith(`${providerId}|`) && !produced.has(k)) {
|
|
863
|
+
drop.add(id);
|
|
864
|
+
this.pluginErrors.delete(k);
|
|
865
|
+
}
|
|
866
|
+
if (drop.size)
|
|
867
|
+
this.set({ errors: this.snap.errors.filter((e) => !drop.has(e.id)) }, [{ type: 'errors', origin: undefined }]);
|
|
868
|
+
}
|
|
869
|
+
// ── export ───────────────────────────────────────────────────────────────
|
|
870
|
+
/** The value changes as `@sheetpane/export` takes them. */
|
|
871
|
+
exportChanges() {
|
|
872
|
+
const out = [];
|
|
873
|
+
for (const [k, c] of this.snap.changes) {
|
|
874
|
+
if (c.kind !== 'value' && c.kind !== 'formula')
|
|
875
|
+
continue;
|
|
876
|
+
const { sheet, ref } = splitKey(k);
|
|
877
|
+
out.push({ sheet, ref, value: c.now });
|
|
878
|
+
}
|
|
879
|
+
return out;
|
|
880
|
+
}
|
|
881
|
+
/** The sheet as it shows now: changes, typed formulas and recalculated cells as their values (a converted download). */
|
|
882
|
+
latestRows(sheet) {
|
|
883
|
+
const rows = this.currentRows(sheet);
|
|
884
|
+
const pre = `${sheet}!`;
|
|
885
|
+
for (const k of new Set([...this.snap.derived.keys(), ...[...this.snap.changes.keys()].filter((x) => !x.slice(pre.length).startsWith('#'))])) {
|
|
886
|
+
if (!k.startsWith(pre))
|
|
887
|
+
continue;
|
|
888
|
+
const ref = k.slice(pre.length), p = parseRef(ref);
|
|
889
|
+
if (!p)
|
|
890
|
+
continue;
|
|
891
|
+
const row = (rows[p.row] ??= []);
|
|
892
|
+
while (row.length < p.col)
|
|
893
|
+
row.push(null);
|
|
894
|
+
row[p.col] = this.value(sheet, ref);
|
|
895
|
+
}
|
|
896
|
+
return Array.from(rows, (r) => Array.from(r ?? [], (v) => v ?? null));
|
|
897
|
+
}
|
|
898
|
+
/** The sheet's rows with every change applied (CSV export, `toXlsx`). */
|
|
899
|
+
currentRows(sheet) {
|
|
900
|
+
const s = this.sheet(sheet);
|
|
901
|
+
if (!s)
|
|
902
|
+
return [];
|
|
903
|
+
const rows = s.rows.map((r) => [...r]);
|
|
904
|
+
for (const c of this.exportChanges()) {
|
|
905
|
+
if (c.sheet !== sheet)
|
|
906
|
+
continue;
|
|
907
|
+
const p = parseRef(c.ref);
|
|
908
|
+
const row = (rows[p.row] ??= []);
|
|
909
|
+
while (row.length < p.col)
|
|
910
|
+
row.push(null);
|
|
911
|
+
row[p.col] = c.value;
|
|
912
|
+
}
|
|
913
|
+
return Array.from(rows, (r) => Array.from(r ?? [], (v) => v ?? null));
|
|
914
|
+
}
|
|
915
|
+
/** Every change applied; the upload never modified (SR-LAW-6). */
|
|
916
|
+
async exportFile(o = {}) {
|
|
917
|
+
const src = this.snap.source, book = this.snap.book;
|
|
918
|
+
if (!src || !book)
|
|
919
|
+
throw spError('SP-3001', 'sheetpane: nothing is loaded to export');
|
|
920
|
+
const base = src.name.replace(/\.(xlsx|csv)$/i, '');
|
|
921
|
+
if (o.format === 'csv' && src.kind === 'xlsx') {
|
|
922
|
+
// CSV holds one sheet and no formatting: the chosen (else the selected) sheet, as it shows now
|
|
923
|
+
const name = o.sheet ?? this.snap.selection.sheet ?? book.sheets[0].name;
|
|
924
|
+
const { text, guarded } = toCsv({ name, rows: this.latestRows(name) });
|
|
925
|
+
return { blob: new Blob([text], { type: 'text/csv;charset=utf-8' }), kind: 'csv', name: `${base}.csv`, guarded, converted: true };
|
|
926
|
+
}
|
|
927
|
+
if (o.format === 'xlsx' && src.kind === 'csv') {
|
|
928
|
+
// one sheet with the latest values; numbers typed as text in the CSV become numbers, as Excel reads them
|
|
929
|
+
const sheet = book.sheets[0];
|
|
930
|
+
const rows = this.latestRows(sheet.name).map((r) => r.map((v) => (typeof v === 'string' && /^-?(?:0|[1-9]\d*)(?:\.\d+)?(?:e[-+]?\d+)?$/i.test(v.trim()) ? Number(v) : v)));
|
|
931
|
+
const bytes = await toXlsx({ sheets: [{ name: sheet.name, rows }] });
|
|
932
|
+
return { blob: new Blob([bytes], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }), kind: 'xlsx', name: `${base}.xlsx`, guarded: [], converted: true };
|
|
933
|
+
}
|
|
934
|
+
if (src.kind === 'csv') {
|
|
935
|
+
const sheet = book.sheets[0];
|
|
936
|
+
const { text, guarded } = toCsv({ name: sheet.name, rows: this.currentRows(sheet.name) }, { delimiter: book.delimiter ?? ',' });
|
|
937
|
+
return { blob: new Blob([text], { type: 'text/csv;charset=utf-8' }), kind: 'csv', name: src.name, guarded, converted: false };
|
|
938
|
+
}
|
|
939
|
+
const ctx = { ...this.pluginContext(), exportOptions: o.options ?? {} };
|
|
940
|
+
const hooks = [...(o.extraPatch ?? []), ...this.plugins.filter((p) => p.provides.includes('exportPatch')).map((p) => (patch) => p.exportPatch(patch, ctx))];
|
|
941
|
+
const freeze = [...this.snap.changes].filter(([k, c]) => c.kind === 'freeze' && k.endsWith('!#freeze')).map(([k, c]) => ({ sheet: splitKey(k).sheet, ...c.now }));
|
|
942
|
+
const mergeSheets = new Set([...this.snap.changes].filter(([, c]) => c.kind === 'merge').map(([k]) => k.slice(0, k.indexOf('!#merge:'))));
|
|
943
|
+
const merges = [...mergeSheets].map((sheet) => ({ sheet, ranges: this.merges(sheet) }));
|
|
944
|
+
// hidden rows, columns and sheet states (W-49): each sheet's whole set after the edits
|
|
945
|
+
const hidSheets = new Set([...this.snap.changes].filter(([, c]) => c.kind === 'hidden' || c.kind === 'sheetState' || c.kind === 'outline').map(([k]) => splitKey(k).sheet));
|
|
946
|
+
const hidden = [...hidSheets].map((sheet) => {
|
|
947
|
+
const h = this.hidden(sheet), o = this.outline(sheet), has = (r) => this.snap.changes.has(key(sheet, r));
|
|
948
|
+
const outline = has('#outlineRows') || has('#outlineCols') ? { outline: { ...(has('#outlineRows') ? { rows: o.rows } : {}), ...(has('#outlineCols') ? { cols: o.cols } : {}) } } : {};
|
|
949
|
+
return { sheet, ...(has('#hiddenRows') ? { rows: h.rows } : {}), ...(has('#hiddenCols') ? { cols: h.cols } : {}), ...(has('#sheetState') ? { state: h.state } : {}), ...outline };
|
|
950
|
+
});
|
|
951
|
+
const bytes = await patchXlsx(src.bytes, this.exportChanges(), { exportPatch: hooks, ...(freeze.length ? { freeze } : {}), ...(merges.length ? { merges } : {}), ...(hidden.length ? { hidden } : {}) });
|
|
952
|
+
return { blob: new Blob([bytes], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }), kind: 'xlsx', name: src.name, guarded: [], converted: false };
|
|
953
|
+
}
|
|
954
|
+
// ── stale formula marks (W-01 DoD 15) ───────────────────────────────────
|
|
955
|
+
refsCache = new WeakMap();
|
|
956
|
+
/** A formula cell that reads a changed cell may show a stale value — unless a `derive` plugin recalculates. */
|
|
957
|
+
mayBeStale(sheet, ref) {
|
|
958
|
+
if (this.plugins.some((p) => p.provides.includes('derive')) || !this.snap.changes.size)
|
|
959
|
+
return false;
|
|
960
|
+
const s = this.sheet(sheet);
|
|
961
|
+
const f = s?.formulas[ref];
|
|
962
|
+
if (!s || f === undefined || this.snap.changes.has(key(sheet, ref)))
|
|
963
|
+
return false;
|
|
964
|
+
let cache = this.refsCache.get(s);
|
|
965
|
+
if (!cache)
|
|
966
|
+
this.refsCache.set(s, (cache = new Map()));
|
|
967
|
+
let refs = cache.get(ref);
|
|
968
|
+
if (!refs)
|
|
969
|
+
cache.set(ref, (refs = formulaRefs(f, sheet)));
|
|
970
|
+
for (const [k, c] of this.snap.changes) {
|
|
971
|
+
if (c.kind !== 'value' && c.kind !== 'formula')
|
|
972
|
+
continue;
|
|
973
|
+
const { sheet: cs, ref: cr } = splitKey(k), p = parseRef(cr);
|
|
974
|
+
const cell = { top: p.row, left: p.col, bottom: p.row, right: p.col };
|
|
975
|
+
if (refs.some((r) => r.sheet === cs && r.range && rangesIntersect(r.range, cell)))
|
|
976
|
+
return true;
|
|
977
|
+
}
|
|
978
|
+
return false;
|
|
979
|
+
}
|
|
980
|
+
}
|
|
981
|
+
/** The ranges a formula reads (A1 refs, ranges, whole columns or rows; sheet-qualified or not). */
|
|
982
|
+
/**
|
|
983
|
+
* The ranges a formula reads (A1 refs, ranges, whole columns or rows; sheet-qualified or not),
|
|
984
|
+
* used for the "may be stale" mark (SR-GRID decision 8).
|
|
985
|
+
* @param f - The formula, without its `=`.
|
|
986
|
+
* @param sheet - The sheet the formula sits on, for unqualified refs.
|
|
987
|
+
* @returns Each referenced range with its sheet.
|
|
988
|
+
*/
|
|
989
|
+
export function formulaRefs(f, sheet) {
|
|
990
|
+
const out = [];
|
|
991
|
+
const src = f.replace(/"(?:[^"]|"")*"/g, '""');
|
|
992
|
+
const re = /(?:(?:'((?:[^']|'')+)'|([A-Za-z_][\w.]*))!)?(\$?[A-Za-z]{1,3}\$?\d+(?::\$?[A-Za-z]{1,3}\$?\d+)?|\$?[A-Za-z]{1,3}:\$?[A-Za-z]{1,3}|\$?\d+:\$?\d+)(?![\w(])/g;
|
|
993
|
+
let m;
|
|
994
|
+
while ((m = re.exec(src))) {
|
|
995
|
+
const sh = m[1] !== undefined ? m[1].replace(/''/g, "'") : m[2] ?? sheet;
|
|
996
|
+
const body = m[3].replace(/\$/g, '');
|
|
997
|
+
let range = parseRange(body);
|
|
998
|
+
if (!range && /^[A-Za-z]+:[A-Za-z]+$/.test(body)) {
|
|
999
|
+
const [a, b] = body.split(':');
|
|
1000
|
+
range = parseRange(`${a}1:${b}1048576`);
|
|
1001
|
+
}
|
|
1002
|
+
if (!range && /^\d+:\d+$/.test(body)) {
|
|
1003
|
+
const [a, b] = body.split(':');
|
|
1004
|
+
range = parseRange(`A${a}:XFD${b}`);
|
|
1005
|
+
}
|
|
1006
|
+
if (range)
|
|
1007
|
+
out.push({ sheet: sh, range });
|
|
1008
|
+
}
|
|
1009
|
+
return out;
|
|
1010
|
+
}
|
|
1011
|
+
/**
|
|
1012
|
+
* Makes a framework-free workbook store (SR-GRID decision 1). Elements make theirs through the
|
|
1013
|
+
* pane registry; a host makes one to share across elements or to drive without any DOM.
|
|
1014
|
+
* @param o - The store's options: its plugins, fixed for its life.
|
|
1015
|
+
* @returns A new, empty {@link WorkbookStore}.
|
|
1016
|
+
* @example
|
|
1017
|
+
* ```ts
|
|
1018
|
+
* import { createWorkbook } from '@sheetpane/grid';
|
|
1019
|
+
* const book = createWorkbook({ plugins: [] });
|
|
1020
|
+
* await book.load(bytes, 'q3.csv');
|
|
1021
|
+
* excel.workbook = book;
|
|
1022
|
+
* errors.workbook = book;
|
|
1023
|
+
* ```
|
|
1024
|
+
*/
|
|
1025
|
+
export function createWorkbook(o = {}) {
|
|
1026
|
+
return new WorkbookStore(o);
|
|
1027
|
+
}
|
|
1028
|
+
//# sourceMappingURL=store.js.map
|