@rowsncolumns/rnc-engine 0.10.132
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.md +100 -0
- package/README.md +83 -0
- package/dist/esm/command-handlers.d.ts +169 -0
- package/dist/esm/command-handlers.d.ts.map +1 -0
- package/dist/esm/command-handlers.js +404 -0
- package/dist/esm/fold.d.ts +140 -0
- package/dist/esm/fold.d.ts.map +1 -0
- package/dist/esm/fold.js +539 -0
- package/dist/esm/index.d.ts +620 -0
- package/dist/esm/index.d.ts.map +1 -0
- package/dist/esm/index.js +425 -0
- package/dist/esm/io.d.ts +119 -0
- package/dist/esm/io.d.ts.map +1 -0
- package/dist/esm/io.js +318 -0
- package/dist/esm/keys.d.ts +20 -0
- package/dist/esm/keys.d.ts.map +1 -0
- package/dist/esm/keys.js +41 -0
- package/dist/esm/move-rescope.d.ts +18 -0
- package/dist/esm/move-rescope.d.ts.map +1 -0
- package/dist/esm/move-rescope.js +38 -0
- package/dist/esm/use-engine-core.d.ts +293 -0
- package/dist/esm/use-engine-core.d.ts.map +1 -0
- package/dist/esm/use-engine-core.js +2279 -0
- package/dist/esm/use-engine-pivot-editor.d.ts +78 -0
- package/dist/esm/use-engine-pivot-editor.d.ts.map +1 -0
- package/dist/esm/use-engine-pivot-editor.js +252 -0
- package/dist/esm/use-spreadsheet-engine.d.ts +7 -0
- package/dist/esm/use-spreadsheet-engine.d.ts.map +1 -0
- package/dist/esm/use-spreadsheet-engine.js +23 -0
- package/dist/esm/use-spreadsheet-ui.d.ts +39 -0
- package/dist/esm/use-spreadsheet-ui.d.ts.map +1 -0
- package/dist/esm/use-spreadsheet-ui.js +78 -0
- package/dist/esm/wasm/rnc_wasm.d.ts +394 -0
- package/dist/esm/wasm/rnc_wasm.js +1455 -0
- package/dist/esm/wasm/rnc_wasm_bg.wasm +0 -0
- package/dist/esm/wasm/rnc_wasm_bg.wasm.d.ts +81 -0
- package/package.json +51 -0
package/dist/esm/fold.js
ADDED
|
@@ -0,0 +1,539 @@
|
|
|
1
|
+
import { cellKey } from "./keys";
|
|
2
|
+
/** An empty render state (seed with your initial sheets). */
|
|
3
|
+
export function emptyRenderState(sheets = []) {
|
|
4
|
+
return {
|
|
5
|
+
sheets,
|
|
6
|
+
sheetData: {},
|
|
7
|
+
cellStyles: new Map(),
|
|
8
|
+
derivedFormats: new Map(),
|
|
9
|
+
notes: new Map(),
|
|
10
|
+
charts: new Map(),
|
|
11
|
+
pivotTables: new Map(),
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
/** Set one cell (1-indexed), creating the sparse row/column slots immutably. */
|
|
15
|
+
export function setCell(prev, sheetId, row, col, mut) {
|
|
16
|
+
const key = String(sheetId);
|
|
17
|
+
const rows = prev[key] ? prev[key].slice() : [];
|
|
18
|
+
const existing = rows[row];
|
|
19
|
+
const values = existing?.values ? existing.values.slice() : [];
|
|
20
|
+
values[col] = mut({ ...(values[col] ?? {}) });
|
|
21
|
+
rows[row] = { ...(existing ?? {}), values };
|
|
22
|
+
return { ...prev, [key]: rows };
|
|
23
|
+
}
|
|
24
|
+
/** Apply one cell change. The engine streams the cell VERBATIM (`ue` input, `ev` computed value incl.
|
|
25
|
+
* `ev.ev` = errorValue, `fv` formatted, …) — JS is view-only and reconstructs nothing. We merge it
|
|
26
|
+
* over the prior cell so the RESULT fields that OTHER channels own — `dataValidationResult` and
|
|
27
|
+
* `conditionalFormattingResultById`, which never ride on the value channel — survive a value edit
|
|
28
|
+
* (the value fields are always present on `ch.cell`, so they're replaced wholesale). `cell: null`
|
|
29
|
+
* clears the value (drop `ue`/`ev`/`fv`), leaving those channel-owned result fields for the
|
|
30
|
+
* validations channel to re-emit if the cleared cell's validity flips. */
|
|
31
|
+
export function applyChange(cell, ch) {
|
|
32
|
+
if (!ch.cell) {
|
|
33
|
+
const next = { ...cell };
|
|
34
|
+
delete next.ue;
|
|
35
|
+
delete next.ev;
|
|
36
|
+
delete next.fv;
|
|
37
|
+
return next;
|
|
38
|
+
}
|
|
39
|
+
const next = { ...cell, ...ch.cell };
|
|
40
|
+
// The engine OMITS cleared optional cell-content fields (it skip-serializes `None`), so a plain merge
|
|
41
|
+
// would keep a stale value when one is removed (`remove-comment`/`remove-citation-from-cell`/
|
|
42
|
+
// `remove-link`). `ch.cell` is the COMPLETE cell, so a field absent from it means it was cleared —
|
|
43
|
+
// drop it. The VALUE fields (`ue`/`ev`/`fv`) are in the list too: a cell can exist WITHOUT a value
|
|
44
|
+
// (an empty pivot-output cell tagged with `pivotId`), and a plain merge would resurrect the previous
|
|
45
|
+
// render's value into it. Pivot cell metadata (`pivotId`/`expandable`/`expanded`/`groupKeys`)
|
|
46
|
+
// follows the same rule so a re-rendered pivot row sheds stale expand chips. (CF/DV RESULT fields
|
|
47
|
+
// are channel-owned and intentionally NOT in this list.)
|
|
48
|
+
for (const k of [
|
|
49
|
+
"ue",
|
|
50
|
+
"ev",
|
|
51
|
+
"fv",
|
|
52
|
+
"citationId",
|
|
53
|
+
"commentThreadId",
|
|
54
|
+
"hyperlink",
|
|
55
|
+
"imageUrl",
|
|
56
|
+
"pivotId",
|
|
57
|
+
"expandable",
|
|
58
|
+
"expanded",
|
|
59
|
+
"groupKeys",
|
|
60
|
+
]) {
|
|
61
|
+
if (!(k in ch.cell))
|
|
62
|
+
delete next[k];
|
|
63
|
+
}
|
|
64
|
+
return next;
|
|
65
|
+
}
|
|
66
|
+
/** Stream the engine's cell change-set into `sheetData`. */
|
|
67
|
+
export function applyChanges(prev, changes) {
|
|
68
|
+
let next = prev;
|
|
69
|
+
for (const ch of changes) {
|
|
70
|
+
next = setCell(next, ch.sheetId, ch.rowIndex, ch.columnIndex, (cell) => applyChange(cell, ch));
|
|
71
|
+
}
|
|
72
|
+
return next;
|
|
73
|
+
}
|
|
74
|
+
/** Fold a style change-set into a consolidated `Map<"sheetId!A1", CellFormat>` (`format: null` =
|
|
75
|
+
* cleared → drop). Used for both own styles (`styleChanges`) and CF-derived styles. */
|
|
76
|
+
export function foldStyles(prev, styles) {
|
|
77
|
+
if (!styles.length)
|
|
78
|
+
return prev;
|
|
79
|
+
const next = new Map(prev);
|
|
80
|
+
for (const s of styles) {
|
|
81
|
+
const key = cellKey(s.sheetId, s.rowIndex, s.columnIndex);
|
|
82
|
+
if (s.format)
|
|
83
|
+
next.set(key, s.format);
|
|
84
|
+
else
|
|
85
|
+
next.delete(key);
|
|
86
|
+
}
|
|
87
|
+
return next;
|
|
88
|
+
}
|
|
89
|
+
/** Fold data-validation results ONTO each cell's `dataValidationResult` (the same place
|
|
90
|
+
* `useSpreadsheetState` keeps it) — `false` = the value violates a rule. No separate invalid set. */
|
|
91
|
+
export function foldDataValidationResults(prev, vals) {
|
|
92
|
+
if (!vals.length)
|
|
93
|
+
return prev;
|
|
94
|
+
let next = prev;
|
|
95
|
+
for (const v of vals) {
|
|
96
|
+
next = setCell(next, v.sheetId, v.rowIndex, v.columnIndex, (cell) => ({
|
|
97
|
+
...cell,
|
|
98
|
+
dataValidationResult: v.valid,
|
|
99
|
+
}));
|
|
100
|
+
}
|
|
101
|
+
return next;
|
|
102
|
+
}
|
|
103
|
+
/** Fold per-cell note deltas (`note: null` = cleared). */
|
|
104
|
+
export function foldNotes(prev, notes) {
|
|
105
|
+
if (!notes.length)
|
|
106
|
+
return prev;
|
|
107
|
+
const next = new Map(prev);
|
|
108
|
+
for (const n of notes) {
|
|
109
|
+
const key = cellKey(n.sheetId, n.rowIndex, n.columnIndex);
|
|
110
|
+
if (n.note == null)
|
|
111
|
+
next.delete(key);
|
|
112
|
+
else
|
|
113
|
+
next.set(key, n.note);
|
|
114
|
+
}
|
|
115
|
+
return next;
|
|
116
|
+
}
|
|
117
|
+
/** Fold id-keyed object spec deltas (charts / pivots; `null` spec = deleted). */
|
|
118
|
+
export function foldObjects(prev, deltas) {
|
|
119
|
+
if (!deltas.length)
|
|
120
|
+
return prev;
|
|
121
|
+
const next = new Map(prev);
|
|
122
|
+
for (const d of deltas) {
|
|
123
|
+
if (d.spec == null)
|
|
124
|
+
next.delete(d.id);
|
|
125
|
+
else
|
|
126
|
+
next.set(d.id, d.spec);
|
|
127
|
+
}
|
|
128
|
+
return next;
|
|
129
|
+
}
|
|
130
|
+
/** Fold row/column metadata deltas into each sheet's `rowMetadata`/`columnMetadata`. */
|
|
131
|
+
export function applyDimensions(prev, rowChanges = [], columnChanges = []) {
|
|
132
|
+
// A commit may omit empty structural channels (or an engine adapter may pass a partial commit), so
|
|
133
|
+
// tolerate undefined rather than crash on `.length` (the default params coerce undefined → []).
|
|
134
|
+
if (!rowChanges.length && !columnChanges.length)
|
|
135
|
+
return prev;
|
|
136
|
+
return prev.map((sheet) => {
|
|
137
|
+
const rows = rowChanges.filter((c) => c.sheetId === sheet.sheetId);
|
|
138
|
+
const cols = columnChanges.filter((c) => c.sheetId === sheet.sheetId);
|
|
139
|
+
if (!rows.length && !cols.length)
|
|
140
|
+
return sheet;
|
|
141
|
+
const next = { ...sheet };
|
|
142
|
+
for (const [changes, key] of [
|
|
143
|
+
[rows, "rowMetadata"],
|
|
144
|
+
[cols, "columnMetadata"],
|
|
145
|
+
]) {
|
|
146
|
+
if (!changes.length)
|
|
147
|
+
continue;
|
|
148
|
+
const meta = [...(next[key] ?? [])];
|
|
149
|
+
for (const change of changes)
|
|
150
|
+
meta[change.index] = change;
|
|
151
|
+
next[key] = meta;
|
|
152
|
+
}
|
|
153
|
+
return next;
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
/** Fold merge-region deltas into each sheet's `merges` (added appended, `removed` dropped by bounds). */
|
|
157
|
+
export function applyMerges(prev, merges = []) {
|
|
158
|
+
if (!merges.length)
|
|
159
|
+
return prev;
|
|
160
|
+
const sameRange = (a, b) => a.startRowIndex === b.startRowIndex &&
|
|
161
|
+
a.endRowIndex === b.endRowIndex &&
|
|
162
|
+
a.startColumnIndex === b.startColumnIndex &&
|
|
163
|
+
a.endColumnIndex === b.endColumnIndex;
|
|
164
|
+
return prev.map((sheet) => {
|
|
165
|
+
const mine = merges.filter((m) => m.sheetId === sheet.sheetId);
|
|
166
|
+
if (!mine.length)
|
|
167
|
+
return sheet;
|
|
168
|
+
let list = [...(sheet.merges ?? [])];
|
|
169
|
+
for (const m of mine) {
|
|
170
|
+
list = list.filter((r) => !sameRange(r, m));
|
|
171
|
+
if (!m.removed) {
|
|
172
|
+
list.push({
|
|
173
|
+
startRowIndex: m.startRowIndex,
|
|
174
|
+
endRowIndex: m.endRowIndex,
|
|
175
|
+
startColumnIndex: m.startColumnIndex,
|
|
176
|
+
endColumnIndex: m.endColumnIndex,
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
return { ...sheet, merges: list };
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
/** Reconcile the render-state sheet list with the engine's AUTHORITATIVE sheet set + order + titles
|
|
184
|
+
* (from {@link RncEngine.sheets}). The engine owns sheet lifecycle — it generates ids for
|
|
185
|
+
* `create-sheet`, rewrites titles on rename, reorders on move, drops on delete — so the tab list is
|
|
186
|
+
* reconciled from it rather than guessed from the command (a `create-sheet` doesn't even carry the
|
|
187
|
+
* generated id). Per-sheet DIMENSION/MERGE metadata is preserved by reusing the existing `Sheet`
|
|
188
|
+
* object for a surviving id (those facets ride the commit channels, folded by {@link foldCommit});
|
|
189
|
+
* only the identity facets (`title`/`index`/`hidden`/`tabColor`) are taken from the engine.
|
|
190
|
+
*
|
|
191
|
+
* Returns the SAME `prev` array reference when nothing changed (every id, order, title, hidden,
|
|
192
|
+
* tabColor matches and each surviving sheet object is reused) — so a pure cell edit never churns the
|
|
193
|
+
* `sheets` prop. An empty `engineSheets` (a store that doesn't expose `sheets()`) leaves `prev` as-is. */
|
|
194
|
+
export function reconcileSheets(prev, engineSheets) {
|
|
195
|
+
if (!engineSheets.length)
|
|
196
|
+
return prev;
|
|
197
|
+
const byId = new Map();
|
|
198
|
+
for (const s of prev)
|
|
199
|
+
byId.set(s.sheetId, s);
|
|
200
|
+
let changed = engineSheets.length !== prev.length;
|
|
201
|
+
const next = engineSheets.map((es, i) => {
|
|
202
|
+
const existing = byId.get(es.sheetId);
|
|
203
|
+
// Identity facets from the engine; everything else (dimensions/merges/counts) from the existing
|
|
204
|
+
// sheet (or sensible defaults for a freshly-created one).
|
|
205
|
+
const merged = {
|
|
206
|
+
...(existing ?? {}),
|
|
207
|
+
sheetId: es.sheetId,
|
|
208
|
+
title: es.title,
|
|
209
|
+
hidden: es.hidden,
|
|
210
|
+
tabColor: es.tabColor,
|
|
211
|
+
showGridLines: es.showGridLines,
|
|
212
|
+
showRowColHeaders: es.showRowColHeaders,
|
|
213
|
+
showZeros: es.showZeros,
|
|
214
|
+
zoomScale: es.zoomScale,
|
|
215
|
+
rightToLeft: es.rightToLeft,
|
|
216
|
+
defaultRowHeight: es.defaultRowHeight,
|
|
217
|
+
};
|
|
218
|
+
// Detect churn: order changed, a new sheet, or an identity/view facet changed on a survivor.
|
|
219
|
+
if (!existing ||
|
|
220
|
+
prev[i]?.sheetId !== es.sheetId ||
|
|
221
|
+
existing.title !== es.title ||
|
|
222
|
+
existing.hidden !== es.hidden ||
|
|
223
|
+
existing.tabColor !== es.tabColor ||
|
|
224
|
+
existing.showGridLines !== es.showGridLines ||
|
|
225
|
+
existing.showRowColHeaders !== es.showRowColHeaders ||
|
|
226
|
+
existing.showZeros !== es.showZeros ||
|
|
227
|
+
existing.zoomScale !== es.zoomScale ||
|
|
228
|
+
existing.rightToLeft !== es.rightToLeft ||
|
|
229
|
+
existing.defaultRowHeight !== es.defaultRowHeight) {
|
|
230
|
+
changed = true;
|
|
231
|
+
return merged;
|
|
232
|
+
}
|
|
233
|
+
// Unchanged identity + same position → reuse the existing object reference (no churn).
|
|
234
|
+
return existing;
|
|
235
|
+
});
|
|
236
|
+
return changed ? next : prev;
|
|
237
|
+
}
|
|
238
|
+
// ─────────────────────────────────────────────────────────────────────────────────────────────
|
|
239
|
+
// WINDOWED render state — for a sheet so large (e.g. 1,000,000 rows) that folding EVERY changed
|
|
240
|
+
// cell into JS would blow up memory. Instead of `foldCommit` (which accumulates all cells forever),
|
|
241
|
+
// a windowed renderer holds ONLY the current viewport's cells + styles, refreshed from the engine's
|
|
242
|
+
// `readWindow` on scroll (and on each commit). The state shape is identical to `EngineRenderState`,
|
|
243
|
+
// so every accessor (`getCellData`/`getEffectiveFormat`/…) works unchanged.
|
|
244
|
+
// ─────────────────────────────────────────────────────────────────────────────────────────────
|
|
245
|
+
/** Reconstruct a renderer-shaped {@link CellData} from a lean {@link WindowCell}. `readWindow` is a
|
|
246
|
+
* RENDER window — it carries what to paint (`formatted` text + `number` + `isFormula`), not the full
|
|
247
|
+
* cell. We surface:
|
|
248
|
+
* • `fv` — the formatted display string (what the grid draws),
|
|
249
|
+
* • `ev` — the effective value as a scalar (`nv` for numbers, else `sv` for text),
|
|
250
|
+
* • `ue` — a best-effort user-entered value reconstructed from the lean fields (no formula source
|
|
251
|
+
* here — literals carry their number/text).
|
|
252
|
+
* This LOSSY reconstruction is the FALLBACK for engines without `readWindowCommit`; the primary path
|
|
253
|
+
* (`RncEngine.readWindow` over `readWindowCommit`) carries each cell VERBATIM (`cell`), so `ue.fv`
|
|
254
|
+
* (formula source), hyperlink, note, and the full `ev` survive. See `windowToSheetData`. */
|
|
255
|
+
export function windowCellToCellData(c) {
|
|
256
|
+
const isNumber = c.number != null;
|
|
257
|
+
const ev = isNumber
|
|
258
|
+
? { nv: c.number }
|
|
259
|
+
: { sv: c.formatted };
|
|
260
|
+
// The user-entered value: a formula cell carries its SOURCE (`c.formula`, e.g. `=SUM(A1:A2)`) so the
|
|
261
|
+
// formula bar / editor shows the formula, not its result; literals carry their number/text.
|
|
262
|
+
const ue = c.formula
|
|
263
|
+
? { fv: c.formula }
|
|
264
|
+
: isNumber
|
|
265
|
+
? { nv: c.number }
|
|
266
|
+
: { sv: c.formatted };
|
|
267
|
+
return { ue, ev, fv: c.formatted };
|
|
268
|
+
}
|
|
269
|
+
/** Replace a sheet's windowed cells with the snapshot's cells (1-indexed, sparse). Everything OUTSIDE
|
|
270
|
+
* the snapshot is evicted — only the window's rows/columns are retained, so JS memory stays bounded
|
|
271
|
+
* to the viewport regardless of sheet size. */
|
|
272
|
+
export function windowToSheetData(prev, snapshot) {
|
|
273
|
+
const key = String(snapshot.sheetId);
|
|
274
|
+
const rows = [];
|
|
275
|
+
for (const c of snapshot.cells) {
|
|
276
|
+
const existing = rows[c.rowIndex];
|
|
277
|
+
const values = existing?.values ?? [];
|
|
278
|
+
// Use the cell VERBATIM when the lossless read carried it (`cell`); else reconstruct from the lean
|
|
279
|
+
// fields (the fallback for engines without `readWindowCommit`).
|
|
280
|
+
values[c.columnIndex] = c.cell ?? windowCellToCellData(c);
|
|
281
|
+
rows[c.rowIndex] = { values };
|
|
282
|
+
}
|
|
283
|
+
// Replace ONLY this sheet's data (drop the previous window); other sheets are untouched.
|
|
284
|
+
return { ...prev, [key]: rows };
|
|
285
|
+
}
|
|
286
|
+
/** Rebuild the consolidated style map for a windowed sheet from the snapshot's `styles`. Entries for
|
|
287
|
+
* the snapshot's sheet are dropped first (eviction), then the window's are set — so a stale style
|
|
288
|
+
* outside the viewport can't linger. Styles for OTHER sheets are preserved. */
|
|
289
|
+
export function windowToStyles(prev, snapshot) {
|
|
290
|
+
const sheetPrefix = `${snapshot.sheetId}!`;
|
|
291
|
+
const next = new Map();
|
|
292
|
+
// Keep other sheets' styles; evict this sheet's prior window.
|
|
293
|
+
for (const [k, v] of prev)
|
|
294
|
+
if (!k.startsWith(sheetPrefix))
|
|
295
|
+
next.set(k, v);
|
|
296
|
+
for (const s of snapshot.styles) {
|
|
297
|
+
if (!s.format)
|
|
298
|
+
continue;
|
|
299
|
+
next.set(cellKey(s.sheetId, s.rowIndex, s.columnIndex), s.format);
|
|
300
|
+
}
|
|
301
|
+
return next;
|
|
302
|
+
}
|
|
303
|
+
/** Fold a {@link WindowSnapshot} into render state — the windowed analog of {@link foldCommit}. Holds
|
|
304
|
+
* ONLY the snapshot's cells + styles for its sheet (evicting the prior window), leaving the sheet
|
|
305
|
+
* lifecycle/dimension metadata (`sheets`) and other sheets untouched. The single entry point a
|
|
306
|
+
* windowed UI calls on scroll and after each commit. */
|
|
307
|
+
export function windowToDerivedFormats(prev, snapshot) {
|
|
308
|
+
const sheetPrefix = `${snapshot.sheetId}!`;
|
|
309
|
+
const next = new Map();
|
|
310
|
+
// Keep other sheets' derived formats; evict this sheet's prior window.
|
|
311
|
+
for (const [k, v] of prev)
|
|
312
|
+
if (!k.startsWith(sheetPrefix))
|
|
313
|
+
next.set(k, v);
|
|
314
|
+
for (const s of snapshot.derivedFormats ?? []) {
|
|
315
|
+
if (!s.format)
|
|
316
|
+
continue;
|
|
317
|
+
next.set(cellKey(s.sheetId, s.rowIndex, s.columnIndex), s.format);
|
|
318
|
+
}
|
|
319
|
+
return next;
|
|
320
|
+
}
|
|
321
|
+
export function foldWindow(prev, snapshot) {
|
|
322
|
+
return foldWindows(prev, [snapshot]);
|
|
323
|
+
}
|
|
324
|
+
/** Serialization equality for the small payload objects a windowed read rebuilds fresh every time
|
|
325
|
+
* (cells, formats). Both sides come from the same construction path, so key order is stable; a
|
|
326
|
+
* false negative only forfeits reuse, never correctness. */
|
|
327
|
+
function sameJson(a, b) {
|
|
328
|
+
return JSON.stringify(a) === JSON.stringify(b);
|
|
329
|
+
}
|
|
330
|
+
/** Content equality for a rebuilt style/derived-format map against the previous one. Entry values
|
|
331
|
+
* are compared by IDENTITY — the fold reuses the previous format object whenever the content
|
|
332
|
+
* matched, so `===` here means "every entry survived reuse" (and other sheets' entries are carried
|
|
333
|
+
* over by reference). */
|
|
334
|
+
function stylesEqual(next, prev) {
|
|
335
|
+
if (next.size !== prev.size)
|
|
336
|
+
return false;
|
|
337
|
+
for (const [k, v] of next)
|
|
338
|
+
if (prev.get(k) !== v)
|
|
339
|
+
return false;
|
|
340
|
+
return true;
|
|
341
|
+
}
|
|
342
|
+
/** Fold SEVERAL window snapshots of the SAME sheet in one pass — the viewport window plus its frozen
|
|
343
|
+
* top/left/corner bands (see {@link frozenBandRanges}). The sheet's prior cells/styles are evicted
|
|
344
|
+
* ONCE, then every snapshot's cells/styles are merged in — folding the snapshots one-by-one through
|
|
345
|
+
* {@link foldWindow} would instead evict each band as the next one lands.
|
|
346
|
+
*
|
|
347
|
+
* IDENTITY-PRESERVING: a windowed read rebuilds every cell/format as a fresh object even when its
|
|
348
|
+
* content is unchanged (the window is re-read after EVERY commit, including remote echoes and
|
|
349
|
+
* edits outside the viewport). Downstream, new identities defeat React memoization and trip the
|
|
350
|
+
* grid's renderer-identity cache invalidation — a full cold re-derive of the viewport on every
|
|
351
|
+
* commit. So the fold reuses the previous state's objects at every level where content matches:
|
|
352
|
+
* cell → row → whole sheet array → style maps → the state object itself. A fold whose window
|
|
353
|
+
* content is identical to the previous state returns `prev` unchanged, which lets React bail out
|
|
354
|
+
* of the setState entirely. */
|
|
355
|
+
export function foldWindows(prev, snapshots) {
|
|
356
|
+
const first = snapshots[0];
|
|
357
|
+
if (!first)
|
|
358
|
+
return prev;
|
|
359
|
+
const key = String(first.sheetId);
|
|
360
|
+
const prevRows = prev.sheetData[key];
|
|
361
|
+
const rows = [];
|
|
362
|
+
const sheetPrefix = `${first.sheetId}!`;
|
|
363
|
+
const cellStyles = new Map();
|
|
364
|
+
const derivedFormats = new Map();
|
|
365
|
+
// Keep other sheets' styles/derived formats; evict this sheet's prior window.
|
|
366
|
+
for (const [k, v] of prev.cellStyles)
|
|
367
|
+
if (!k.startsWith(sheetPrefix))
|
|
368
|
+
cellStyles.set(k, v);
|
|
369
|
+
for (const [k, v] of prev.derivedFormats)
|
|
370
|
+
if (!k.startsWith(sheetPrefix))
|
|
371
|
+
derivedFormats.set(k, v);
|
|
372
|
+
for (const snapshot of snapshots) {
|
|
373
|
+
for (const c of snapshot.cells) {
|
|
374
|
+
const values = rows[c.rowIndex]?.values ?? [];
|
|
375
|
+
// Use the cell VERBATIM when the lossless read carried it (`cell`); else reconstruct from the
|
|
376
|
+
// lean fields (the fallback for engines without `readWindowCommit`).
|
|
377
|
+
const built = c.cell ?? windowCellToCellData(c);
|
|
378
|
+
const prevCell = prevRows?.[c.rowIndex]?.values?.[c.columnIndex];
|
|
379
|
+
values[c.columnIndex] =
|
|
380
|
+
prevCell != null && sameJson(prevCell, built) ? prevCell : built;
|
|
381
|
+
rows[c.rowIndex] = { values };
|
|
382
|
+
}
|
|
383
|
+
for (const s of snapshot.styles) {
|
|
384
|
+
if (!s.format)
|
|
385
|
+
continue;
|
|
386
|
+
const k = cellKey(s.sheetId, s.rowIndex, s.columnIndex);
|
|
387
|
+
const prevFmt = prev.cellStyles.get(k);
|
|
388
|
+
cellStyles.set(k, prevFmt != null && sameJson(prevFmt, s.format) ? prevFmt : s.format);
|
|
389
|
+
}
|
|
390
|
+
for (const s of snapshot.derivedFormats ?? []) {
|
|
391
|
+
if (!s.format)
|
|
392
|
+
continue;
|
|
393
|
+
const k = cellKey(s.sheetId, s.rowIndex, s.columnIndex);
|
|
394
|
+
const prevFmt = prev.derivedFormats.get(k);
|
|
395
|
+
derivedFormats.set(k, prevFmt != null && sameJson(prevFmt, s.format) ? prevFmt : s.format);
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
// Row-level reuse: a row whose every cell survived per-cell reuse takes the previous row object;
|
|
399
|
+
// if EVERY row (including holes) matches, the whole sheet array keeps its identity.
|
|
400
|
+
let rowsAllReused = prevRows != null && rows.length === prevRows.length;
|
|
401
|
+
for (let i = 0; i < rows.length; i++) {
|
|
402
|
+
const row = rows[i];
|
|
403
|
+
const prevRow = prevRows?.[i];
|
|
404
|
+
if (row == null) {
|
|
405
|
+
// A hole (no cells in this row's window) only matches a hole.
|
|
406
|
+
if (prevRow != null)
|
|
407
|
+
rowsAllReused = false;
|
|
408
|
+
continue;
|
|
409
|
+
}
|
|
410
|
+
const values = row.values ?? [];
|
|
411
|
+
const prevValues = prevRow?.values;
|
|
412
|
+
let same = prevValues != null && prevValues.length === values.length;
|
|
413
|
+
if (same)
|
|
414
|
+
for (let j = 0; j < values.length; j++)
|
|
415
|
+
if (values[j] !== prevValues[j]) {
|
|
416
|
+
same = false;
|
|
417
|
+
break;
|
|
418
|
+
}
|
|
419
|
+
if (same)
|
|
420
|
+
rows[i] = prevRow;
|
|
421
|
+
else
|
|
422
|
+
rowsAllReused = false;
|
|
423
|
+
}
|
|
424
|
+
const nextRows = rowsAllReused && prevRows != null ? prevRows : rows;
|
|
425
|
+
const nextCellStyles = stylesEqual(cellStyles, prev.cellStyles)
|
|
426
|
+
? prev.cellStyles
|
|
427
|
+
: cellStyles;
|
|
428
|
+
const nextDerivedFormats = stylesEqual(derivedFormats, prev.derivedFormats)
|
|
429
|
+
? prev.derivedFormats
|
|
430
|
+
: derivedFormats;
|
|
431
|
+
if (nextRows === prevRows &&
|
|
432
|
+
nextCellStyles === prev.cellStyles &&
|
|
433
|
+
nextDerivedFormats === prev.derivedFormats)
|
|
434
|
+
return prev;
|
|
435
|
+
return {
|
|
436
|
+
...prev,
|
|
437
|
+
sheetData: nextRows === prevRows
|
|
438
|
+
? prev.sheetData
|
|
439
|
+
: { ...prev.sheetData, [key]: nextRows },
|
|
440
|
+
cellStyles: nextCellStyles,
|
|
441
|
+
derivedFormats: nextDerivedFormats,
|
|
442
|
+
};
|
|
443
|
+
}
|
|
444
|
+
/** Hard caps on the frozen-band spans a windowed read will load. Frozen panes occupy screen space, so
|
|
445
|
+
* only ~a screenful of frozen rows/columns can ever be visible — but the frozen COUNTS are unbounded
|
|
446
|
+
* (freeze "up to current row" deep in a million-row sheet stores that row as the count). Without a cap,
|
|
447
|
+
* one band read becomes `rows 1..count × window cols` — millions of `CellData` entries accumulated into
|
|
448
|
+
* a single `HashMap` inside the wasm store, whose resize overflows wasm32's 2 GiB allocation-layout
|
|
449
|
+
* limit and traps with hashbrown's "Hash table capacity overflow" panic, poisoning the shared engine.
|
|
450
|
+
* These caps bound the band read; frozen cells beyond them are off-screen and render blank only in the
|
|
451
|
+
* (already unusable) case of a frozen span taller/wider than the screen. */
|
|
452
|
+
const MAX_FROZEN_BAND_ROWS = 512;
|
|
453
|
+
const MAX_FROZEN_BAND_COLS = 128;
|
|
454
|
+
/** The frozen-pane bands a windowed read must ALWAYS include alongside the viewport window: the top
|
|
455
|
+
* band (frozen rows × the window's columns), the left band (the window's rows × frozen columns), and
|
|
456
|
+
* the top-left corner (frozen rows × frozen columns). Frozen panes stay on screen at every scroll
|
|
457
|
+
* position, so their cells must never be evicted by the window fold — without these bands, scrolling
|
|
458
|
+
* past the frozen rows/columns blanks them. Bands already covered by the window (its edge sits at
|
|
459
|
+
* row/column 1) are omitted; a window starting INSIDE the frozen span only needs the uncovered part.
|
|
460
|
+
* Band spans are clamped to {@link MAX_FROZEN_BAND_ROWS}/{@link MAX_FROZEN_BAND_COLS}, and non-finite
|
|
461
|
+
* or fractional counts (a corrupted doc plane) are floored — `NaN` disables the band. */
|
|
462
|
+
export function frozenBandRanges(w, frozenRowCount, frozenColumnCount) {
|
|
463
|
+
const topRows = Math.min(Math.floor(frozenRowCount), MAX_FROZEN_BAND_ROWS, w.startRow - 1);
|
|
464
|
+
const leftCols = Math.min(Math.floor(frozenColumnCount), MAX_FROZEN_BAND_COLS, w.startCol - 1);
|
|
465
|
+
const bands = [];
|
|
466
|
+
if (topRows > 0)
|
|
467
|
+
bands.push({
|
|
468
|
+
sheetId: w.sheetId,
|
|
469
|
+
startRow: 1,
|
|
470
|
+
startCol: w.startCol,
|
|
471
|
+
endRow: topRows,
|
|
472
|
+
endCol: w.endCol,
|
|
473
|
+
});
|
|
474
|
+
if (leftCols > 0)
|
|
475
|
+
bands.push({
|
|
476
|
+
sheetId: w.sheetId,
|
|
477
|
+
startRow: w.startRow,
|
|
478
|
+
startCol: 1,
|
|
479
|
+
endRow: w.endRow,
|
|
480
|
+
endCol: leftCols,
|
|
481
|
+
});
|
|
482
|
+
if (topRows > 0 && leftCols > 0)
|
|
483
|
+
bands.push({
|
|
484
|
+
sheetId: w.sheetId,
|
|
485
|
+
startRow: 1,
|
|
486
|
+
startCol: 1,
|
|
487
|
+
endRow: topRows,
|
|
488
|
+
endCol: leftCols,
|
|
489
|
+
});
|
|
490
|
+
return bands;
|
|
491
|
+
}
|
|
492
|
+
/** Fold a {@link WindowSnapshot} companion {@link CommitResult}, keeping the channels a windowed
|
|
493
|
+
* sheet can't re-read from the window: structural metadata (dimensions / merges) AND the whole-document
|
|
494
|
+
* object channels (charts / pivots). In windowed mode a commit's CELL changes are intentionally NOT
|
|
495
|
+
* accumulated (the window is re-read instead); but row/column metadata + merges, and charts + pivots,
|
|
496
|
+
* are not part of the window read (and `documentFacets` excludes charts), so they must fold here or a
|
|
497
|
+
* windowed sheet silently drops them — e.g. charts/pivots an xlsx import adds. Cells/styles are left
|
|
498
|
+
* for {@link foldWindow} to refresh. */
|
|
499
|
+
export function foldCommitStructuralOnly(prev, commit) {
|
|
500
|
+
const sheets = applyMerges(applyDimensions(prev.sheets, commit.rowChanges, commit.columnChanges), commit.merges);
|
|
501
|
+
// `foldObjects` returns `prev` unchanged on an empty delta, so an edit-only commit stays churn-free.
|
|
502
|
+
const charts = foldObjects(prev.charts, commit.chartChanges.map((c) => ({ id: c.chartId, spec: c.chart })));
|
|
503
|
+
const pivotTables = foldObjects(prev.pivotTables, commit.pivotChanges.map((p) => ({ id: p.pivotId, spec: p.pivotTable })));
|
|
504
|
+
// Notes are a SIDE map (`getNote`), not cell data — the window re-read never delivers them, so
|
|
505
|
+
// unlike values/styles they must fold here too. Without this, an `update-note` on a windowed doc
|
|
506
|
+
// (and the notes carried by a reload/reproject snapshot or a remote commit) never reached
|
|
507
|
+
// `state.notes`: the note persisted in the doc but rendered nowhere — no corner indicator, no
|
|
508
|
+
// tooltip, and the note editor reopened empty. `foldNotes` returns `prev.notes` on an empty
|
|
509
|
+
// delta, keeping the churn-free contract.
|
|
510
|
+
const notes = foldNotes(prev.notes, commit.noteChanges ?? []);
|
|
511
|
+
if (sheets === prev.sheets &&
|
|
512
|
+
charts === prev.charts &&
|
|
513
|
+
pivotTables === prev.pivotTables &&
|
|
514
|
+
notes === prev.notes) {
|
|
515
|
+
return prev;
|
|
516
|
+
}
|
|
517
|
+
return { ...prev, sheets, charts, pivotTables, notes };
|
|
518
|
+
}
|
|
519
|
+
/** Fold a whole {@link CommitResult} into the render state — the single entry point a thin UI calls
|
|
520
|
+
* for every engine change-set (edit, undo/redo, load). Returns a new state (referentially changed
|
|
521
|
+
* only where a channel had deltas). */
|
|
522
|
+
export function foldCommit(prev, commit) {
|
|
523
|
+
let sheets = applyDimensions(prev.sheets, commit.rowChanges, commit.columnChanges);
|
|
524
|
+
sheets = applyMerges(sheets, commit.merges);
|
|
525
|
+
// Values first, then the data-validation RESULT lands on each affected cell (not a side map).
|
|
526
|
+
let sheetData = applyChanges(prev.sheetData, commit.changes);
|
|
527
|
+
sheetData = foldDataValidationResults(sheetData, commit.validations);
|
|
528
|
+
return {
|
|
529
|
+
sheets,
|
|
530
|
+
sheetData,
|
|
531
|
+
cellStyles: foldStyles(prev.cellStyles, commit.styleChanges),
|
|
532
|
+
// Render-only inherited formats (`=SUM($a,$b)` → currency). Empty/absent when no formula cell
|
|
533
|
+
// changed (the engine skip-serializes it), so default to `[]`.
|
|
534
|
+
derivedFormats: foldStyles(prev.derivedFormats, commit.derivedFormatChanges ?? []),
|
|
535
|
+
notes: foldNotes(prev.notes, commit.noteChanges),
|
|
536
|
+
charts: foldObjects(prev.charts, commit.chartChanges.map((c) => ({ id: c.chartId, spec: c.chart }))),
|
|
537
|
+
pivotTables: foldObjects(prev.pivotTables, commit.pivotChanges.map((p) => ({ id: p.pivotId, spec: p.pivotTable }))),
|
|
538
|
+
};
|
|
539
|
+
}
|