@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.
Files changed (37) hide show
  1. package/LICENSE.md +100 -0
  2. package/README.md +83 -0
  3. package/dist/esm/command-handlers.d.ts +169 -0
  4. package/dist/esm/command-handlers.d.ts.map +1 -0
  5. package/dist/esm/command-handlers.js +404 -0
  6. package/dist/esm/fold.d.ts +140 -0
  7. package/dist/esm/fold.d.ts.map +1 -0
  8. package/dist/esm/fold.js +539 -0
  9. package/dist/esm/index.d.ts +620 -0
  10. package/dist/esm/index.d.ts.map +1 -0
  11. package/dist/esm/index.js +425 -0
  12. package/dist/esm/io.d.ts +119 -0
  13. package/dist/esm/io.d.ts.map +1 -0
  14. package/dist/esm/io.js +318 -0
  15. package/dist/esm/keys.d.ts +20 -0
  16. package/dist/esm/keys.d.ts.map +1 -0
  17. package/dist/esm/keys.js +41 -0
  18. package/dist/esm/move-rescope.d.ts +18 -0
  19. package/dist/esm/move-rescope.d.ts.map +1 -0
  20. package/dist/esm/move-rescope.js +38 -0
  21. package/dist/esm/use-engine-core.d.ts +293 -0
  22. package/dist/esm/use-engine-core.d.ts.map +1 -0
  23. package/dist/esm/use-engine-core.js +2279 -0
  24. package/dist/esm/use-engine-pivot-editor.d.ts +78 -0
  25. package/dist/esm/use-engine-pivot-editor.d.ts.map +1 -0
  26. package/dist/esm/use-engine-pivot-editor.js +252 -0
  27. package/dist/esm/use-spreadsheet-engine.d.ts +7 -0
  28. package/dist/esm/use-spreadsheet-engine.d.ts.map +1 -0
  29. package/dist/esm/use-spreadsheet-engine.js +23 -0
  30. package/dist/esm/use-spreadsheet-ui.d.ts +39 -0
  31. package/dist/esm/use-spreadsheet-ui.d.ts.map +1 -0
  32. package/dist/esm/use-spreadsheet-ui.js +78 -0
  33. package/dist/esm/wasm/rnc_wasm.d.ts +394 -0
  34. package/dist/esm/wasm/rnc_wasm.js +1455 -0
  35. package/dist/esm/wasm/rnc_wasm_bg.wasm +0 -0
  36. package/dist/esm/wasm/rnc_wasm_bg.wasm.d.ts +81 -0
  37. package/package.json +51 -0
@@ -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
+ }