@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
|
@@ -0,0 +1,2279 @@
|
|
|
1
|
+
// Lightweight dependency-graph NODE classes (data holders — NOT the JS calculator) used to shape the
|
|
2
|
+
// real `getPrecedents`/`getDependents` results, which the engine now backs with actual graph data.
|
|
3
|
+
import { CellNode, CellRangeNode, isCellCoordinate, isCellNode, makeKey, } from "@rowsncolumns/dag";
|
|
4
|
+
import { DEFAULT_ACTIVE_CELL, DEFAULT_ARRAY, defaultSpreadsheetTheme, getInitialActiveSheetId, ICON_SET_COLORS, ICON_SET_GLYPHS, useFonts, } from "@rowsncolumns/spreadsheet";
|
|
5
|
+
import { CellXfsRegistry, generateDataFromClipboard, isStyleReference, pickFormula, pickValue, SharedStringRegistry, transpose, useOnCopy, useOnTraceDependentsPrecedents, } from "@rowsncolumns/spreadsheet-state";
|
|
6
|
+
import { getCellUserEnteredFormat, getCellUserEnteredValue, getExtendedValueBool, getExtendedValueFormula, getExtendedValueNumber, getExtendedValueString, normalizeExternalUrl, } from "@rowsncolumns/utils";
|
|
7
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
8
|
+
import { createCommandHandlers, } from "./command-handlers";
|
|
9
|
+
import { emptyRenderState, foldCommit, foldCommitStructuralOnly, foldWindow, foldWindows, frozenBandRanges, reconcileSheets, } from "./fold";
|
|
10
|
+
import { RncEngine, } from "./index";
|
|
11
|
+
import { cellKey, parseCellKey } from "./keys";
|
|
12
|
+
import { rescopeRulesAfterMove } from "./move-rescope";
|
|
13
|
+
/** Extra rows/columns to read on EACH side of the visible range so a small scroll reveals already-
|
|
14
|
+
* loaded cells (no flash) before the next `readWindow`. Tuned for a typical viewport; the window
|
|
15
|
+
* size stays O(viewport), not O(sheet), so JS memory is bounded regardless of a 1M-row sheet. */
|
|
16
|
+
const WINDOW_BUFFER_ROWS = 50;
|
|
17
|
+
const WINDOW_BUFFER_COLS = 5;
|
|
18
|
+
/** How close (in rows/cols) the visible range may get to the LOADED window's edge before we re-read.
|
|
19
|
+
* `onViewPortChange` fires on every scroll tick, but the loaded window already extends a full buffer
|
|
20
|
+
* past the viewport — so while the visible range stays inside that buffer (minus this margin), the
|
|
21
|
+
* cells are already in state and scrolling is pure paint: NO synchronous `readWindow`, no `foldWindow`,
|
|
22
|
+
* no re-render. We only re-read once the viewport scrolls within this margin of an edge (prefetching
|
|
23
|
+
* the next slice while buffer still covers the gap). Must be < the buffer, or every tick re-reads.
|
|
24
|
+
* This is what keeps windowed scrolling smooth — without it each scroll tick blocked on a wasm read. */
|
|
25
|
+
const WINDOW_REFRESH_MARGIN_ROWS = 16;
|
|
26
|
+
const WINDOW_REFRESH_MARGIN_COLS = 2;
|
|
27
|
+
/** Windowed read that always keeps the frozen panes loaded: the buffered viewport window PLUS the
|
|
28
|
+
* sheet's frozen top/left/corner bands (see {@link frozenBandRanges}). Frozen rows/columns stay on
|
|
29
|
+
* screen at every scroll position, but the window fold evicts everything outside what's read here —
|
|
30
|
+
* so without the bands, scrolling past the frozen span blanks the frozen cells. Fold the result with
|
|
31
|
+
* `foldWindows` (one eviction, all snapshots merged).
|
|
32
|
+
*
|
|
33
|
+
* `pinnedCell` (the sheet's active cell) gets the same residency guarantee as the frozen bands: the
|
|
34
|
+
* formula bar keeps displaying the ACTIVE cell's user-entered value while the user scrolls it out of
|
|
35
|
+
* the viewport, but every accessor answering that read (`getUserEnteredValue`, `getTextFormatRuns`,
|
|
36
|
+
* `getCellFormat`, …) resolves through the windowed `getCellData` — so an evicted active cell blanked
|
|
37
|
+
* the formula bar on scroll. A 1×1 range piggybacking on a read that's already crossing into wasm is
|
|
38
|
+
* free; skipped when the cell is inside the window or a frozen band. */
|
|
39
|
+
function readWindowWithFrozenBands(engine, w, frozenRowCount, frozenColumnCount, pinnedCell) {
|
|
40
|
+
const ranges = [w, ...frozenBandRanges(w, frozenRowCount, frozenColumnCount)];
|
|
41
|
+
if (pinnedCell) {
|
|
42
|
+
const { rowIndex, columnIndex } = pinnedCell;
|
|
43
|
+
const covered = ranges.some((r) => rowIndex >= r.startRow &&
|
|
44
|
+
rowIndex <= r.endRow &&
|
|
45
|
+
columnIndex >= r.startCol &&
|
|
46
|
+
columnIndex <= r.endCol);
|
|
47
|
+
if (!covered && rowIndex >= 1 && columnIndex >= 1) {
|
|
48
|
+
ranges.push({
|
|
49
|
+
sheetId: w.sheetId,
|
|
50
|
+
startRow: rowIndex,
|
|
51
|
+
startCol: columnIndex,
|
|
52
|
+
endRow: rowIndex,
|
|
53
|
+
endCol: columnIndex,
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return ranges.map((r) => engine.readWindow(r.sheetId, r.startRow, r.startCol, r.endRow, r.endCol));
|
|
58
|
+
}
|
|
59
|
+
/** Display text for a cell whose custom-function eval is in flight — the same transient
|
|
60
|
+
* placeholder the JS engine's `SheetCell` shows (`LOADING_VALUE`). */
|
|
61
|
+
const LOADING_FORMATTED_VALUE = "Loading...";
|
|
62
|
+
// A paint-format target spanning at least this many rows/cols is a header-click
|
|
63
|
+
// full-column/row selection, not an intentional drag — bound it to the data extent.
|
|
64
|
+
const UNBOUNDED_PAINT_SPAN = 10_000;
|
|
65
|
+
/** The engine marks a host-call cell awaiting its answer — and every cell SHADOWED by it, i.e.
|
|
66
|
+
* whose value derives from it directly, through a chain, or through a range (see rnc-store
|
|
67
|
+
* `refresh_pending_shadow`) — with an error named `#PENDING!`, instead of letting dependents
|
|
68
|
+
* compute off 0/empty; the host's `set-effective-value` answer settles the whole set. It is a
|
|
69
|
+
* WAIT state, not a failure: the accessors present it as LOADING ({@link LOADING_FORMATTED_VALUE}
|
|
70
|
+
* text, no error indicator / popover). Sentinel-driven, so collab PEERS of the evaluating client
|
|
71
|
+
* render loading too. */
|
|
72
|
+
const isPendingEvalError = (ev) => {
|
|
73
|
+
const err = ev?.ev;
|
|
74
|
+
return !!err && (err.name === "#PENDING!" || err.message === "#PENDING!");
|
|
75
|
+
};
|
|
76
|
+
/** The Invalid marker presented for a cell whose engine data-validation verdict is `false`.
|
|
77
|
+
* One shared object (stable identity across reads); message mirrors `SheetCell.validate()`. */
|
|
78
|
+
const DATA_VALIDATION_INVALID_ERROR = {
|
|
79
|
+
type: "Invalid",
|
|
80
|
+
message: "This cell's contents violate its validation rule",
|
|
81
|
+
};
|
|
82
|
+
/** A {@link CellData} for a previewed value. The stream hands us display strings (the host's values,
|
|
83
|
+
* or a formula restated when evaluation is off), so a numeric-looking string is presented as a number
|
|
84
|
+
* — that's what right-alignment and any consumer reading `ev.nv` expect. Nothing here is evaluated:
|
|
85
|
+
* a preview never runs the formula engine, because it never commits. */
|
|
86
|
+
function previewCellData(text) {
|
|
87
|
+
if (text == null)
|
|
88
|
+
return { ue: {}, ev: {}, fv: "" };
|
|
89
|
+
const trimmed = text.trim();
|
|
90
|
+
const asNumber = trimmed !== "" && !trimmed.startsWith("=") ? Number(trimmed) : Number.NaN;
|
|
91
|
+
if (!Number.isNaN(asNumber)) {
|
|
92
|
+
return { ue: { nv: asNumber }, ev: { nv: asNumber }, fv: text };
|
|
93
|
+
}
|
|
94
|
+
return { ue: { sv: text }, ev: { sv: text }, fv: text };
|
|
95
|
+
}
|
|
96
|
+
/** Seed for the parsed facets before the first commit (stable reference). */
|
|
97
|
+
const EMPTY_FACETS = {
|
|
98
|
+
conditionalFormats: [],
|
|
99
|
+
dataValidations: [],
|
|
100
|
+
namedRanges: [],
|
|
101
|
+
tables: [],
|
|
102
|
+
embeds: [],
|
|
103
|
+
slicers: [],
|
|
104
|
+
bandedRanges: [],
|
|
105
|
+
protectedRanges: [],
|
|
106
|
+
basicFilters: [],
|
|
107
|
+
citations: [],
|
|
108
|
+
};
|
|
109
|
+
/** True when 1-indexed cell (row,col) falls inside a `SheetRange` on the given sheet. */
|
|
110
|
+
function rangeContains(range, sheetId, row, col) {
|
|
111
|
+
return (range.sheetId === sheetId &&
|
|
112
|
+
row >= (range.startRowIndex ?? 0) &&
|
|
113
|
+
row <= (range.endRowIndex ?? -1) &&
|
|
114
|
+
col >= (range.startColumnIndex ?? 0) &&
|
|
115
|
+
col <= (range.endColumnIndex ?? -1));
|
|
116
|
+
}
|
|
117
|
+
// ── Drop-in parity stubs for the rest of `useSpreadsheetState`'s surface ──
|
|
118
|
+
// These keys exist so the hook is STRUCTURALLY a `useSpreadsheetState` (any consumer that destructures
|
|
119
|
+
// them type-checks), but in Option B they're INERT: the engine owns calc + the dependency graph +
|
|
120
|
+
// undo/redo (so the JS immer-patch history is vestigial — see `undo`/`redo`, which delegate to it), and
|
|
121
|
+
// import / dialogs / clipboard / citations are the host app's concern. Typed via `SS[K]` so signatures
|
|
122
|
+
// stay exact; module-level so the references are stable (one identity, no re-render churn, no deps).
|
|
123
|
+
const EMPTY_COLORS = {};
|
|
124
|
+
// Calc + dependency graph — the engine computes everything; nothing to schedule, evaluate, or trace in JS.
|
|
125
|
+
const addFormulaToGraph = () => { };
|
|
126
|
+
const enqueueCalculation = () => { };
|
|
127
|
+
const enqueueGraphOperation = () => { };
|
|
128
|
+
const updateDependencyGraph = () => { };
|
|
129
|
+
const clearEvaluatedCellsCache = () => { };
|
|
130
|
+
// `calculateNow` and `onRequestCalculate` are REAL methods (defined in the hook — recalculate
|
|
131
|
+
// command / engine scratch-eval respectively); see the hook body.
|
|
132
|
+
// `getDependencyGraph` (a whole-graph JSON dump) stays a thin stub — niche debug/export, low value.
|
|
133
|
+
// `getPrecedents`/`getDependents` are REAL, implemented in the hook over the engine's graph (below);
|
|
134
|
+
// `onTracePrecedents`/`onTraceDependents`/`onRemoveArrows`/`arrows` are REAL too — the reference's
|
|
135
|
+
// trace hook composed over those engine-backed queries (see the hook body).
|
|
136
|
+
const getDependencyGraph = () => "[]";
|
|
137
|
+
// `onViewPortChange` is REAL here (it drives windowed reads), so it's NOT a module-level stub — it's
|
|
138
|
+
// implemented in the hook with the `ViewportRange` shape CanvasGrid reports, and is a no-op in the
|
|
139
|
+
// default (non-windowed) path. See the hook body + the `EngineSpreadsheet` declaration.
|
|
140
|
+
const evaluateConditionalFormatting = () => { };
|
|
141
|
+
const evaluateConditionalFormattingForViewport = () => { };
|
|
142
|
+
// The Rust engine re-derives CF/DV results for changed cells inside every
|
|
143
|
+
// commit (`apply_cf_into_changes`), so the JS-side explicit re-derivation is
|
|
144
|
+
// inherently unnecessary here.
|
|
145
|
+
const reevaluateDerivationsForCells = () => { };
|
|
146
|
+
const evaluateDataValidations = () => { };
|
|
147
|
+
const evaluateDataValidationsForViewport = () => { };
|
|
148
|
+
// Undo/redo — owned by the engine (`undo`/`redo`). These JS immer-patch history hooks are inert.
|
|
149
|
+
const applyPatch = () => { };
|
|
150
|
+
const generateStatePatches = () => Promise.resolve({});
|
|
151
|
+
const createHistory = () => () => { };
|
|
152
|
+
// `onClearHistory` is REAL (implemented in the hook — clears the engine's undo/redo stacks).
|
|
153
|
+
// `getFormattingFromRange` / `getFormulasFromRange` / `getUserEnteredValuesFromRange` are REAL,
|
|
154
|
+
// implemented in the hook over the folded state (see below). `getSheetProperties` stays a no-op (the
|
|
155
|
+
// grid reads sheet props via the dedicated accessors).
|
|
156
|
+
const getSheetProperties = () => undefined;
|
|
157
|
+
// A rejected promise (`Promise<never>` → assignable anywhere) for the async ops the HOST owns in
|
|
158
|
+
// Option B — import / paste / file-drop. Honest signal if a drop-in caller ever invokes one.
|
|
159
|
+
const hostOwned = (op) => Promise.reject(new Error(`${op} is host-owned in the engine-backed hook`));
|
|
160
|
+
// Import — a separate host-driven ingestion path, not the live render surface.
|
|
161
|
+
const importExcelFile = () => hostOwned("importExcelFile");
|
|
162
|
+
const importCSVFile = () => hostOwned("importCSVFile");
|
|
163
|
+
const importCSVFileLegacy = () => hostOwned("importCSVFileLegacy");
|
|
164
|
+
// `onUpdateSheet`, `onRequestAddRows`, `onDeleteDataValidationRules`, `onClearHistory`, and
|
|
165
|
+
// `onChangeBatchStream` are REAL (implemented in the hook below — they emit commands / extend the
|
|
166
|
+
// grid / stream chunked batches / clear engine history). The one that remains inert:
|
|
167
|
+
// • `onChangeCellXfs` — a React `setState` for Excel's cellXfs (style-index) registry; the engine
|
|
168
|
+
// stores `CellFormat` directly on cells, so there's no xfs registry to mutate — no engine analog.
|
|
169
|
+
const onChangeCellXfs = undefined;
|
|
170
|
+
/** Which axes a range reader omits for a `HIDDEN_DIMENSION_STRATEGY`. All four variants are honored:
|
|
171
|
+
* rows only, columns only, both, or neither (`SHOW_ALL` — and `null`/`undefined`, which the chart
|
|
172
|
+
* callers use to mean "show all"). */
|
|
173
|
+
function hiddenStrategyAxes(strategy) {
|
|
174
|
+
return {
|
|
175
|
+
skipRows: strategy === "SKIP_HIDDEN_ROWS_AND_COLUMNS" ||
|
|
176
|
+
strategy === "SKIP_HIDDEN_ROWS",
|
|
177
|
+
skipCols: strategy === "SKIP_HIDDEN_ROWS_AND_COLUMNS" ||
|
|
178
|
+
strategy === "SKIP_HIDDEN_COLUMNS",
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
/** 1-based column number for an A1 column label (`"A"` → 1, `"AA"` → 27). Inverse of
|
|
182
|
+
* {@link columnLabel}. */
|
|
183
|
+
function columnNumber(label) {
|
|
184
|
+
let n = 0;
|
|
185
|
+
for (let i = 0; i < label.length; i++)
|
|
186
|
+
n = n * 26 + (label.toUpperCase().charCodeAt(i) - 64);
|
|
187
|
+
return n;
|
|
188
|
+
}
|
|
189
|
+
/** An A1 cell/range reference (`B3`, `$B$3`, `B3:C5`) — the part of an internal link after `!`. */
|
|
190
|
+
const A1_RANGE_RE = /^\$?([A-Za-z]{1,3})\$?(\d+)(?::\$?([A-Za-z]{1,3})\$?(\d+))?$/;
|
|
191
|
+
/** Parse an internal-link `location` (`'My Sheet'!A1:B5`, `Sheet1!B3`, `A1`) into its sheet-name
|
|
192
|
+
* part and 1-based range. `range` is undefined when the reference isn't A1-shaped (e.g. a named
|
|
193
|
+
* range) — the caller can still act on `sheetName`. */
|
|
194
|
+
function parseInternalLocation(location) {
|
|
195
|
+
const bang = location.indexOf("!");
|
|
196
|
+
const sheetName = bang >= 0
|
|
197
|
+
? location.slice(0, bang).replace(/^'|'$/g, "").replace(/''/g, "'")
|
|
198
|
+
: undefined;
|
|
199
|
+
const ref = (bang >= 0 ? location.slice(bang + 1) : location).trim();
|
|
200
|
+
const m = A1_RANGE_RE.exec(ref);
|
|
201
|
+
if (!m)
|
|
202
|
+
return { sheetName };
|
|
203
|
+
const startColumn = columnNumber(m[1]);
|
|
204
|
+
const startRow = Number(m[2]);
|
|
205
|
+
const endColumn = m[3] ? columnNumber(m[3]) : startColumn;
|
|
206
|
+
const endRow = m[4] ? Number(m[4]) : startRow;
|
|
207
|
+
return {
|
|
208
|
+
sheetName,
|
|
209
|
+
range: {
|
|
210
|
+
startRowIndex: Math.min(startRow, endRow),
|
|
211
|
+
endRowIndex: Math.max(startRow, endRow),
|
|
212
|
+
startColumnIndex: Math.min(startColumn, endColumn),
|
|
213
|
+
endColumnIndex: Math.max(startColumn, endColumn),
|
|
214
|
+
},
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
// `useEngineCore` is the engine-only surface (data / calc / commands / accessors) — NOT a standalone
|
|
218
|
+
// drop-in for `useSpreadsheetState`. The chrome (dialogs + host intents) lives in `useSpreadsheetUI`;
|
|
219
|
+
// the drop-in parity lock is asserted on their composition, `useSpreadsheetEngine`.
|
|
220
|
+
/** **Option B, pure core:** the Rust→WASM data store + calc + commands, with no UI/chrome. The
|
|
221
|
+
* composed {@link useSpreadsheetEngine} layers {@link useSpreadsheetUI} (dialogs) on top — most apps
|
|
222
|
+
* want that. Use `useEngineCore` directly only when you bring your own chrome. */
|
|
223
|
+
export function useEngineCore(options) {
|
|
224
|
+
const engineRef = useRef(null);
|
|
225
|
+
// Host custom-function implementations by name (JS bodies the engine calls back via `customEvals`).
|
|
226
|
+
const customFnsRef = useRef(new Map());
|
|
227
|
+
// Per-cell eval generation + in-flight AbortController. A `customCancels` entry (or a NEWER eval
|
|
228
|
+
// of the same cell) bumps the generation and aborts the controller: the impl's signal fires (so
|
|
229
|
+
// fetches/poll loops actually stop) and its late answer finds a newer generation on resolution
|
|
230
|
+
// and is dropped — a stale result must never materialize at a vacated coordinate.
|
|
231
|
+
const evalGenRef = useRef(new Map());
|
|
232
|
+
const evalAbortRef = useRef(new Map());
|
|
233
|
+
const abortEval = useCallback((key) => {
|
|
234
|
+
const gens = evalGenRef.current;
|
|
235
|
+
gens.set(key, (gens.get(key) ?? 0) + 1);
|
|
236
|
+
const controller = evalAbortRef.current.get(key);
|
|
237
|
+
if (controller) {
|
|
238
|
+
evalAbortRef.current.delete(key);
|
|
239
|
+
controller.abort();
|
|
240
|
+
}
|
|
241
|
+
}, []);
|
|
242
|
+
// Latest-ref for the host's cancel observer (identity may change every render).
|
|
243
|
+
const onCustomEvalCancelRef = useRef(options.onCustomEvalCancel);
|
|
244
|
+
onCustomEvalCancelRef.current = options.onCustomEvalCancel;
|
|
245
|
+
// Latest-refs for the streaming batch-write observers (identities may change every render).
|
|
246
|
+
const onStreamStartRef = useRef(options.onStreamStart);
|
|
247
|
+
onStreamStartRef.current = options.onStreamStart;
|
|
248
|
+
const onStreamProgressRef = useRef(options.onStreamProgress);
|
|
249
|
+
onStreamProgressRef.current = options.onStreamProgress;
|
|
250
|
+
const onStreamEndRef = useRef(options.onStreamEnd);
|
|
251
|
+
onStreamEndRef.current = options.onStreamEnd;
|
|
252
|
+
const [ready, setReady] = useState(false);
|
|
253
|
+
const [state, setState] = useState(() => emptyRenderState(options.sheets));
|
|
254
|
+
// Windowed mode (default off). When on, render state holds only the current viewport's cells/styles
|
|
255
|
+
// (see the `windowed` option). `windowedRef` is read inside the engine subscriber (stable closure);
|
|
256
|
+
// `windowRef` holds the currently-loaded buffered range so a commit can re-read THAT window.
|
|
257
|
+
const windowedRef = useRef(options.windowed ?? false);
|
|
258
|
+
const windowRef = useRef(null);
|
|
259
|
+
// The last viewport CanvasGrid reported (via `onViewPortChange`) — the row/column span the user is
|
|
260
|
+
// actually looking at. Reused to seed the window on a sheet switch so the WHOLE visible grid loads:
|
|
261
|
+
// switching sheets keeps the scroll position and therefore does NOT re-fire `onViewPortChange`, so a
|
|
262
|
+
// tiny top-left placeholder would leave everything past the first few columns unfetched until a
|
|
263
|
+
// manual scroll. Null until the first viewport report.
|
|
264
|
+
const lastViewportRef = useRef(null);
|
|
265
|
+
// Live refs for the windowed frozen-band reads (read inside stable callbacks + the mount effect).
|
|
266
|
+
// The host getter is authoritative (a collab host answers from its doc plane); the hook's own folded
|
|
267
|
+
// per-sheet counts are the fallback. Latest-ref pattern: reassigned every render.
|
|
268
|
+
const getWindowFrozenCountsRef = useRef(options.getWindowFrozenCounts);
|
|
269
|
+
getWindowFrozenCountsRef.current = options.getWindowFrozenCounts;
|
|
270
|
+
const stateSheetsRef = useRef(options.sheets);
|
|
271
|
+
stateSheetsRef.current = state.sheets;
|
|
272
|
+
// One windowed read = the buffered window + the sheet's frozen bands + the sheet's active cell
|
|
273
|
+
// (pinned so the formula bar's active-cell read survives scrolling it out of the window — see
|
|
274
|
+
// `readWindowWithFrozenBands`). Frozen counts come from the host's getter when provided (see
|
|
275
|
+
// `getWindowFrozenCounts`), else the hook's folded sheet metadata. The active cell defaults to A1
|
|
276
|
+
// exactly like the published view state does (`viewOfSheet`), so a never-clicked sheet still pins
|
|
277
|
+
// the cell the formula bar is showing. `activeCellBySheetIdRef` is declared below; the closure only
|
|
278
|
+
// runs after mount, when it's initialized.
|
|
279
|
+
const readWindowedSnapshots = useCallback((engine, w) => {
|
|
280
|
+
const host = getWindowFrozenCountsRef.current?.(w.sheetId);
|
|
281
|
+
const sheet = stateSheetsRef.current.find((s) => s.sheetId === w.sheetId);
|
|
282
|
+
return readWindowWithFrozenBands(engine, w, host?.frozenRowCount ?? sheet?.frozenRowCount ?? 0, host?.frozenColumnCount ?? sheet?.frozenColumnCount ?? 0, activeCellBySheetIdRef.current[w.sheetId] ?? DEFAULT_ACTIVE_CELL);
|
|
283
|
+
}, []);
|
|
284
|
+
// Engine data extents `[sheetId, rowCount, colCount][]` → the grid's scroll extent in windowed mode
|
|
285
|
+
// (so it scrolls the full sheet while JS holds only the window). Empty until the engine reports them.
|
|
286
|
+
const [extents, setExtents] = useState([]);
|
|
287
|
+
// Document-level facets (CF rules, tables, named/protected ranges, embeds, slicers, banded ranges,
|
|
288
|
+
// basic filters, DV rules) — read in one shot from the engine after each commit. Stored as the RAW
|
|
289
|
+
// JSON string so a pure cell edit (facets unchanged) keeps the SAME string ⇒ same parsed object ⇒
|
|
290
|
+
// stable facet props ⇒ no needless CanvasGrid re-render. Parsed lazily in `facets` below.
|
|
291
|
+
const [facetsJson, setFacetsJson] = useState("{}");
|
|
292
|
+
const [hist, setHist] = useState({ canUndo: false, canRedo: false });
|
|
293
|
+
// View state — pure UI, never engine-owned. Cursor + selections are stored BY SHEET (mirroring the
|
|
294
|
+
// JS engine's `useSheetState`), so every sheet remembers its own active cell/selections and a sheet
|
|
295
|
+
// switch restores them. The maps are mirrored into refs SYNCHRONOUSLY at every mutation site (like
|
|
296
|
+
// `viewRef`) so stable callbacks read the live value, not a stale closure.
|
|
297
|
+
// Initial active sheet: the first VISIBLE titled sheet (the JS engine's `getInitialActiveSheetId`),
|
|
298
|
+
// falling back to the first seed (seeds may omit `title`, which that helper filters out) — then 1.
|
|
299
|
+
const [activeSheetId, setActiveSheetId] = useState(() => getInitialActiveSheetId(options.sheets) ?? options.sheets[0]?.sheetId ?? 1);
|
|
300
|
+
const [activeCellBySheetId, setActiveCellBySheetId] = useState({});
|
|
301
|
+
const [selectionsBySheetId, setSelectionsBySheetId] = useState({});
|
|
302
|
+
const activeCellBySheetIdRef = useRef(activeCellBySheetId);
|
|
303
|
+
const selectionsBySheetIdRef = useRef(selectionsBySheetId);
|
|
304
|
+
// The ACTIVE sheet's entries — the shared defaults keep identities stable for unvisited sheets.
|
|
305
|
+
const activeCell = activeCellBySheetId[activeSheetId] ?? DEFAULT_ACTIVE_CELL;
|
|
306
|
+
const selections = selectionsBySheetId[activeSheetId] ?? DEFAULT_ARRAY;
|
|
307
|
+
/** The remembered view for `sheetId` (cursor + selections it was left with, or the defaults). */
|
|
308
|
+
const viewOfSheet = useCallback((sheetId) => ({
|
|
309
|
+
activeSheetId: sheetId,
|
|
310
|
+
activeCell: activeCellBySheetIdRef.current[sheetId] ?? DEFAULT_ACTIVE_CELL,
|
|
311
|
+
selections: selectionsBySheetIdRef.current[sheetId] ?? DEFAULT_ARRAY,
|
|
312
|
+
}), []);
|
|
313
|
+
// Format painter: the captured 2-D pattern of source formats (empty = inactive). Applied to a target
|
|
314
|
+
// via a `change-formatting` command with `options.replace` (mirrors `usePaintFormat`).
|
|
315
|
+
const [paintFormats, setPaintFormats] = useState([]);
|
|
316
|
+
// The last SINGLE formatting command (a paint-array `change-formatting` is excluded — not a
|
|
317
|
+
// repeatable single action), replayed onto the current target by `onRepeatFormatting`. Recorded in
|
|
318
|
+
// `onCommand` so toolbar formatting (which goes straight through the pure handlers) is captured too.
|
|
319
|
+
const lastFormattingRef = useRef(null);
|
|
320
|
+
// Load default fonts
|
|
321
|
+
useFonts();
|
|
322
|
+
// The engine owns the DOCUMENT undo/redo; the hook keeps a PARALLEL view-state stack (cursor /
|
|
323
|
+
// selection / active sheet) so undo also restores where the user was — kept in lockstep with the
|
|
324
|
+
// engine via its undo-stack DEPTH. `viewRef` mirrors the current view for reading inside callbacks
|
|
325
|
+
// (avoids stale closures); the stacks hold the view as it was BEFORE each recorded edit.
|
|
326
|
+
const viewRef = useRef({
|
|
327
|
+
activeSheetId: getInitialActiveSheetId(options.sheets) ?? options.sheets[0]?.sheetId ?? 1,
|
|
328
|
+
activeCell: DEFAULT_ACTIVE_CELL,
|
|
329
|
+
selections: DEFAULT_ARRAY,
|
|
330
|
+
});
|
|
331
|
+
const viewUndo = useRef([]);
|
|
332
|
+
const viewRedo = useRef([]);
|
|
333
|
+
const lastUndoDepth = useRef(0);
|
|
334
|
+
// Post-hydration active-sheet re-derivation state (see the `onChanges` subscriber): the seed ids
|
|
335
|
+
// the mount-time derivation saw, whether the one-shot re-derivation already ran, and whether the
|
|
336
|
+
// user/host explicitly picked a sheet (which always wins over the re-derivation).
|
|
337
|
+
const seedSheetIdsRef = useRef(new Set(options.sheets.map((s) => s.sheetId)));
|
|
338
|
+
const activeSheetRederivedRef = useRef(false);
|
|
339
|
+
const userPickedSheetRef = useRef(false);
|
|
340
|
+
const applyView = useCallback((v) => {
|
|
341
|
+
viewRef.current = v;
|
|
342
|
+
const cells = {
|
|
343
|
+
...activeCellBySheetIdRef.current,
|
|
344
|
+
[v.activeSheetId]: v.activeCell,
|
|
345
|
+
};
|
|
346
|
+
const sels = {
|
|
347
|
+
...selectionsBySheetIdRef.current,
|
|
348
|
+
[v.activeSheetId]: v.selections,
|
|
349
|
+
};
|
|
350
|
+
activeCellBySheetIdRef.current = cells;
|
|
351
|
+
selectionsBySheetIdRef.current = sels;
|
|
352
|
+
setActiveSheetId(v.activeSheetId);
|
|
353
|
+
setActiveCellBySheetId(cells);
|
|
354
|
+
setSelectionsBySheetId(sels);
|
|
355
|
+
}, []);
|
|
356
|
+
// The initial sheets/document are creation-time inputs; capture them so the effect runs once and
|
|
357
|
+
// doesn't tear the engine down on every render.
|
|
358
|
+
const initial = useRef(options);
|
|
359
|
+
useEffect(() => {
|
|
360
|
+
let alive = true;
|
|
361
|
+
let unsubscribe = () => { };
|
|
362
|
+
void RncEngine.create(initial.current.sheets.map((s) => ({
|
|
363
|
+
sheetId: s.sheetId,
|
|
364
|
+
title: String(s.title ?? s.sheetId),
|
|
365
|
+
})), {
|
|
366
|
+
engine: initial.current.createEngine?.(),
|
|
367
|
+
gridRows: initial.current.gridRows,
|
|
368
|
+
gridCols: initial.current.gridCols,
|
|
369
|
+
}).then((engine) => {
|
|
370
|
+
if (!alive) {
|
|
371
|
+
engine.free();
|
|
372
|
+
return;
|
|
373
|
+
}
|
|
374
|
+
engineRef.current = engine;
|
|
375
|
+
// Tell the engine we render windowed BEFORE any commit (load below included), so it strips the
|
|
376
|
+
// per-cell channels we'd only discard — the windowed fold re-reads visible cells from the window.
|
|
377
|
+
// No-op on engines without `setWindowed`; the fold still ignores those channels either way.
|
|
378
|
+
engine.setWindowed(windowedRef.current);
|
|
379
|
+
// Seed option-provided custom functions so a LOADED `=NAME(…)` cell resolves on the first recalc
|
|
380
|
+
// (registration must precede load — see `customFunctions` on the options). Both the engine and the
|
|
381
|
+
// resolve loop below read these from `customFnsRef`.
|
|
382
|
+
if (initial.current.customFunctions)
|
|
383
|
+
for (const [name, impl] of Object.entries(initial.current.customFunctions))
|
|
384
|
+
customFnsRef.current.set(name, impl);
|
|
385
|
+
// Re-register any custom functions declared before the engine finished initializing.
|
|
386
|
+
for (const name of customFnsRef.current.keys())
|
|
387
|
+
engine.registerCustomFunction(name);
|
|
388
|
+
// Refresh the engine's data extents into state (windowed mode sizes the grid scroll to these).
|
|
389
|
+
const refreshExtents = () => {
|
|
390
|
+
const next = engine.sheetExtents();
|
|
391
|
+
setExtents((prev) =>
|
|
392
|
+
// Keep the prior identity when unchanged so the grid's row/col count props stay stable.
|
|
393
|
+
JSON.stringify(prev) === JSON.stringify(next) ? prev : next);
|
|
394
|
+
};
|
|
395
|
+
// Read the currently-loaded window (plus the sheet's frozen bands) from the engine and fold ONLY
|
|
396
|
+
// that into render state (evicting cells outside it). No-op until a viewport has been reported
|
|
397
|
+
// (or the initial window set).
|
|
398
|
+
const refreshWindow = () => {
|
|
399
|
+
const w = windowRef.current;
|
|
400
|
+
if (!w)
|
|
401
|
+
return;
|
|
402
|
+
const snaps = readWindowedSnapshots(engine, w);
|
|
403
|
+
setState((prev) => foldWindows(prev, snaps));
|
|
404
|
+
};
|
|
405
|
+
unsubscribe = engine.onChanges((commit) => {
|
|
406
|
+
// Reconcile the tab list with the engine's authoritative sheet set/order/titles after every
|
|
407
|
+
// commit — so create / rename / delete / reorder / hide / color update the tabs (and a new
|
|
408
|
+
// sheet renders). The engine owns lifecycle (it generates `create-sheet` ids), so this can't
|
|
409
|
+
// be derived from the command. `reconcileSheets` preserves each surviving sheet's
|
|
410
|
+
// dimension/merge metadata and returns the SAME array when nothing changed (no churn).
|
|
411
|
+
const engineSheets = engine.sheets();
|
|
412
|
+
if (windowedRef.current) {
|
|
413
|
+
// Windowed: do NOT accumulate the commit's cell changes (that's the 1M-row blowup). Fold only
|
|
414
|
+
// the structural channels (dimensions / merges), then re-read the CURRENT window so in-view
|
|
415
|
+
// edits + recomputed dependents update. Out-of-view changes need no folding.
|
|
416
|
+
setState((prev) => {
|
|
417
|
+
const folded = foldCommitStructuralOnly(prev, commit);
|
|
418
|
+
const sheets = reconcileSheets(folded.sheets, engineSheets);
|
|
419
|
+
return sheets === folded.sheets ? folded : { ...folded, sheets };
|
|
420
|
+
});
|
|
421
|
+
refreshWindow();
|
|
422
|
+
refreshExtents();
|
|
423
|
+
}
|
|
424
|
+
else {
|
|
425
|
+
setState((prev) => {
|
|
426
|
+
const folded = foldCommit(prev, commit);
|
|
427
|
+
const sheets = reconcileSheets(folded.sheets, engineSheets);
|
|
428
|
+
return sheets === folded.sheets ? folded : { ...folded, sheets };
|
|
429
|
+
});
|
|
430
|
+
}
|
|
431
|
+
// If the active sheet was deleted (it's gone from the engine's set), fall back to the first
|
|
432
|
+
// remaining sheet so the renderer keeps a valid active sheet. Done inline (not via
|
|
433
|
+
// `onChangeActiveSheet`, defined later) since this subscriber closes over `[]`; the live active
|
|
434
|
+
// id is read from `viewRef`. A windowed window pointing at the gone sheet is re-seeded on the
|
|
435
|
+
// next scroll/commit, so no extra handling is needed here.
|
|
436
|
+
if (engineSheets.length &&
|
|
437
|
+
!engineSheets.some((s) => s.sheetId === viewRef.current.activeSheetId)) {
|
|
438
|
+
const firstId = engineSheets[0].sheetId;
|
|
439
|
+
viewRef.current = viewOfSheet(firstId);
|
|
440
|
+
setActiveSheetId(firstId);
|
|
441
|
+
}
|
|
442
|
+
// ONE-SHOT post-hydration re-derivation of the initial active sheet. Collab hosts seed the
|
|
443
|
+
// engine with a placeholder sheet list (e.g. `[{sheetId: 1, title: 'Sheet1'}]`) before the
|
|
444
|
+
// Y.Doc syncs, so the mount-time `getInitialActiveSheetId` ran against the SEED — a doc whose
|
|
445
|
+
// data lives on other sheets (agent-built workbooks: tool-created sheets get fresh ids while
|
|
446
|
+
// the empty seed survives, listed last) would open on the blank seed sheet every time. The
|
|
447
|
+
// first commit whose authoritative sheet set goes beyond the seed re-runs the derivation —
|
|
448
|
+
// skipped once the user (or host) has explicitly picked a sheet.
|
|
449
|
+
if (!activeSheetRederivedRef.current &&
|
|
450
|
+
engineSheets.length &&
|
|
451
|
+
engineSheets.some((s) => !seedSheetIdsRef.current.has(s.sheetId))) {
|
|
452
|
+
activeSheetRederivedRef.current = true;
|
|
453
|
+
if (!userPickedSheetRef.current) {
|
|
454
|
+
const derivedId = getInitialActiveSheetId(engineSheets) ?? engineSheets[0].sheetId;
|
|
455
|
+
if (derivedId !== viewRef.current.activeSheetId) {
|
|
456
|
+
viewRef.current = viewOfSheet(derivedId);
|
|
457
|
+
setActiveSheetId(derivedId);
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
// Re-read facets and keep the prior string identity if unchanged (the common case).
|
|
462
|
+
const nextFacets = engine.documentFacetsJson();
|
|
463
|
+
setFacetsJson((prev) => (prev === nextFacets ? prev : nextFacets));
|
|
464
|
+
setHist({ canUndo: engine.canUndo(), canRedo: engine.canRedo() });
|
|
465
|
+
// Resolve host custom-function requests: run the registered JS impl with the engine's RESOLVED
|
|
466
|
+
// args + the calling cell, then push the result (incl. `structuredValue`) back via
|
|
467
|
+
// `set-effective-value`. Everything is deferred to a microtask, so we never re-enter the engine
|
|
468
|
+
// during its own commit. While the impl is in flight the cell carries the engine's `#PENDING!`
|
|
469
|
+
// sentinel, which the accessors present as "Loading..." (see `isPendingEvalError`); the answer
|
|
470
|
+
// (or the error below) clears it.
|
|
471
|
+
// Cancelled host calls: their cell no longer holds the formula (cleared / overwritten /
|
|
472
|
+
// undone / removed with its row or column). Bump the cell's eval generation so any
|
|
473
|
+
// in-flight impl's late answer is dropped, then surface the batch to the host so it can
|
|
474
|
+
// abort external work the call started (e.g. a backend `=AOP` run).
|
|
475
|
+
if (commit.customCancels?.length) {
|
|
476
|
+
for (const c of commit.customCancels) {
|
|
477
|
+
abortEval(cellKey(c.sheetId, c.rowIndex, c.columnIndex));
|
|
478
|
+
}
|
|
479
|
+
onCustomEvalCancelRef.current?.(commit.customCancels);
|
|
480
|
+
}
|
|
481
|
+
for (const req of commit.customEvals) {
|
|
482
|
+
const impl = customFnsRef.current.get(req.name);
|
|
483
|
+
if (!impl)
|
|
484
|
+
continue;
|
|
485
|
+
const coords = {
|
|
486
|
+
rowIndex: req.rowIndex,
|
|
487
|
+
columnIndex: req.columnIndex,
|
|
488
|
+
};
|
|
489
|
+
const key = cellKey(req.sheetId, req.rowIndex, req.columnIndex);
|
|
490
|
+
// A newer eval supersedes any in-flight one for the same cell (a precedent changed
|
|
491
|
+
// mid-flight): abort it so its work stops AND its slower answer can't land after ours.
|
|
492
|
+
abortEval(key);
|
|
493
|
+
const generation = evalGenRef.current.get(key) ?? 0;
|
|
494
|
+
const controller = new AbortController();
|
|
495
|
+
evalAbortRef.current.set(key, controller);
|
|
496
|
+
const push = (effectiveValue) => {
|
|
497
|
+
// The cell's call was cancelled/superseded after this request — drop the stale answer.
|
|
498
|
+
if ((evalGenRef.current.get(key) ?? 0) !== generation)
|
|
499
|
+
return;
|
|
500
|
+
evalAbortRef.current.delete(key);
|
|
501
|
+
engine.applyCommand({
|
|
502
|
+
command: "set-effective-value",
|
|
503
|
+
sheetId: req.sheetId,
|
|
504
|
+
coords,
|
|
505
|
+
effectiveValue,
|
|
506
|
+
});
|
|
507
|
+
};
|
|
508
|
+
void Promise.resolve().then(() => {
|
|
509
|
+
const result = impl(req.args, {
|
|
510
|
+
sheetId: req.sheetId,
|
|
511
|
+
rowIndex: req.rowIndex,
|
|
512
|
+
columnIndex: req.columnIndex,
|
|
513
|
+
signal: controller.signal,
|
|
514
|
+
});
|
|
515
|
+
if (result instanceof Promise) {
|
|
516
|
+
return result
|
|
517
|
+
.then((ev) => {
|
|
518
|
+
if (ev)
|
|
519
|
+
push(ev);
|
|
520
|
+
})
|
|
521
|
+
// A rejected impl must still clear the engine's #PENDING! sentinel — land an error
|
|
522
|
+
// value (the async analog of a sync throw) instead of leaving the cell loading forever.
|
|
523
|
+
.catch(() => push({
|
|
524
|
+
ev: {
|
|
525
|
+
type: "Error",
|
|
526
|
+
name: "#ERROR!",
|
|
527
|
+
message: "Custom function failed",
|
|
528
|
+
},
|
|
529
|
+
}));
|
|
530
|
+
}
|
|
531
|
+
if (result)
|
|
532
|
+
push(result);
|
|
533
|
+
});
|
|
534
|
+
}
|
|
535
|
+
});
|
|
536
|
+
if (initial.current.initialDocument)
|
|
537
|
+
engine.load(initial.current.initialDocument);
|
|
538
|
+
// Push the host's starting locale so the first render is already localized (re-derives + streams
|
|
539
|
+
// every formatted cell). en-US is the engine default, so only a non-default locale needs this.
|
|
540
|
+
if (initial.current.locale && initial.current.locale !== "en-US")
|
|
541
|
+
engine.applyCommand({
|
|
542
|
+
command: "change-locale",
|
|
543
|
+
locale: initial.current.locale,
|
|
544
|
+
});
|
|
545
|
+
// Windowed mode: seed an initial top-left window (so the grid has something to paint before the
|
|
546
|
+
// first scroll event) and read the engine's extents. The load above already ran with `windowRef`
|
|
547
|
+
// null (so its commit folded only structurally — no 1M-cell accumulation); we now read the window.
|
|
548
|
+
if (windowedRef.current) {
|
|
549
|
+
const firstSheet = initial.current.sheets[0]?.sheetId;
|
|
550
|
+
if (firstSheet != null) {
|
|
551
|
+
windowRef.current = {
|
|
552
|
+
sheetId: firstSheet,
|
|
553
|
+
startRow: 1,
|
|
554
|
+
startCol: 1,
|
|
555
|
+
endRow: 1 + WINDOW_BUFFER_ROWS,
|
|
556
|
+
endCol: 1 + WINDOW_BUFFER_COLS,
|
|
557
|
+
};
|
|
558
|
+
const w = windowRef.current;
|
|
559
|
+
const snap = engine.readWindow(w.sheetId, w.startRow, w.startCol, w.endRow, w.endCol);
|
|
560
|
+
setState((prev) => foldWindow(prev, snap));
|
|
561
|
+
}
|
|
562
|
+
refreshExtents();
|
|
563
|
+
}
|
|
564
|
+
setReady(true);
|
|
565
|
+
});
|
|
566
|
+
return () => {
|
|
567
|
+
alive = false;
|
|
568
|
+
unsubscribe();
|
|
569
|
+
// Stop every in-flight custom-function eval — the engine is going away with the answers.
|
|
570
|
+
for (const controller of evalAbortRef.current.values()) {
|
|
571
|
+
controller.abort();
|
|
572
|
+
}
|
|
573
|
+
evalAbortRef.current.clear();
|
|
574
|
+
engineRef.current?.free();
|
|
575
|
+
engineRef.current = null;
|
|
576
|
+
};
|
|
577
|
+
}, []);
|
|
578
|
+
// Cells painted by a PREVIEW batch stream: rendered, never committed. `onChangeBatchStream`'s
|
|
579
|
+
// contract (and the legacy JS engine's) is local-render-only — the chunks show what is coming and
|
|
580
|
+
// the host persists once, after the stream completes, through a single history-on `onChangeBatch`.
|
|
581
|
+
// Committing per chunk instead would write partial results to the document, broadcast every frame
|
|
582
|
+
// to collab peers, and turn one user action into N undo entries.
|
|
583
|
+
const [streamPreview, setStreamPreview] = useState(null);
|
|
584
|
+
const streamPreviewRef = useRef(null);
|
|
585
|
+
const publishStreamPreview = useCallback(() => {
|
|
586
|
+
const next = streamPreviewRef.current;
|
|
587
|
+
setStreamPreview(next && next.size ? new Map(next) : null);
|
|
588
|
+
}, []);
|
|
589
|
+
const clearStreamPreview = useCallback(() => {
|
|
590
|
+
if (!streamPreviewRef.current?.size)
|
|
591
|
+
return;
|
|
592
|
+
streamPreviewRef.current = null;
|
|
593
|
+
setStreamPreview(null);
|
|
594
|
+
}, []);
|
|
595
|
+
const retireStreamPreviewForCommand = useCallback((command) => {
|
|
596
|
+
const preview = streamPreviewRef.current;
|
|
597
|
+
if (!preview?.size)
|
|
598
|
+
return;
|
|
599
|
+
const cmd = command;
|
|
600
|
+
if (cmd.command === "change-theme")
|
|
601
|
+
return;
|
|
602
|
+
const ranges = cmd.ranges ?? (cmd.range ? [cmd.range] : null);
|
|
603
|
+
if (!ranges?.length || cmd.sheetId == null) {
|
|
604
|
+
streamPreviewRef.current = null;
|
|
605
|
+
setStreamPreview(null);
|
|
606
|
+
return;
|
|
607
|
+
}
|
|
608
|
+
let removed = false;
|
|
609
|
+
for (const key of Array.from(preview.keys())) {
|
|
610
|
+
const parsed = parseCellKey(key);
|
|
611
|
+
if (!parsed || parsed.sheetId !== cmd.sheetId)
|
|
612
|
+
continue;
|
|
613
|
+
const covered = ranges.some((r) => parsed.rowIndex >= r.startRowIndex &&
|
|
614
|
+
parsed.rowIndex <= r.endRowIndex &&
|
|
615
|
+
parsed.columnIndex >= r.startColumnIndex &&
|
|
616
|
+
parsed.columnIndex <= r.endColumnIndex);
|
|
617
|
+
if (covered) {
|
|
618
|
+
preview.delete(key);
|
|
619
|
+
removed = true;
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
if (!removed)
|
|
623
|
+
return;
|
|
624
|
+
streamPreviewRef.current = preview.size ? preview : null;
|
|
625
|
+
setStreamPreview(preview.size ? new Map(preview) : null);
|
|
626
|
+
}, []);
|
|
627
|
+
// The committed `fv` a preview is standing on. Read through a ref so the streaming callback
|
|
628
|
+
// (created once) always sees fresh values.
|
|
629
|
+
const sheetDataRef = useRef(state.sheetData);
|
|
630
|
+
sheetDataRef.current = state.sheetData;
|
|
631
|
+
const committedFvAt = useCallback((sheetId, rowIndex, columnIndex) => sheetDataRef.current?.[sheetId]?.[rowIndex]?.values?.[columnIndex]?.fv ??
|
|
632
|
+
undefined, []);
|
|
633
|
+
const onCommand = useCallback((command) => {
|
|
634
|
+
const engine = engineRef.current;
|
|
635
|
+
if (!engine)
|
|
636
|
+
return;
|
|
637
|
+
// The host's apply (or any other local write) is the authority — retire the preview for the cells
|
|
638
|
+
// it covers in this same render, so the committed value replaces the previewed one with no blank
|
|
639
|
+
// frame in between.
|
|
640
|
+
retireStreamPreviewForCommand(command);
|
|
641
|
+
// Remember the last SINGLE formatting action so `onRepeatFormatting` can replay it onto a new
|
|
642
|
+
// target (a 2-D paint-format array is not a repeatable single action, so it's excluded).
|
|
643
|
+
if ((command.command === "change-formatting" &&
|
|
644
|
+
!Array.isArray(command.cellFormat)) ||
|
|
645
|
+
command.command === "change-border") {
|
|
646
|
+
lastFormattingRef.current = command;
|
|
647
|
+
}
|
|
648
|
+
// Snapshot the view BEFORE applying — that's where undo should return the user.
|
|
649
|
+
const before = viewRef.current;
|
|
650
|
+
engine.applyCommand(command);
|
|
651
|
+
// Only record a view snapshot if the command actually pushed a document undo entry (skips
|
|
652
|
+
// no-op-inverse commands like change-theme), keeping the two stacks aligned.
|
|
653
|
+
if (engine.undoDepth() > lastUndoDepth.current) {
|
|
654
|
+
viewUndo.current.push(before);
|
|
655
|
+
viewRedo.current = [];
|
|
656
|
+
}
|
|
657
|
+
lastUndoDepth.current = engine.undoDepth();
|
|
658
|
+
// The engine's move-range relocates cells but never re-scopes range-scoped CF/DV rules —
|
|
659
|
+
// carry the touched rules to the destination right after the move (Excel/Sheets behavior).
|
|
660
|
+
if (command.command === "move-range") {
|
|
661
|
+
for (const followUp of rescopeRulesAfterMove(engine.documentFacets(), command.sheetId, command.from, command.to)) {
|
|
662
|
+
onCommand(followUp);
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
}, [retireStreamPreviewForCommand]);
|
|
666
|
+
// "Calculate Now" (F9): force a full recalculation so volatile functions (NOW/TODAY/RAND/…) refresh.
|
|
667
|
+
// The engine re-evaluates + streams the changed cells through the normal commit (folded like any edit);
|
|
668
|
+
// it mutates no document state, so it isn't undoable. The reference's `options` (reset-graph /
|
|
669
|
+
// disable-eval) are moot here — the engine has no deferred-calc queue or graph-only mode to gate.
|
|
670
|
+
const calculateNow = useCallback(() => {
|
|
671
|
+
onCommand({ command: "recalculate" });
|
|
672
|
+
return Promise.resolve();
|
|
673
|
+
}, [onCommand]);
|
|
674
|
+
// Live formula preview — the cell editor's `onRequestCalculate`. Scratch-evaluates the
|
|
675
|
+
// in-progress formula through the engine: read-only, nothing installed or committed, no dirty
|
|
676
|
+
// marks. Error results suppress the preview (mirroring the reference wrapper's `FormulaError`
|
|
677
|
+
// handling) — which also covers a host custom-function call (`=AOP(...)`): it is not a
|
|
678
|
+
// formualizer function, so it evaluates to `#NAME?` and, critically, never enqueues a
|
|
679
|
+
// `customEvals` request — typing an =AOP formula can't fire an agent run from the preview.
|
|
680
|
+
const onRequestCalculate = useCallback(async (value, sheetId, rowIndex, columnIndex, _options) => {
|
|
681
|
+
const formula = typeof value === "string" ? value.trim() : "";
|
|
682
|
+
if (!formula.startsWith("="))
|
|
683
|
+
return undefined;
|
|
684
|
+
const ev = engineRef.current?.evalFormula(sheetId, rowIndex, columnIndex, formula);
|
|
685
|
+
if (!ev)
|
|
686
|
+
return undefined;
|
|
687
|
+
// The preview contract is stringly (the reference returns display text / a result array).
|
|
688
|
+
const nv = ev.nv ?? ev.numberValue;
|
|
689
|
+
if (nv != null)
|
|
690
|
+
return String(nv);
|
|
691
|
+
const sv = ev.sv ?? ev.stringValue;
|
|
692
|
+
if (sv != null)
|
|
693
|
+
return sv;
|
|
694
|
+
const bv = ev.bv ?? ev.boolValue;
|
|
695
|
+
if (bv != null)
|
|
696
|
+
return bv ? "TRUE" : "FALSE";
|
|
697
|
+
return undefined;
|
|
698
|
+
}, []);
|
|
699
|
+
// "Filter by color" swatches for the FilterBox — computed engine-side in ONE column scan
|
|
700
|
+
// (the JS per-row `getEffectiveFormat` scan is disabled on windowed hosts). Icon entries come
|
|
701
|
+
// back as (iconSet, iconId); the glyph + painted color resolve through the same tables the CF
|
|
702
|
+
// renderer uses, so the picker shows exactly what the grid draws.
|
|
703
|
+
const getFilterColors = useCallback((sheetId, columnIndex, range) => {
|
|
704
|
+
const res = engineRef.current?.filterColors(sheetId, columnIndex, range.startRowIndex, range.endRowIndex);
|
|
705
|
+
if (!res)
|
|
706
|
+
return undefined;
|
|
707
|
+
const icons = res.icons.map(({ iconSet, iconId }) => ({
|
|
708
|
+
iconSet,
|
|
709
|
+
iconId,
|
|
710
|
+
glyph: ICON_SET_GLYPHS[iconSet]?.[iconId] ?? "",
|
|
711
|
+
color: ICON_SET_COLORS[iconSet]?.[iconId],
|
|
712
|
+
}));
|
|
713
|
+
return {
|
|
714
|
+
availableCellColors: res.cellColors.length ? res.cellColors : undefined,
|
|
715
|
+
availableFontColors: res.fontColors.length ? res.fontColors : undefined,
|
|
716
|
+
availableIcons: icons.length ? icons : undefined,
|
|
717
|
+
};
|
|
718
|
+
}, []);
|
|
719
|
+
// Host-push of a computed effective value (custom-formula / structured result) — the Option-B
|
|
720
|
+
// `setEffectiveValue`. The engine stores it (incl. `structuredValue`), feeds the scalar to
|
|
721
|
+
// dependents, and protects it from recalc until the cell is edited.
|
|
722
|
+
const setEffectiveValue = useCallback((sheetId, coords, effectiveValue) => onCommand({
|
|
723
|
+
command: "set-effective-value",
|
|
724
|
+
sheetId,
|
|
725
|
+
coords,
|
|
726
|
+
effectiveValue,
|
|
727
|
+
}), [onCommand]);
|
|
728
|
+
// Register a host (JS) custom function. The engine recognizes `=NAME(…)` (no `#NAME?`, deps tracked)
|
|
729
|
+
// and streams a `customEvals` request the subscriber answers by running `impl` (see the effect).
|
|
730
|
+
const registerCustomFunction = useCallback((name, impl) => {
|
|
731
|
+
customFnsRef.current.set(name, impl);
|
|
732
|
+
engineRef.current?.registerCustomFunction(name);
|
|
733
|
+
}, []);
|
|
734
|
+
// Undo AND redo of an edit both return the cursor to the EDIT location — mirroring the reference's
|
|
735
|
+
// `{ activeCell: { undo: coords, redo: coords } }`. So when an entry crosses between the undo/redo
|
|
736
|
+
// stacks we move the SAME stored view, NOT the live cursor (pushing the live cursor would make redo
|
|
737
|
+
// jump to wherever you happened to be when you pressed undo, e.g. B2 instead of the edited A1).
|
|
738
|
+
const undo = useCallback(() => {
|
|
739
|
+
const engine = engineRef.current;
|
|
740
|
+
if (!engine)
|
|
741
|
+
return;
|
|
742
|
+
const prevDepth = engine.undoDepth();
|
|
743
|
+
engine.undo();
|
|
744
|
+
if (engine.undoDepth() < prevDepth) {
|
|
745
|
+
const restore = viewUndo.current.pop();
|
|
746
|
+
if (restore) {
|
|
747
|
+
viewRedo.current.push(restore);
|
|
748
|
+
applyView(restore);
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
lastUndoDepth.current = engine.undoDepth();
|
|
752
|
+
}, [applyView]);
|
|
753
|
+
const redo = useCallback(() => {
|
|
754
|
+
const engine = engineRef.current;
|
|
755
|
+
if (!engine)
|
|
756
|
+
return;
|
|
757
|
+
const prevRedoDepth = engine.redoDepth();
|
|
758
|
+
engine.redo();
|
|
759
|
+
if (engine.redoDepth() < prevRedoDepth) {
|
|
760
|
+
const restore = viewRedo.current.pop();
|
|
761
|
+
if (restore) {
|
|
762
|
+
viewUndo.current.push(restore);
|
|
763
|
+
applyView(restore);
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
lastUndoDepth.current = engine.undoDepth();
|
|
767
|
+
}, [applyView]);
|
|
768
|
+
const load = useCallback((document) => {
|
|
769
|
+
engineRef.current?.load(document);
|
|
770
|
+
}, []);
|
|
771
|
+
/**
|
|
772
|
+
* Import an `.xlsx` workbook through the native Rust engine, REPLACING the current document (sheets,
|
|
773
|
+
* cell data, facets, history) — not merging into it. Render state is cleared first so nothing from
|
|
774
|
+
* the previous workbook lingers; the engine fresh-loads and its commit re-populates state via the
|
|
775
|
+
* onChanges subscriber (non-windowed folds the full commit onto the empty state + reconciles tabs;
|
|
776
|
+
* windowed re-reads the first imported sheet's window). Returns false if the backend can't import.
|
|
777
|
+
*/
|
|
778
|
+
const importXlsx = useCallback((bytes) => {
|
|
779
|
+
const engine = engineRef.current;
|
|
780
|
+
if (!engine?.importXlsx)
|
|
781
|
+
return false;
|
|
782
|
+
// The engine REPLACES its document and returns the full commit WITHOUT notifying subscribers — we
|
|
783
|
+
// drive the render reset here. The commit's merges/row-col metadata fold only onto sheets that
|
|
784
|
+
// already exist in the base, so we seed the base from the engine's post-import sheet set first
|
|
785
|
+
// (otherwise reconcileSheets would rebuild bare sheets and drop merges/dimensions).
|
|
786
|
+
const commit = engine.importXlsx(bytes);
|
|
787
|
+
if (!commit)
|
|
788
|
+
return false;
|
|
789
|
+
const engineSheets = engine.sheets?.() ?? [];
|
|
790
|
+
const seedSheets = engineSheets.map((es) => ({
|
|
791
|
+
sheetId: es.sheetId,
|
|
792
|
+
title: es.title,
|
|
793
|
+
hidden: es.hidden,
|
|
794
|
+
tabColor: es.tabColor,
|
|
795
|
+
defaultRowHeight: es.defaultRowHeight,
|
|
796
|
+
}));
|
|
797
|
+
const firstId = engineSheets[0]?.sheetId ?? viewRef.current.activeSheetId;
|
|
798
|
+
windowRef.current = windowedRef.current
|
|
799
|
+
? {
|
|
800
|
+
sheetId: firstId,
|
|
801
|
+
startRow: 1,
|
|
802
|
+
startCol: 1,
|
|
803
|
+
endRow: 1 + WINDOW_BUFFER_ROWS,
|
|
804
|
+
endCol: 1 + WINDOW_BUFFER_COLS,
|
|
805
|
+
}
|
|
806
|
+
: null;
|
|
807
|
+
// `importXlsx`'s return is engine-specific: the in-process rnc-engine returns a full `CommitResult`
|
|
808
|
+
// we fold directly, but the yrs-store engine loads its document into the CRDT and returns only a
|
|
809
|
+
// lightweight summary (`[{sheetId,name}]`). Folding that non-commit crashed the render
|
|
810
|
+
// (`changes is not iterable`). Detect a foldable commit; otherwise read the document back from the
|
|
811
|
+
// engine via `readWindow` (the universal path both engines support).
|
|
812
|
+
const foldableCommit = typeof commit === "object" &&
|
|
813
|
+
commit !== null &&
|
|
814
|
+
Array.isArray(commit.changes);
|
|
815
|
+
const extents = engine.sheetExtents?.() ?? [];
|
|
816
|
+
setState(() => {
|
|
817
|
+
let next = emptyRenderState(seedSheets);
|
|
818
|
+
if (windowedRef.current) {
|
|
819
|
+
// Windowed: fold only structural channels (cells come from the viewport read below).
|
|
820
|
+
if (foldableCommit)
|
|
821
|
+
next = foldCommitStructuralOnly(next, commit);
|
|
822
|
+
if (windowRef.current) {
|
|
823
|
+
const w = windowRef.current;
|
|
824
|
+
next = foldWindow(next, engine.readWindow(w.sheetId, w.startRow, w.startCol, w.endRow, w.endCol));
|
|
825
|
+
}
|
|
826
|
+
}
|
|
827
|
+
else if (foldableCommit) {
|
|
828
|
+
// Non-windowed + a real commit (rnc-engine): fold every imported cell + style in one pass.
|
|
829
|
+
next = foldCommit(next, commit);
|
|
830
|
+
}
|
|
831
|
+
else {
|
|
832
|
+
// Non-windowed + a non-commit return (yrs-store summary): read each sheet's full extent from
|
|
833
|
+
// the engine to populate cells/styles/formulas (the doc already lives in the engine's CRDT).
|
|
834
|
+
for (const es of engineSheets) {
|
|
835
|
+
const extent = extents.find((e) => e[0] === es.sheetId);
|
|
836
|
+
const maxRow = extent?.[1] ?? 0;
|
|
837
|
+
const maxCol = extent?.[2] ?? 0;
|
|
838
|
+
if (maxRow >= 1 && maxCol >= 1) {
|
|
839
|
+
next = foldWindow(next, engine.readWindow(es.sheetId, 1, 1, maxRow, maxCol));
|
|
840
|
+
}
|
|
841
|
+
}
|
|
842
|
+
}
|
|
843
|
+
const sheets = reconcileSheets(next.sheets, engineSheets);
|
|
844
|
+
return sheets === next.sheets ? next : { ...next, sheets };
|
|
845
|
+
});
|
|
846
|
+
// Re-read the engine's authoritative facets / extents / history after the replace.
|
|
847
|
+
setExtents(engine.sheetExtents?.() ?? []);
|
|
848
|
+
const nextFacets = engine.documentFacetsJson();
|
|
849
|
+
setFacetsJson((prev) => (prev === nextFacets ? prev : nextFacets));
|
|
850
|
+
setHist({ canUndo: engine.canUndo(), canRedo: engine.canRedo() });
|
|
851
|
+
// The document was REPLACED: the old workbook's per-sheet cursors are meaningless on the new one
|
|
852
|
+
// (imported sheet ids typically collide with the old ids), and the view undo/redo stacks pair with
|
|
853
|
+
// engine history that no longer exists. Reset all of it; the import opens at the first sheet's A1.
|
|
854
|
+
activeCellBySheetIdRef.current = {};
|
|
855
|
+
selectionsBySheetIdRef.current = {};
|
|
856
|
+
setActiveCellBySheetId({});
|
|
857
|
+
setSelectionsBySheetId({});
|
|
858
|
+
viewUndo.current = [];
|
|
859
|
+
viewRedo.current = [];
|
|
860
|
+
lastUndoDepth.current = engine.undoDepth();
|
|
861
|
+
viewRef.current = {
|
|
862
|
+
activeSheetId: firstId,
|
|
863
|
+
activeCell: DEFAULT_ACTIVE_CELL,
|
|
864
|
+
selections: DEFAULT_ARRAY,
|
|
865
|
+
};
|
|
866
|
+
setActiveSheetId(firstId);
|
|
867
|
+
return true;
|
|
868
|
+
}, []);
|
|
869
|
+
// WINDOWED reads. Wire to CanvasGrid's `onViewPortChange`: read the visible range (+ a buffer) from
|
|
870
|
+
// the engine and replace the in-JS window (evicting cells outside it, so memory stays O(viewport)).
|
|
871
|
+
// A no-op in the default mode (every cell is already folded). Reads the active sheet from `viewRef`
|
|
872
|
+
// (live, no stale closure) so it stays a stable callback.
|
|
873
|
+
const onViewPortChange = useCallback((viewport) => {
|
|
874
|
+
if (!windowedRef.current)
|
|
875
|
+
return;
|
|
876
|
+
const engine = engineRef.current;
|
|
877
|
+
if (!engine)
|
|
878
|
+
return;
|
|
879
|
+
// Remember the visible span so a sheet switch (which doesn't re-fire this) can re-seed to it.
|
|
880
|
+
lastViewportRef.current = viewport;
|
|
881
|
+
const sheetId = viewRef.current.activeSheetId;
|
|
882
|
+
// Skip the re-read while the visible range is still comfortably INSIDE the loaded window. The window
|
|
883
|
+
// already extends a full buffer past the prior viewport, so scrolling within it needs no engine read
|
|
884
|
+
// (the cells are in state) — doing one on every tick is what made windowed scrolling jitter. We only
|
|
885
|
+
// re-read when the viewport gets within the refresh margin of an edge we could still scroll past (a
|
|
886
|
+
// window edge sitting at 1 / `Math.max` clamp can't extend further, so it never forces a re-read).
|
|
887
|
+
const loaded = windowRef.current;
|
|
888
|
+
if (loaded &&
|
|
889
|
+
loaded.sheetId === sheetId &&
|
|
890
|
+
(loaded.startRow <= 1 ||
|
|
891
|
+
viewport.rowStartIndex >=
|
|
892
|
+
loaded.startRow + WINDOW_REFRESH_MARGIN_ROWS) &&
|
|
893
|
+
viewport.rowStopIndex <= loaded.endRow - WINDOW_REFRESH_MARGIN_ROWS &&
|
|
894
|
+
(loaded.startCol <= 1 ||
|
|
895
|
+
viewport.columnStartIndex >=
|
|
896
|
+
loaded.startCol + WINDOW_REFRESH_MARGIN_COLS) &&
|
|
897
|
+
viewport.columnStopIndex <= loaded.endCol - WINDOW_REFRESH_MARGIN_COLS) {
|
|
898
|
+
return;
|
|
899
|
+
}
|
|
900
|
+
// CanvasGrid reports 0-indexed buffered bounds; the engine window is 1-indexed inclusive. Clamp the
|
|
901
|
+
// low edge to 1 and pad both edges by the buffer so a small scroll reveals already-loaded cells.
|
|
902
|
+
const startRow = Math.max(1, viewport.rowStartIndex - WINDOW_BUFFER_ROWS);
|
|
903
|
+
const startCol = Math.max(1, viewport.columnStartIndex - WINDOW_BUFFER_COLS);
|
|
904
|
+
const endRow = Math.max(startRow, viewport.rowStopIndex + WINDOW_BUFFER_ROWS);
|
|
905
|
+
const endCol = Math.max(startCol, viewport.columnStopIndex + WINDOW_BUFFER_COLS);
|
|
906
|
+
const w = { sheetId, startRow, startCol, endRow, endCol };
|
|
907
|
+
windowRef.current = w;
|
|
908
|
+
const snaps = readWindowedSnapshots(engine, w);
|
|
909
|
+
setState((prev) => foldWindows(prev, snaps));
|
|
910
|
+
}, [readWindowedSnapshots]);
|
|
911
|
+
const onChangeActiveSheet = useCallback((sheetId) => {
|
|
912
|
+
// An explicit pick (user tab click, host navigation) permanently disables the post-hydration
|
|
913
|
+
// initial-active-sheet re-derivation — never yank the user off a sheet they chose.
|
|
914
|
+
userPickedSheetRef.current = true;
|
|
915
|
+
// Restore the target sheet's remembered cursor/selections (per-sheet view state, like the JS
|
|
916
|
+
// engine's `useSheetState`) so callbacks reading `viewRef` see the restored view immediately.
|
|
917
|
+
viewRef.current = viewOfSheet(sheetId);
|
|
918
|
+
setActiveSheetId(sheetId);
|
|
919
|
+
// Windowed: re-seed the window on the newly-active sheet (the old window pointed at the prior
|
|
920
|
+
// sheet). Switching sheets keeps the scroll position, so CanvasGrid does NOT re-fire
|
|
921
|
+
// `onViewPortChange` — re-seed to the SAME span the user is currently viewing (the last reported
|
|
922
|
+
// viewport) so the whole visible grid loads, not just a top-left A–F placeholder. Fall back to the
|
|
923
|
+
// top-left default only before the first viewport report.
|
|
924
|
+
if (windowedRef.current && engineRef.current) {
|
|
925
|
+
const vp = lastViewportRef.current;
|
|
926
|
+
const w = vp
|
|
927
|
+
? {
|
|
928
|
+
sheetId,
|
|
929
|
+
startRow: Math.max(1, vp.rowStartIndex - WINDOW_BUFFER_ROWS),
|
|
930
|
+
startCol: Math.max(1, vp.columnStartIndex - WINDOW_BUFFER_COLS),
|
|
931
|
+
endRow: Math.max(1, vp.rowStopIndex) + WINDOW_BUFFER_ROWS,
|
|
932
|
+
endCol: Math.max(1, vp.columnStopIndex) + WINDOW_BUFFER_COLS,
|
|
933
|
+
}
|
|
934
|
+
: {
|
|
935
|
+
sheetId,
|
|
936
|
+
startRow: 1,
|
|
937
|
+
startCol: 1,
|
|
938
|
+
endRow: 1 + WINDOW_BUFFER_ROWS,
|
|
939
|
+
endCol: 1 + WINDOW_BUFFER_COLS,
|
|
940
|
+
};
|
|
941
|
+
windowRef.current = w;
|
|
942
|
+
const snaps = readWindowedSnapshots(engineRef.current, w);
|
|
943
|
+
setState((prev) => foldWindows(prev, snaps));
|
|
944
|
+
}
|
|
945
|
+
}, [readWindowedSnapshots]);
|
|
946
|
+
const onChangeActiveCell = useCallback((sheetId, c) => {
|
|
947
|
+
const next = { ...activeCellBySheetIdRef.current, [sheetId]: c };
|
|
948
|
+
activeCellBySheetIdRef.current = next;
|
|
949
|
+
if (sheetId === viewRef.current.activeSheetId)
|
|
950
|
+
viewRef.current = { ...viewRef.current, activeCell: c };
|
|
951
|
+
setActiveCellBySheetId(next);
|
|
952
|
+
}, []);
|
|
953
|
+
const onChangeSelections = useCallback((sheetId, sel) => {
|
|
954
|
+
const next = { ...selectionsBySheetIdRef.current, [sheetId]: sel };
|
|
955
|
+
selectionsBySheetIdRef.current = next;
|
|
956
|
+
if (sheetId === viewRef.current.activeSheetId)
|
|
957
|
+
viewRef.current = { ...viewRef.current, selections: sel };
|
|
958
|
+
setSelectionsBySheetId(next);
|
|
959
|
+
}, []);
|
|
960
|
+
const { sheetData, cellStyles, derivedFormats, notes } = state;
|
|
961
|
+
const getCellData = useCallback((sheetId, rowIndex, columnIndex) => {
|
|
962
|
+
const committed = sheetData?.[sheetId]?.[rowIndex]?.values?.[columnIndex] ?? undefined;
|
|
963
|
+
// A previewing stream shadows the committed cell — until the real value lands, which is any
|
|
964
|
+
// change to what was committed when the preview was painted (`basis`). That covers a local
|
|
965
|
+
// apply, a backend tool's authoritative write, and a collab peer's edit alike.
|
|
966
|
+
const preview = streamPreview?.get(cellKey(sheetId, rowIndex, columnIndex));
|
|
967
|
+
if (preview && committed?.fv === preview.basis)
|
|
968
|
+
return preview.data;
|
|
969
|
+
return committed;
|
|
970
|
+
}, [sheetData, streamPreview]);
|
|
971
|
+
// Stable-identity cache for the user⊕derived merge: a 2-level `WeakMap<userFmt, WeakMap<derivedFmt,
|
|
972
|
+
// merged>>`, so the SAME merged object is returned across renders unless an input actually changes.
|
|
973
|
+
// Mirrors `use-sheet-properties`'s `getEffectiveFormat`, keeping the grid from re-rendering on
|
|
974
|
+
// identity churn. GC-friendly: keyed on the format objects themselves (no manual eviction).
|
|
975
|
+
const mergedFormatCache = useRef(new WeakMap());
|
|
976
|
+
const getEffectiveFormat = useCallback((sheetId, rowIndex, columnIndex) => {
|
|
977
|
+
const key = cellKey(sheetId, rowIndex, columnIndex);
|
|
978
|
+
// The cell's OWN consolidated style. Conditional formatting is NOT layered here — CanvasGrid
|
|
979
|
+
// computes it from the rules (`useConditionalFormatting`); CF overlays don't ride on the format.
|
|
980
|
+
const own = cellStyles.get(key);
|
|
981
|
+
// A previewing stream's formatting wins over the committed style, so the preview shows what the
|
|
982
|
+
// apply will look like — under the same "until the real value lands" rule `getCellData` uses.
|
|
983
|
+
const previewEntry = streamPreview?.get(key);
|
|
984
|
+
if (previewEntry?.format &&
|
|
985
|
+
(sheetData?.[sheetId]?.[rowIndex]?.values?.[columnIndex]?.fv ??
|
|
986
|
+
undefined) === previewEntry.basis)
|
|
987
|
+
return own ? { ...own, ...previewEntry.format } : previewEntry.format;
|
|
988
|
+
// The engine-derived inherited format (`=SUM($a,$b)` → currency), layered UNDER the user's own
|
|
989
|
+
// style — exactly as the JS reference's `getDerivedFormat`. The engine suppresses it whenever the
|
|
990
|
+
// cell has its own number format, so the user's choice always wins on conflict.
|
|
991
|
+
const derived = derivedFormats.get(key);
|
|
992
|
+
if (!derived)
|
|
993
|
+
return own;
|
|
994
|
+
if (!own)
|
|
995
|
+
return derived;
|
|
996
|
+
let inner = mergedFormatCache.current.get(own);
|
|
997
|
+
if (!inner) {
|
|
998
|
+
inner = new WeakMap();
|
|
999
|
+
mergedFormatCache.current.set(own, inner);
|
|
1000
|
+
}
|
|
1001
|
+
let merged = inner.get(derived);
|
|
1002
|
+
if (!merged) {
|
|
1003
|
+
merged = { ...derived, ...own };
|
|
1004
|
+
inner.set(derived, merged);
|
|
1005
|
+
}
|
|
1006
|
+
return merged;
|
|
1007
|
+
}, [cellStyles, derivedFormats, streamPreview, sheetData]);
|
|
1008
|
+
const getNote = useCallback((sheetId, rowIndex, columnIndex) => notes.get(cellKey(sheetId, rowIndex, columnIndex)), [notes]);
|
|
1009
|
+
const getFormattedValue = useCallback((sheetId, rowIndex, columnIndex) => {
|
|
1010
|
+
const cell = getCellData(sheetId, rowIndex, columnIndex);
|
|
1011
|
+
// A host-call cell awaiting its answer displays the loading placeholder, not the engine's
|
|
1012
|
+
// `#PENDING!` sentinel text (the JS engine's transient `fv = "Loading..."` analog).
|
|
1013
|
+
if (isPendingEvalError(cell?.ev))
|
|
1014
|
+
return LOADING_FORMATTED_VALUE;
|
|
1015
|
+
// A structured result's display text lives in the payload, NOT the engine-derived `fv`
|
|
1016
|
+
// (which is empty for a structured-only effective value). Mirrors useSheetProperties'
|
|
1017
|
+
// `getFormattedValue` structured branch, so chips (=AOP queued/running/answered states,
|
|
1018
|
+
// hyperlink results) report the text they display — without this, consumers keyed on the
|
|
1019
|
+
// formatted value treat the cell as EMPTY (e.g. a long neighbour's text overflows over a
|
|
1020
|
+
// queued =AOP chip).
|
|
1021
|
+
const structured = cell?.ev?.structuredValue;
|
|
1022
|
+
if (structured) {
|
|
1023
|
+
switch (structured.kind) {
|
|
1024
|
+
case "hyperlink":
|
|
1025
|
+
return (structured.title ??
|
|
1026
|
+
structured.url);
|
|
1027
|
+
case "image":
|
|
1028
|
+
return "";
|
|
1029
|
+
default:
|
|
1030
|
+
return (structured.formattedValue ??
|
|
1031
|
+
cell?.fv ??
|
|
1032
|
+
undefined);
|
|
1033
|
+
}
|
|
1034
|
+
}
|
|
1035
|
+
// Excel display parity: a user-entered lone text-prefix apostrophe (`'`)
|
|
1036
|
+
// renders as a blank cell — the `'` stays visible only in the formula
|
|
1037
|
+
// bar / editor (`ue`). Guarding the read side also heals documents whose
|
|
1038
|
+
// persisted `fv` was stamped before this rule existed.
|
|
1039
|
+
if (cell?.ue?.sv === "'") {
|
|
1040
|
+
return "";
|
|
1041
|
+
}
|
|
1042
|
+
return cell?.fv ?? undefined;
|
|
1043
|
+
}, [getCellData]);
|
|
1044
|
+
// The cell's effective value as a SCALAR (bool ?? number ?? string) — the shape `<CanvasGrid>` and
|
|
1045
|
+
// chart series want. Mirrors useSheetProperties' `getEffectiveValue`. Errors → undefined.
|
|
1046
|
+
const getEffectiveValue = useCallback((sheetId, rowIndex, columnIndex) => {
|
|
1047
|
+
const ev = getCellData(sheetId, rowIndex, columnIndex)?.ev;
|
|
1048
|
+
if (!ev)
|
|
1049
|
+
return undefined;
|
|
1050
|
+
return ev.bv ?? ev.nv ?? ev.sv ?? undefined;
|
|
1051
|
+
}, [getCellData]);
|
|
1052
|
+
// The cell's effective value as the full extended-value OBJECT (`{nv,sv,bv,…}`) — the
|
|
1053
|
+
// useSheetProperties `getEffectiveExtendedValue` (api / backwards-compat consumers).
|
|
1054
|
+
// The `#PENDING!` sentinel is stripped: CanvasGrid derives its red error corner-marker from
|
|
1055
|
+
// THIS accessor (`getExtendedValueError(getEffectiveExtendedValue(...))`, not the hook's
|
|
1056
|
+
// `getErrorValue`), so leaking the pending eval error paints every in-flight async formula —
|
|
1057
|
+
// and its dependents — as errored. A pending ev carries no scalar value, so omitting the
|
|
1058
|
+
// error facet loses nothing for the other consumers.
|
|
1059
|
+
// Cache the ev synthesized for an invalid cell (keyed on the folded cell object, which is
|
|
1060
|
+
// replaced whenever its value or verdict changes) so repeated reads keep a stable identity.
|
|
1061
|
+
const invalidEvCache = useRef(new WeakMap());
|
|
1062
|
+
const getEffectiveExtendedValue = useCallback((sheetId, rowIndex, columnIndex) => {
|
|
1063
|
+
const cell = getCellData(sheetId, rowIndex, columnIndex);
|
|
1064
|
+
const ev = cell?.ev;
|
|
1065
|
+
if (ev && isPendingEvalError(ev)) {
|
|
1066
|
+
const { ev: _pendingError, ...rest } = ev;
|
|
1067
|
+
return rest;
|
|
1068
|
+
}
|
|
1069
|
+
// The engine's data-validation verdict rides `dataValidationResult` (`false` = violates its
|
|
1070
|
+
// rule), not `ev.ev` — bridge it into the error facet so the grid draws the same Invalid
|
|
1071
|
+
// corner + popover the JS host stamps via `SheetCell.validate()`.
|
|
1072
|
+
if (cell && cell.dataValidationResult === false && !ev?.ev) {
|
|
1073
|
+
let merged = invalidEvCache.current.get(cell);
|
|
1074
|
+
if (!merged) {
|
|
1075
|
+
merged = { ...(ev ?? {}), ev: DATA_VALIDATION_INVALID_ERROR };
|
|
1076
|
+
invalidEvCache.current.set(cell, merged);
|
|
1077
|
+
}
|
|
1078
|
+
return merged;
|
|
1079
|
+
}
|
|
1080
|
+
return ev;
|
|
1081
|
+
}, [getCellData]);
|
|
1082
|
+
const getUserEnteredValue = useCallback((sheetId, rowIndex, columnIndex) => {
|
|
1083
|
+
const ue = getCellData(sheetId, rowIndex, columnIndex)?.ue;
|
|
1084
|
+
if (!ue)
|
|
1085
|
+
return undefined;
|
|
1086
|
+
if (ue.fv != null)
|
|
1087
|
+
return ue.fv; // a formula (`=SUM(...)`)
|
|
1088
|
+
if (ue.nv != null)
|
|
1089
|
+
return String(ue.nv);
|
|
1090
|
+
if (ue.bv != null)
|
|
1091
|
+
return ue.bv ? "TRUE" : "FALSE";
|
|
1092
|
+
return ue.sv ?? undefined;
|
|
1093
|
+
}, [getCellData]);
|
|
1094
|
+
const getHyperlink = useCallback((sheetId, rowIndex, columnIndex) => getCellData(sheetId, rowIndex, columnIndex)?.hyperlink, [getCellData]);
|
|
1095
|
+
// ── Additional cell accessors (mirror useSheetProperties / useSpreadsheetState, typed via SS) ──
|
|
1096
|
+
// Rich-text runs ride on the cell (`textFormatRuns`, emitted by the engine from the imported
|
|
1097
|
+
// rich shared string). Surface them so the grid renders mixed in-cell formatting.
|
|
1098
|
+
const getTextFormatRuns = useCallback((sheetId, rowIndex, columnIndex) => getCellData(sheetId, rowIndex, columnIndex)?.textFormatRuns, [getCellData]);
|
|
1099
|
+
const getUserEnteredExtendedValue = useCallback((sheetId, rowIndex, columnIndex) => getCellData(sheetId, rowIndex, columnIndex)?.ue, [getCellData]);
|
|
1100
|
+
const getUserEnteredFormat = useCallback((sheetId, rowIndex, columnIndex) => cellStyles.get(cellKey(sheetId, rowIndex, columnIndex)), [cellStyles]);
|
|
1101
|
+
const getErrorValue = useCallback((sheetId, rowIndex, columnIndex) => {
|
|
1102
|
+
const cell = getCellData(sheetId, rowIndex, columnIndex);
|
|
1103
|
+
const ev = cell?.ev;
|
|
1104
|
+
// The `#PENDING!` sentinel is a WAIT state, not a failure — presenting it through the error
|
|
1105
|
+
// channel would draw the red corner + error popover on every in-flight async formula. The
|
|
1106
|
+
// cell displays "Loading..." via `getFormattedValue` instead.
|
|
1107
|
+
if (isPendingEvalError(ev))
|
|
1108
|
+
return undefined;
|
|
1109
|
+
if (ev?.ev)
|
|
1110
|
+
return ev.ev;
|
|
1111
|
+
// The engine's data-validation verdict rides `dataValidationResult` (`false` = violates its
|
|
1112
|
+
// rule), NOT `ev.ev` — bridge it here, matching `getEffectiveExtendedValue`, so both error
|
|
1113
|
+
// channels agree with the Invalid marker the JS host stamps via `SheetCell.validate()`.
|
|
1114
|
+
if (cell?.dataValidationResult === false) {
|
|
1115
|
+
return DATA_VALIDATION_INVALID_ERROR;
|
|
1116
|
+
}
|
|
1117
|
+
return undefined;
|
|
1118
|
+
}, [getCellData]);
|
|
1119
|
+
// Mentions ride on the cell's rich-text runs — the mention-typed entries of `textFormatRuns`
|
|
1120
|
+
// (the reference filters its shared-strings runs the same way).
|
|
1121
|
+
const getMentionsFromCell = useCallback((sheetId, rowIndex, columnIndex) => getTextFormatRuns(sheetId, rowIndex, columnIndex)?.filter((run) => run.nodeType === "mention"), [getTextFormatRuns]);
|
|
1122
|
+
const { sheets } = state;
|
|
1123
|
+
// Memoized sheet lookups — the `useSheetProperties` pattern (`sheetMetaDataById` /
|
|
1124
|
+
// `sheetPropertiesById`): build maps ONCE per sheet-list change so every accessor is O(1), instead
|
|
1125
|
+
// of an O(n) `sheets.find()` on each call (which the grid fires per visible cell, per render).
|
|
1126
|
+
const { sheetById, sheetIndexById, sheetIdByName } = useMemo(() => {
|
|
1127
|
+
const byId = new Map();
|
|
1128
|
+
const indexById = new Map();
|
|
1129
|
+
const idByName = new Map();
|
|
1130
|
+
sheets.forEach((s, i) => {
|
|
1131
|
+
byId.set(s.sheetId, s);
|
|
1132
|
+
indexById.set(s.sheetId, i);
|
|
1133
|
+
if (s.title != null)
|
|
1134
|
+
idByName.set(String(s.title), s.sheetId);
|
|
1135
|
+
});
|
|
1136
|
+
return {
|
|
1137
|
+
sheetById: byId,
|
|
1138
|
+
sheetIndexById: indexById,
|
|
1139
|
+
sheetIdByName: idByName,
|
|
1140
|
+
};
|
|
1141
|
+
}, [sheets]);
|
|
1142
|
+
// Engine data extent per sheet `sheetId → {rows, cols}` (windowed mode). The grid's scroll extent
|
|
1143
|
+
// takes the MAX of the seeded sheet count and the engine's data extent — so the sheet scrolls past
|
|
1144
|
+
// the loaded window (the whole point) without ever shrinking below the declared grid size.
|
|
1145
|
+
const extentById = useMemo(() => {
|
|
1146
|
+
const m = new Map();
|
|
1147
|
+
for (const [sheetId, rows, cols] of extents)
|
|
1148
|
+
m.set(sheetId, { rows, cols });
|
|
1149
|
+
return m;
|
|
1150
|
+
}, [extents]);
|
|
1151
|
+
const dimMeta = useCallback((sheetId, axis, index) => {
|
|
1152
|
+
const meta = sheetById.get(sheetId)?.[axis];
|
|
1153
|
+
return meta?.[index];
|
|
1154
|
+
}, [sheetById]);
|
|
1155
|
+
const getRowHeight = useCallback((sheetId, rowIndex) => dimMeta(sheetId, "rowMetadata", rowIndex)?.size, [dimMeta]);
|
|
1156
|
+
const getColumnWidth = useCallback((sheetId, columnIndex) => dimMeta(sheetId, "columnMetadata", columnIndex)?.size, [dimMeta]);
|
|
1157
|
+
const isHiddenRow = useCallback((sheetId, rowIndex) => {
|
|
1158
|
+
const m = dimMeta(sheetId, "rowMetadata", rowIndex);
|
|
1159
|
+
return Boolean(m?.hiddenByUser || m?.hiddenByFilter || m?.hiddenByGroup);
|
|
1160
|
+
}, [dimMeta]);
|
|
1161
|
+
const isHiddenColumn = useCallback((sheetId, columnIndex) => {
|
|
1162
|
+
const m = dimMeta(sheetId, "columnMetadata", columnIndex);
|
|
1163
|
+
return Boolean(m?.hiddenByUser || m?.hiddenByFilter || m?.hiddenByGroup);
|
|
1164
|
+
}, [dimMeta]);
|
|
1165
|
+
// Return types mirror CanvasGridProps exactly (string / number, not `… | undefined`) so the hook is
|
|
1166
|
+
// a cast-free drop-in. A missing sheet falls back to the grid defaults.
|
|
1167
|
+
const getSheetName = useCallback((sheetId) => sheetById.get(sheetId)?.title ?? "", [sheetById]);
|
|
1168
|
+
const getSheetCount = useCallback(() => sheets.length, [sheets]);
|
|
1169
|
+
const getSheetIndex = useCallback((sheetId) => sheetIndexById.get(sheetId) ?? -1, [sheetIndexById]);
|
|
1170
|
+
const getSheetId = useCallback((title) => sheetIdByName.get(title), [sheetIdByName]);
|
|
1171
|
+
// The sheet's grid extent. In windowed mode it's the MAX of the seeded count and the engine's data
|
|
1172
|
+
// extent, so the grid scrolls the full sheet even though JS holds only the window.
|
|
1173
|
+
const getSheetRowCount = useCallback((sheetId) => Math.max(sheetById.get(sheetId)?.rowCount ?? 1000, extentById.get(sheetId)?.rows ?? 0), [sheetById, extentById]);
|
|
1174
|
+
const getSheetColumnCount = useCallback((sheetId) => Math.max(sheetById.get(sheetId)?.columnCount ?? 26, extentById.get(sheetId)?.cols ?? 0), [sheetById, extentById]);
|
|
1175
|
+
// Rows that actually carry data (the grid uses this to bound auto-fill / navigation). `sheetId` is
|
|
1176
|
+
// optional to match the grid's `getDataRowCount?(sheetId?: number)`. In windowed mode the folded
|
|
1177
|
+
// `sheetData` is only the viewport, so fall back to the engine's data extent (the true data height).
|
|
1178
|
+
const getDataRowCount = useCallback((sheetId) => {
|
|
1179
|
+
if (windowedRef.current && sheetId != null) {
|
|
1180
|
+
const ext = extentById.get(sheetId)?.rows;
|
|
1181
|
+
if (ext != null)
|
|
1182
|
+
return ext;
|
|
1183
|
+
}
|
|
1184
|
+
return sheetData[String(sheetId)]?.length;
|
|
1185
|
+
}, [sheetData, extentById]);
|
|
1186
|
+
const getDataColumnCount = useCallback((sheetId) => {
|
|
1187
|
+
const rows = sheetData[String(sheetId)];
|
|
1188
|
+
let max = 0;
|
|
1189
|
+
for (const row of rows ?? [])
|
|
1190
|
+
max = Math.max(max, row?.values?.length ?? 0);
|
|
1191
|
+
return max;
|
|
1192
|
+
}, [sheetData]);
|
|
1193
|
+
const getNonEmptyRowCount = useCallback((sheetId) => sheetData[String(sheetId)]?.length, [sheetData]);
|
|
1194
|
+
const getNonEmptyColumnCount = useCallback((sheetId, rowIndex) => {
|
|
1195
|
+
const rows = sheetData[String(sheetId)];
|
|
1196
|
+
return rows?.[rowIndex]?.values?.length;
|
|
1197
|
+
}, [sheetData]);
|
|
1198
|
+
// ── Range reads (charts / pivots) ──
|
|
1199
|
+
// CRITICAL: a chart/pivot source range can extend FAR past the rendered window, so these read the
|
|
1200
|
+
// FULL document from the engine via `readWindow` (which accepts any rectangle) — NOT the hook's
|
|
1201
|
+
// folded `sheetData`, which only holds the visible viewport. `readWindow` is a synchronous in-process
|
|
1202
|
+
// WASM call today, so these stay sync (the chart/pivot consumer contract requires sync). They will
|
|
1203
|
+
// need to become async only when the engine moves off the main thread (Web Worker) or behind a
|
|
1204
|
+
// remote data source — at which point those consumers' contracts change too (queue-until-loaded).
|
|
1205
|
+
// The `sheetData` dep is the reactivity signal: a new fold ⇒ new accessor identity ⇒ charts re-read.
|
|
1206
|
+
/** Off-window search: row-major coordinates of every cell on `sheetId` matching `query`. Backed by
|
|
1207
|
+
* the engine (`RncEngine.findAll`), so it finds matches OUTSIDE the loaded viewport — which the JS
|
|
1208
|
+
* `useSearch` (it only sees loaded cells) cannot. Wire this as `useSearch`'s `findMatches` so search,
|
|
1209
|
+
* next/prev, counter, and highlighting all work over the full windowed document. */
|
|
1210
|
+
const findAll = useCallback((sheetId, query, options) => engineRef.current?.findAll(sheetId, query, options) ?? [], []);
|
|
1211
|
+
const getSeriesValuesFromRange = useCallback((sheetRange, hiddenDimensionStrategy = "SKIP_HIDDEN_ROWS_AND_COLUMNS") => {
|
|
1212
|
+
const engine = engineRef.current;
|
|
1213
|
+
if (!engine)
|
|
1214
|
+
return [];
|
|
1215
|
+
const { sheetId, startRowIndex, startColumnIndex, endRowIndex, endColumnIndex, } = sheetRange;
|
|
1216
|
+
const snap = engine.readWindow(sheetId, startRowIndex, startColumnIndex, endRowIndex, endColumnIndex);
|
|
1217
|
+
// `readWindow` returns only the non-empty cells, sparsely — index them, then walk the full range
|
|
1218
|
+
// in row-major order so absent cells surface as `undefined` (matching the reference accessor).
|
|
1219
|
+
const byKey = new Map();
|
|
1220
|
+
for (const c of snap.cells)
|
|
1221
|
+
byKey.set(`${c.rowIndex},${c.columnIndex}`, c.number ?? (c.formatted || undefined));
|
|
1222
|
+
const values = [];
|
|
1223
|
+
const { skipRows, skipCols } = hiddenStrategyAxes(hiddenDimensionStrategy);
|
|
1224
|
+
for (let rowIndex = startRowIndex; rowIndex <= endRowIndex; rowIndex++) {
|
|
1225
|
+
for (let columnIndex = startColumnIndex; columnIndex <= endColumnIndex; columnIndex++) {
|
|
1226
|
+
if ((skipRows && isHiddenRow(sheetId, rowIndex)) ||
|
|
1227
|
+
(skipCols && isHiddenColumn(sheetId, columnIndex)))
|
|
1228
|
+
continue;
|
|
1229
|
+
values.push(byKey.get(`${rowIndex},${columnIndex}`));
|
|
1230
|
+
}
|
|
1231
|
+
}
|
|
1232
|
+
return values;
|
|
1233
|
+
},
|
|
1234
|
+
// `sheetData` is an intentional reactivity signal: the body reads the engine (not the fold), so a
|
|
1235
|
+
// new fold ⇒ new accessor identity ⇒ chart consumers re-read. See the block comment above.
|
|
1236
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
1237
|
+
[sheetData, isHiddenRow, isHiddenColumn]);
|
|
1238
|
+
const getColumnarDataFromRange = useCallback((sheetRange) => {
|
|
1239
|
+
const columnar = {};
|
|
1240
|
+
const engine = engineRef.current;
|
|
1241
|
+
if (!engine)
|
|
1242
|
+
return columnar;
|
|
1243
|
+
const { sheetId, startRowIndex, startColumnIndex, endRowIndex, endColumnIndex, } = sheetRange;
|
|
1244
|
+
const snap = engine.readWindow(sheetId, startRowIndex, startColumnIndex, endRowIndex, endColumnIndex);
|
|
1245
|
+
const byKey = new Map();
|
|
1246
|
+
for (const c of snap.cells)
|
|
1247
|
+
byKey.set(`${c.rowIndex},${c.columnIndex}`, c);
|
|
1248
|
+
// First row = the column headers; each column below it becomes `columnar[header]`.
|
|
1249
|
+
for (let columnIndex = startColumnIndex; columnIndex <= endColumnIndex; columnIndex++) {
|
|
1250
|
+
const header = byKey.get(`${startRowIndex},${columnIndex}`)?.formatted;
|
|
1251
|
+
if (header == null)
|
|
1252
|
+
continue;
|
|
1253
|
+
const col = [];
|
|
1254
|
+
for (let rowIndex = startRowIndex + 1; rowIndex <= endRowIndex; rowIndex++) {
|
|
1255
|
+
const cell = byKey.get(`${rowIndex},${columnIndex}`);
|
|
1256
|
+
col.push(cell ? cell.number ?? cell.formatted : "");
|
|
1257
|
+
}
|
|
1258
|
+
columnar[header] = col;
|
|
1259
|
+
}
|
|
1260
|
+
return columnar;
|
|
1261
|
+
},
|
|
1262
|
+
// `sheetData` is an intentional reactivity signal (see getSeriesValuesFromRange).
|
|
1263
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
1264
|
+
[sheetData]);
|
|
1265
|
+
// The FORMATTED display values of a range, in row-major order — chart DOMAIN (axis labels). Same
|
|
1266
|
+
// engine-sourced (off-window-safe) walk as getSeriesValuesFromRange, but uses `cell.formatted`.
|
|
1267
|
+
const getDomainValuesFromRange = useCallback((sheetRange, hiddenDimensionStrategy = "SKIP_HIDDEN_ROWS_AND_COLUMNS") => {
|
|
1268
|
+
const engine = engineRef.current;
|
|
1269
|
+
if (!engine)
|
|
1270
|
+
return [];
|
|
1271
|
+
const { sheetId, startRowIndex, startColumnIndex, endRowIndex, endColumnIndex, } = sheetRange;
|
|
1272
|
+
const snap = engine.readWindow(sheetId, startRowIndex, startColumnIndex, endRowIndex, endColumnIndex);
|
|
1273
|
+
const byKey = new Map();
|
|
1274
|
+
for (const c of snap.cells)
|
|
1275
|
+
byKey.set(`${c.rowIndex},${c.columnIndex}`, c.formatted || undefined);
|
|
1276
|
+
const values = [];
|
|
1277
|
+
const { skipRows, skipCols } = hiddenStrategyAxes(hiddenDimensionStrategy);
|
|
1278
|
+
for (let rowIndex = startRowIndex; rowIndex <= endRowIndex; rowIndex++) {
|
|
1279
|
+
for (let columnIndex = startColumnIndex; columnIndex <= endColumnIndex; columnIndex++) {
|
|
1280
|
+
if ((skipRows && isHiddenRow(sheetId, rowIndex)) ||
|
|
1281
|
+
(skipCols && isHiddenColumn(sheetId, columnIndex)))
|
|
1282
|
+
continue;
|
|
1283
|
+
values.push(byKey.get(`${rowIndex},${columnIndex}`));
|
|
1284
|
+
}
|
|
1285
|
+
}
|
|
1286
|
+
return values;
|
|
1287
|
+
},
|
|
1288
|
+
// `sheetData` is an intentional reactivity signal (see getSeriesValuesFromRange).
|
|
1289
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
1290
|
+
[sheetData, isHiddenRow, isHiddenColumn]);
|
|
1291
|
+
// Range readers — a row-major walk over the folded cells, surfacing per cell the user-entered value,
|
|
1292
|
+
// the formula source, or the effective format. `useSpreadsheetState` exposes these off its JS
|
|
1293
|
+
// calculator; here they reuse the engine-backed accessors. The `HIDDEN_DIMENSION_STRATEGY` picks
|
|
1294
|
+
// which hidden axes are omitted (rows, columns, both — or none for `SHOW_ALL`).
|
|
1295
|
+
const getUserEnteredValuesFromRange = useCallback((sheetRange, strategy) => {
|
|
1296
|
+
const { sheetId, startRowIndex, startColumnIndex, endRowIndex, endColumnIndex, } = sheetRange;
|
|
1297
|
+
const { skipRows, skipCols } = hiddenStrategyAxes(strategy);
|
|
1298
|
+
const out = [];
|
|
1299
|
+
for (let r = startRowIndex; r <= endRowIndex; r++)
|
|
1300
|
+
for (let c = startColumnIndex; c <= endColumnIndex; c++) {
|
|
1301
|
+
if ((skipRows && isHiddenRow(sheetId, r)) ||
|
|
1302
|
+
(skipCols && isHiddenColumn(sheetId, c)))
|
|
1303
|
+
continue;
|
|
1304
|
+
out.push(getUserEnteredValue(sheetId, r, c));
|
|
1305
|
+
}
|
|
1306
|
+
return out;
|
|
1307
|
+
}, [getUserEnteredValue, isHiddenRow, isHiddenColumn]);
|
|
1308
|
+
const getFormulasFromRange = useCallback((sheetRange, strategy) => {
|
|
1309
|
+
const { sheetId, startRowIndex, startColumnIndex, endRowIndex, endColumnIndex, } = sheetRange;
|
|
1310
|
+
const { skipRows, skipCols } = hiddenStrategyAxes(strategy);
|
|
1311
|
+
const out = [];
|
|
1312
|
+
for (let r = startRowIndex; r <= endRowIndex; r++)
|
|
1313
|
+
for (let c = startColumnIndex; c <= endColumnIndex; c++) {
|
|
1314
|
+
if ((skipRows && isHiddenRow(sheetId, r)) ||
|
|
1315
|
+
(skipCols && isHiddenColumn(sheetId, c)))
|
|
1316
|
+
continue;
|
|
1317
|
+
// The formula source (`=…`) only; a literal cell contributes `undefined`.
|
|
1318
|
+
out.push(getCellData(sheetId, r, c)?.ue?.fv ?? undefined);
|
|
1319
|
+
}
|
|
1320
|
+
return out;
|
|
1321
|
+
}, [getCellData, isHiddenRow, isHiddenColumn]);
|
|
1322
|
+
const getFormattingFromRange = useCallback((sheetRange, strategy) => {
|
|
1323
|
+
const { sheetId, startRowIndex, startColumnIndex, endRowIndex, endColumnIndex, } = sheetRange;
|
|
1324
|
+
const { skipRows, skipCols } = hiddenStrategyAxes(strategy);
|
|
1325
|
+
const out = [];
|
|
1326
|
+
for (let r = startRowIndex; r <= endRowIndex; r++)
|
|
1327
|
+
for (let c = startColumnIndex; c <= endColumnIndex; c++) {
|
|
1328
|
+
if ((skipRows && isHiddenRow(sheetId, r)) ||
|
|
1329
|
+
(skipCols && isHiddenColumn(sheetId, c)))
|
|
1330
|
+
continue;
|
|
1331
|
+
out.push(getEffectiveFormat(sheetId, r, c));
|
|
1332
|
+
}
|
|
1333
|
+
return out;
|
|
1334
|
+
}, [getEffectiveFormat, isHiddenRow, isHiddenColumn]);
|
|
1335
|
+
// Dependency queries — backed by the engine's REAL graph (it parses each formula with the grammar it
|
|
1336
|
+
// evaluates). Returned as `@rowsncolumns/dag` nodes so they're drop-in for the reference's trace
|
|
1337
|
+
// tooling: the source node's `inputKeys` carry its DIRECT precedents, and each dependent carries its
|
|
1338
|
+
// OWN refs as `inputKeys` — the exact shape `useOnTraceDependentsPrecedents` walks to build arrows.
|
|
1339
|
+
// A range SOURCE unions the per-cell results (deduped by node key), bounded to the engine's data
|
|
1340
|
+
// extent so a full-column selection doesn't walk a million empty cells.
|
|
1341
|
+
const sourceCellsOf = useCallback((engine, source) => {
|
|
1342
|
+
if (isCellCoordinate(source))
|
|
1343
|
+
return [source];
|
|
1344
|
+
// The engine's data extent (authoritative, current even in non-windowed mode where the hook's
|
|
1345
|
+
// extent state isn't refreshed per commit); the folded state extent is the fallback.
|
|
1346
|
+
const engineExtent = engine
|
|
1347
|
+
.sheetExtents()
|
|
1348
|
+
.find((e) => e[0] === source.sheetId);
|
|
1349
|
+
const extent = engineExtent
|
|
1350
|
+
? { rows: engineExtent[1], cols: engineExtent[2] }
|
|
1351
|
+
: extentById.get(source.sheetId);
|
|
1352
|
+
const endRow = Math.min(source.endRowIndex, Math.max(extent?.rows ?? 0, source.startRowIndex));
|
|
1353
|
+
const endCol = Math.min(source.endColumnIndex, Math.max(extent?.cols ?? 0, source.startColumnIndex));
|
|
1354
|
+
const cells = [];
|
|
1355
|
+
for (let r = source.startRowIndex; r <= endRow; r++)
|
|
1356
|
+
for (let c = source.startColumnIndex; c <= endCol; c++)
|
|
1357
|
+
cells.push({ sheetId: source.sheetId, rowIndex: r, columnIndex: c });
|
|
1358
|
+
return cells;
|
|
1359
|
+
}, [extentById]);
|
|
1360
|
+
const getPrecedents = useCallback((source, includeSource) => {
|
|
1361
|
+
const engine = engineRef.current;
|
|
1362
|
+
if (!engine)
|
|
1363
|
+
return [];
|
|
1364
|
+
const sourceKey = makeKey(source);
|
|
1365
|
+
const byKey = new Map();
|
|
1366
|
+
for (const cell of sourceCellsOf(engine, source)) {
|
|
1367
|
+
const { cells, ranges } = engine.precedents(cell.sheetId, cell.rowIndex, cell.columnIndex);
|
|
1368
|
+
for (const c of cells) {
|
|
1369
|
+
const k = makeKey(c);
|
|
1370
|
+
if (k !== sourceKey && !byKey.has(k))
|
|
1371
|
+
byKey.set(k, new CellNode(k, c));
|
|
1372
|
+
}
|
|
1373
|
+
for (const r of ranges) {
|
|
1374
|
+
const k = makeKey(r);
|
|
1375
|
+
if (k !== sourceKey && !byKey.has(k))
|
|
1376
|
+
byKey.set(k, new CellRangeNode(k, r));
|
|
1377
|
+
}
|
|
1378
|
+
}
|
|
1379
|
+
const nodes = [...byKey.values()];
|
|
1380
|
+
if (!includeSource)
|
|
1381
|
+
return nodes;
|
|
1382
|
+
if (isCellCoordinate(source)) {
|
|
1383
|
+
const sourceNode = new CellNode(sourceKey, source);
|
|
1384
|
+
for (const n of nodes)
|
|
1385
|
+
sourceNode.inputKeys.add(n);
|
|
1386
|
+
return [sourceNode, ...nodes];
|
|
1387
|
+
}
|
|
1388
|
+
const sourceNode = new CellRangeNode(sourceKey, source);
|
|
1389
|
+
for (const n of nodes)
|
|
1390
|
+
if (isCellNode(n))
|
|
1391
|
+
sourceNode.inputKeys.add(n);
|
|
1392
|
+
return [sourceNode, ...nodes];
|
|
1393
|
+
},
|
|
1394
|
+
// `sheetData` is an intentional reactivity signal (see getSeriesValuesFromRange).
|
|
1395
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
1396
|
+
[sheetData, sourceCellsOf]);
|
|
1397
|
+
const getDependents = useCallback((source, includeSource) => {
|
|
1398
|
+
const engine = engineRef.current;
|
|
1399
|
+
if (!engine)
|
|
1400
|
+
return [];
|
|
1401
|
+
const sourceKey = makeKey(source);
|
|
1402
|
+
const byKey = new Map();
|
|
1403
|
+
for (const cell of sourceCellsOf(engine, source)) {
|
|
1404
|
+
for (const d of engine.dependents(cell.sheetId, cell.rowIndex, cell.columnIndex)) {
|
|
1405
|
+
const k = makeKey(d);
|
|
1406
|
+
if (k !== sourceKey && !byKey.has(k))
|
|
1407
|
+
byKey.set(k, new CellNode(k, d));
|
|
1408
|
+
}
|
|
1409
|
+
}
|
|
1410
|
+
for (const node of byKey.values()) {
|
|
1411
|
+
const { cells, ranges } = engine.precedents(node.position.sheetId, node.position.rowIndex, node.position.columnIndex);
|
|
1412
|
+
for (const c of cells)
|
|
1413
|
+
node.inputKeys.add(new CellNode(makeKey(c), c));
|
|
1414
|
+
for (const r of ranges)
|
|
1415
|
+
node.inputKeys.add(new CellRangeNode(makeKey(r), r));
|
|
1416
|
+
}
|
|
1417
|
+
const nodes = [...byKey.values()];
|
|
1418
|
+
// The source node only rides along for a CELL source — the reference's return type
|
|
1419
|
+
// (`(CellNode | StaticNode)[]`) has no range-node arm.
|
|
1420
|
+
return includeSource && isCellCoordinate(source)
|
|
1421
|
+
? [new CellNode(sourceKey, source), ...nodes]
|
|
1422
|
+
: nodes;
|
|
1423
|
+
},
|
|
1424
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
1425
|
+
[sheetData, sourceCellsOf]);
|
|
1426
|
+
// Trace precedents/dependents arrows — the REFERENCE hook (state + `<Arrow/>` node construction)
|
|
1427
|
+
// composed over the engine-backed queries above. Arrows are rebuilt per trace call and cleared by
|
|
1428
|
+
// `onRemoveArrows`, exactly like the JS engine.
|
|
1429
|
+
const { arrows, onRemoveArrows, onTraceDependents, onTracePrecedents } = useOnTraceDependentsPrecedents({ getDependents, getPrecedents });
|
|
1430
|
+
// A sheet-property update → ONE `update-sheet` command carrying the partial spec; the engine applies
|
|
1431
|
+
// the provided fields (title + view metadata). Returns undefined: the engine streams the result, so
|
|
1432
|
+
// callers don't need the updated Sheet back synchronously (the `S | undefined` signature allows it).
|
|
1433
|
+
const onUpdateSheet = useCallback((sheetId, sheetSpec) => {
|
|
1434
|
+
onCommand({ command: "update-sheet", sheetId, sheetSpec });
|
|
1435
|
+
return undefined;
|
|
1436
|
+
}, [onCommand]);
|
|
1437
|
+
// "Add N more rows" (the grid footer): extend the sheet's grid size — the reference's `rowCount`
|
|
1438
|
+
// bump through `onUpdateSheet`. The grid's scroll extent derives from the render-state sheet
|
|
1439
|
+
// (`MAX(rowCount, data extent)`), so the bump lands on the render state; the engine grows cell
|
|
1440
|
+
// storage on demand and has no explicit row capacity to update.
|
|
1441
|
+
const onRequestAddRows = useCallback((sheetId, additionalRowCount) => {
|
|
1442
|
+
setState((prev) => ({
|
|
1443
|
+
...prev,
|
|
1444
|
+
sheets: prev.sheets.map((s) => s.sheetId === sheetId
|
|
1445
|
+
? { ...s, rowCount: (s.rowCount ?? 1000) + additionalRowCount }
|
|
1446
|
+
: s),
|
|
1447
|
+
}));
|
|
1448
|
+
return undefined;
|
|
1449
|
+
}, []);
|
|
1450
|
+
// Delete data-validation rules — one `delete-data-validation` command per rule.
|
|
1451
|
+
const onDeleteDataValidationRules = useCallback((rules) => {
|
|
1452
|
+
for (const rule of rules)
|
|
1453
|
+
onCommand({ command: "delete-data-validation", ruleId: rule.id });
|
|
1454
|
+
}, [onCommand]);
|
|
1455
|
+
// Clear the engine's undo/redo history (the document is unchanged) + reset the toolbar flags.
|
|
1456
|
+
const onClearHistory = useCallback(() => {
|
|
1457
|
+
engineRef.current?.clearHistory();
|
|
1458
|
+
setHist({ canUndo: false, canRedo: false });
|
|
1459
|
+
}, []);
|
|
1460
|
+
// Streaming batch writes — the AI/agent write path, with the reference hook's exact public
|
|
1461
|
+
// contract: an async iterable of CUMULATIVE row-major chunks (each chunk restates the range's rows
|
|
1462
|
+
// so far; `rows[0]` maps to `range.startRowIndex`), `onStreamStart` / `onStreamProgress` (last
|
|
1463
|
+
// non-empty cell) / `onStreamEnd` lifecycle callbacks, and the `disableDelta` /
|
|
1464
|
+
// `disableEvaluation` / `isAborted` options. Each chunk is applied as ONE `change-batch` command —
|
|
1465
|
+
// NOT the engine's NDJSON `streamBegin`/`streamWrite`/`streamEnd`, which is a load-time hydration
|
|
1466
|
+
// (raw `StreamRecord` lines, one recompute at the very end, no per-chunk visibility). Command-per-
|
|
1467
|
+
// chunk gives progressive rendering through the normal commit → fold pipeline, engine-evaluated
|
|
1468
|
+
// formulas, and the engine's own row-height refit for wrap/text-format styles.
|
|
1469
|
+
//
|
|
1470
|
+
// Divergences from the reference internals (same observable contract):
|
|
1471
|
+
// • A cell the chunk doesn't own a value for but still touches (style-only `cell_styles`, or a
|
|
1472
|
+
// formula skipped under `disableEvaluation`) RESTATES its current user-entered value in the
|
|
1473
|
+
// grid — `change-batch` is an atomic rectangle write, and a `null` entry would clear the cell
|
|
1474
|
+
// the reference preserves. The restate is read from the engine (off-window-safe).
|
|
1475
|
+
// • Delta mode (`disableDelta: false`) governs PROGRESS reporting (each cell is announced once);
|
|
1476
|
+
// the write always restates the chunk's full rows — the input is cumulative, so the rewrite is
|
|
1477
|
+
// idempotent and the final document is identical.
|
|
1478
|
+
const onChangeBatchStream = useCallback((sheetId, range, stream, streamOptions) => {
|
|
1479
|
+
const streamId = `stream_${Date.now()}_${Math.random()
|
|
1480
|
+
.toString(36)
|
|
1481
|
+
.substring(2, 9)}`;
|
|
1482
|
+
onStreamStartRef.current?.(streamId);
|
|
1483
|
+
const disableDeltaUpdates = streamOptions?.disableDelta ?? true;
|
|
1484
|
+
const disableEvaluation = streamOptions?.disableEvaluation;
|
|
1485
|
+
// Opt-in commit-per-chunk, for a caller that genuinely wants each chunk persisted as it
|
|
1486
|
+
// arrives (and accepts N undo entries). The default is the documented preview contract.
|
|
1487
|
+
const commitChunks = streamOptions
|
|
1488
|
+
?.commitChunks === true;
|
|
1489
|
+
// Per-row column cursor (delta mode) — progress fires only for cells not yet announced.
|
|
1490
|
+
const rowCursorByIndex = new Map();
|
|
1491
|
+
return (async () => {
|
|
1492
|
+
try {
|
|
1493
|
+
for await (const rows of stream) {
|
|
1494
|
+
if (!Array.isArray(rows) || !rows.length)
|
|
1495
|
+
continue;
|
|
1496
|
+
if (streamOptions?.isAborted?.())
|
|
1497
|
+
return;
|
|
1498
|
+
const engine = engineRef.current;
|
|
1499
|
+
if (!engine)
|
|
1500
|
+
return;
|
|
1501
|
+
const rowCount = Math.min(rows.length, range.endRowIndex - range.startRowIndex + 1);
|
|
1502
|
+
if (rowCount <= 0)
|
|
1503
|
+
continue;
|
|
1504
|
+
// Existing user-entered values for restated cells, read lazily ONCE per chunk from the
|
|
1505
|
+
// engine. Prefers the lossless `cell.ue`; lean-window engines fall back to the formula
|
|
1506
|
+
// source / raw number.
|
|
1507
|
+
let existingUe = null;
|
|
1508
|
+
const restatedUeAt = (rowIndex, columnIndex) => {
|
|
1509
|
+
if (!existingUe) {
|
|
1510
|
+
existingUe = new Map();
|
|
1511
|
+
let maxLen = 0;
|
|
1512
|
+
for (let i = 0; i < rowCount; i++) {
|
|
1513
|
+
const len = Array.isArray(rows[i]) ? rows[i].length : 0;
|
|
1514
|
+
if (len > maxLen)
|
|
1515
|
+
maxLen = len;
|
|
1516
|
+
}
|
|
1517
|
+
const endCol = Math.min(range.startColumnIndex + Math.max(maxLen, 1) - 1, range.endColumnIndex);
|
|
1518
|
+
const win = engine.readWindow(sheetId, range.startRowIndex, range.startColumnIndex, range.startRowIndex + rowCount - 1, endCol);
|
|
1519
|
+
for (const cell of win.cells) {
|
|
1520
|
+
const ue = cell.cell?.ue;
|
|
1521
|
+
const s = ue?.fv ??
|
|
1522
|
+
(ue?.nv != null
|
|
1523
|
+
? String(ue.nv)
|
|
1524
|
+
: ue?.bv != null
|
|
1525
|
+
? ue.bv
|
|
1526
|
+
? "TRUE"
|
|
1527
|
+
: "FALSE"
|
|
1528
|
+
: (ue?.sv ??
|
|
1529
|
+
cell.formula ??
|
|
1530
|
+
(cell.number != null
|
|
1531
|
+
? String(cell.number)
|
|
1532
|
+
: cell.formatted || null)));
|
|
1533
|
+
if (s != null)
|
|
1534
|
+
existingUe.set(`${cell.rowIndex},${cell.columnIndex}`, s);
|
|
1535
|
+
}
|
|
1536
|
+
}
|
|
1537
|
+
return existingUe.get(`${rowIndex},${columnIndex}`) ?? null;
|
|
1538
|
+
};
|
|
1539
|
+
const values = [];
|
|
1540
|
+
const formatting = [];
|
|
1541
|
+
let hasFormatting = false;
|
|
1542
|
+
let hasCells = false;
|
|
1543
|
+
let maxWrittenCols = 0;
|
|
1544
|
+
for (let i = 0; i < rowCount; i++) {
|
|
1545
|
+
const row = rows[i];
|
|
1546
|
+
const rowValues = [];
|
|
1547
|
+
const rowFormats = [];
|
|
1548
|
+
values.push(rowValues);
|
|
1549
|
+
formatting.push(rowFormats);
|
|
1550
|
+
if (!Array.isArray(row) || !row.length)
|
|
1551
|
+
continue;
|
|
1552
|
+
const absRow = range.startRowIndex + i;
|
|
1553
|
+
const colCount = Math.min(row.length, range.endColumnIndex - range.startColumnIndex + 1);
|
|
1554
|
+
const cursor = disableDeltaUpdates
|
|
1555
|
+
? 0
|
|
1556
|
+
: (rowCursorByIndex.get(i) ?? 0);
|
|
1557
|
+
let lastNonEmptyIndex = -1;
|
|
1558
|
+
for (let j = 0; j < colCount; j++) {
|
|
1559
|
+
const cellInput = row[j];
|
|
1560
|
+
const absCol = range.startColumnIndex + j;
|
|
1561
|
+
const formula = cellInput?.formula;
|
|
1562
|
+
const hasFormula = formula != null && formula !== "";
|
|
1563
|
+
const hasValue = cellInput?.value != null && cellInput.value !== "";
|
|
1564
|
+
if (j >= cursor &&
|
|
1565
|
+
(hasFormula || hasValue || cellInput?.cell_styles != null)) {
|
|
1566
|
+
lastNonEmptyIndex = Math.max(lastNonEmptyIndex, j);
|
|
1567
|
+
onStreamProgressRef.current?.(streamId, sheetId, {
|
|
1568
|
+
rowIndex: absRow,
|
|
1569
|
+
columnIndex: absCol,
|
|
1570
|
+
});
|
|
1571
|
+
}
|
|
1572
|
+
if (hasFormula && !disableEvaluation) {
|
|
1573
|
+
const trimmed = String(formula).trim();
|
|
1574
|
+
rowValues.push(trimmed
|
|
1575
|
+
? trimmed.startsWith("=")
|
|
1576
|
+
? trimmed
|
|
1577
|
+
: `=${trimmed}`
|
|
1578
|
+
: null);
|
|
1579
|
+
}
|
|
1580
|
+
else if (hasFormula) {
|
|
1581
|
+
rowValues.push(restatedUeAt(absRow, absCol));
|
|
1582
|
+
}
|
|
1583
|
+
else if (cellInput?.value != null) {
|
|
1584
|
+
rowValues.push(String(cellInput.value));
|
|
1585
|
+
}
|
|
1586
|
+
else if (cellInput?.cell_styles != null) {
|
|
1587
|
+
rowValues.push(restatedUeAt(absRow, absCol));
|
|
1588
|
+
}
|
|
1589
|
+
else {
|
|
1590
|
+
rowValues.push(null);
|
|
1591
|
+
}
|
|
1592
|
+
const styles = cellInput?.cell_styles ?? null;
|
|
1593
|
+
rowFormats.push(styles);
|
|
1594
|
+
if (styles)
|
|
1595
|
+
hasFormatting = true;
|
|
1596
|
+
}
|
|
1597
|
+
hasCells = hasCells || colCount > 0;
|
|
1598
|
+
if (colCount > maxWrittenCols)
|
|
1599
|
+
maxWrittenCols = colCount;
|
|
1600
|
+
if (!disableDeltaUpdates && lastNonEmptyIndex >= cursor)
|
|
1601
|
+
rowCursorByIndex.set(i, lastNonEmptyIndex + 1);
|
|
1602
|
+
}
|
|
1603
|
+
if (streamOptions?.isAborted?.())
|
|
1604
|
+
return;
|
|
1605
|
+
if (!hasCells)
|
|
1606
|
+
continue;
|
|
1607
|
+
if (commitChunks) {
|
|
1608
|
+
onCommand({
|
|
1609
|
+
command: "change-batch",
|
|
1610
|
+
sheetId,
|
|
1611
|
+
ranges: [
|
|
1612
|
+
{
|
|
1613
|
+
startRowIndex: range.startRowIndex,
|
|
1614
|
+
endRowIndex: range.startRowIndex + rowCount - 1,
|
|
1615
|
+
startColumnIndex: range.startColumnIndex,
|
|
1616
|
+
endColumnIndex: range.startColumnIndex + Math.max(maxWrittenCols, 1) - 1,
|
|
1617
|
+
},
|
|
1618
|
+
],
|
|
1619
|
+
values,
|
|
1620
|
+
formatting: hasFormatting ? formatting : undefined,
|
|
1621
|
+
});
|
|
1622
|
+
continue;
|
|
1623
|
+
}
|
|
1624
|
+
// Preview: paint the chunk locally, commit nothing. `null` entries are cells this chunk
|
|
1625
|
+
// doesn't own — leave the committed cell showing through rather than shadowing it blank.
|
|
1626
|
+
const preview = streamPreviewRef.current ?? new Map();
|
|
1627
|
+
for (let i = 0; i < values.length; i++) {
|
|
1628
|
+
const absRow = range.startRowIndex + i;
|
|
1629
|
+
const rowValues = values[i] ?? [];
|
|
1630
|
+
const rowFormats = formatting[i] ?? [];
|
|
1631
|
+
for (let j = 0; j < rowValues.length; j++) {
|
|
1632
|
+
const text = rowValues[j];
|
|
1633
|
+
const format = rowFormats[j] ?? null;
|
|
1634
|
+
if (text == null && !format)
|
|
1635
|
+
continue;
|
|
1636
|
+
const column = range.startColumnIndex + j;
|
|
1637
|
+
preview.set(cellKey(sheetId, absRow, column), {
|
|
1638
|
+
data: previewCellData(text),
|
|
1639
|
+
format,
|
|
1640
|
+
basis: committedFvAt(sheetId, absRow, column),
|
|
1641
|
+
});
|
|
1642
|
+
}
|
|
1643
|
+
}
|
|
1644
|
+
streamPreviewRef.current = preview;
|
|
1645
|
+
publishStreamPreview();
|
|
1646
|
+
}
|
|
1647
|
+
}
|
|
1648
|
+
catch (error) {
|
|
1649
|
+
if (!commitChunks)
|
|
1650
|
+
clearStreamPreview();
|
|
1651
|
+
throw error;
|
|
1652
|
+
}
|
|
1653
|
+
finally {
|
|
1654
|
+
// An aborted preview is discarded here. A COMPLETED preview deliberately survives: the
|
|
1655
|
+
// host persists next, and the applying write retires it in that same render. Clearing on
|
|
1656
|
+
// end instead would blank the cells until the apply landed.
|
|
1657
|
+
if (!commitChunks && streamOptions?.isAborted?.())
|
|
1658
|
+
clearStreamPreview();
|
|
1659
|
+
onStreamEndRef.current?.(streamId);
|
|
1660
|
+
}
|
|
1661
|
+
})();
|
|
1662
|
+
}, [
|
|
1663
|
+
onCommand,
|
|
1664
|
+
clearStreamPreview,
|
|
1665
|
+
publishStreamPreview,
|
|
1666
|
+
committedFvAt,
|
|
1667
|
+
]);
|
|
1668
|
+
// Derived dimensions of the active sheet (folded metadata + seeded counts) — O(1) via the map. In
|
|
1669
|
+
// windowed mode the grid's scroll extent is MAX(seeded, engine data extent) so it scrolls the full
|
|
1670
|
+
// sheet (`getSheetRowCount`/`getSheetColumnCount` apply the same max).
|
|
1671
|
+
const activeSheet = sheetById.get(activeSheetId);
|
|
1672
|
+
const activeExtent = extentById.get(activeSheetId);
|
|
1673
|
+
const rowCount = Math.max(activeSheet?.rowCount ?? 1000, activeExtent?.rows ?? 0);
|
|
1674
|
+
const columnCount = Math.max(activeSheet?.columnCount ?? 26, activeExtent?.cols ?? 0);
|
|
1675
|
+
const frozenRowCount = activeSheet?.frozenRowCount ?? 0;
|
|
1676
|
+
const frozenColumnCount = activeSheet?.frozenColumnCount ?? 0;
|
|
1677
|
+
// Memoize the array facets on the active sheet — the `?? []` fallback would otherwise mint a fresh
|
|
1678
|
+
// empty array each render and churn the return memo's deps. `activeSheet` is a stable Map reference
|
|
1679
|
+
// while `sheets`/`activeSheetId` are unchanged.
|
|
1680
|
+
const rowMetadata = useMemo(() => activeSheet?.rowMetadata ?? [], [activeSheet]);
|
|
1681
|
+
const columnMetadata = useMemo(() => activeSheet?.columnMetadata ?? [], [activeSheet]);
|
|
1682
|
+
const merges = useMemo(() => activeSheet?.merges ?? [], [activeSheet]);
|
|
1683
|
+
// The full command-emitting handler surface — every `on*` is a thin shim turning UI-shaped args
|
|
1684
|
+
// into a `commands.ts` command (the engine owns ALL execution). Built once: `onCommand` is a stable
|
|
1685
|
+
// empty-dep useCallback. The pure factory + its spec (command-handlers.ts) guarantee the
|
|
1686
|
+
// handler→command mapping; nothing here touches the document directly.
|
|
1687
|
+
const handlers = useMemo(() => createCommandHandlers(onCommand), [onCommand]);
|
|
1688
|
+
// Grid-shaped handlers that need view state (the active sheet). Kept as STABLE useCallbacks here (not
|
|
1689
|
+
// in the pure factory) so `<CanvasGrid>` props don't churn every render — they read `viewRef.current`
|
|
1690
|
+
// (the live active sheet) instead of closing over `activeSheetId`, so identity never changes. The
|
|
1691
|
+
// grid omits sheetId from chart move/resize (charts live on the active sheet) and passes just the rule
|
|
1692
|
+
// to create-conditional-format.
|
|
1693
|
+
const onMoveChart = useCallback((chartId, anchorCell, offsetXPixels, offsetYPixels) => onCommand({
|
|
1694
|
+
command: "move-chart",
|
|
1695
|
+
chartId,
|
|
1696
|
+
sheetId: viewRef.current.activeSheetId,
|
|
1697
|
+
anchorCell,
|
|
1698
|
+
offsetXPixels,
|
|
1699
|
+
offsetYPixels,
|
|
1700
|
+
}), [onCommand]);
|
|
1701
|
+
const onResizeChart = useCallback((chartId, anchorCell, offsetXPixels, offsetYPixels, width, height) => onCommand({
|
|
1702
|
+
command: "resize-chart",
|
|
1703
|
+
chartId,
|
|
1704
|
+
sheetId: viewRef.current.activeSheetId,
|
|
1705
|
+
anchorCell,
|
|
1706
|
+
offsetXPixels,
|
|
1707
|
+
offsetYPixels,
|
|
1708
|
+
width,
|
|
1709
|
+
height,
|
|
1710
|
+
}), [onCommand]);
|
|
1711
|
+
const onCreateConditionalFormattingRule = useCallback((rule) => onCommand({
|
|
1712
|
+
command: "create-conditional-format",
|
|
1713
|
+
sheetId: viewRef.current.activeSheetId,
|
|
1714
|
+
rule,
|
|
1715
|
+
}), [onCommand]);
|
|
1716
|
+
// Repeat the last formatting action on the current target — re-emit the recorded command (captured in
|
|
1717
|
+
// `onCommand`) with the new sheet/cell/selection swapped in. Mirrors `onRepeatFormatting`.
|
|
1718
|
+
const onRepeatFormatting = useCallback((sheetId, activeCell, selections) => {
|
|
1719
|
+
const last = lastFormattingRef.current;
|
|
1720
|
+
if (last)
|
|
1721
|
+
onCommand({ ...last, sheetId, activeCell, selections });
|
|
1722
|
+
}, [onCommand]);
|
|
1723
|
+
// ── Format painter (mirrors `usePaintFormat`): capture a 2-D format pattern, then replay it over a
|
|
1724
|
+
// target with `replace` (the engine tiles the pattern). `isPaintFormatActive` is derived below. ──
|
|
1725
|
+
const captureFormats = useCallback((sheetId, range) => {
|
|
1726
|
+
const grid = [];
|
|
1727
|
+
for (let r = range.startRowIndex; r <= range.endRowIndex; r++) {
|
|
1728
|
+
const row = [];
|
|
1729
|
+
for (let c = range.startColumnIndex; c <= range.endColumnIndex; c++) {
|
|
1730
|
+
row.push(getEffectiveFormat(sheetId, r, c));
|
|
1731
|
+
}
|
|
1732
|
+
grid.push(row);
|
|
1733
|
+
}
|
|
1734
|
+
return grid;
|
|
1735
|
+
}, [getEffectiveFormat]);
|
|
1736
|
+
const onSavePaintFormat = useCallback((sheetId, activeCell, selections) => {
|
|
1737
|
+
const range = selections?.[0]?.range ?? {
|
|
1738
|
+
startRowIndex: activeCell.rowIndex,
|
|
1739
|
+
endRowIndex: activeCell.rowIndex,
|
|
1740
|
+
startColumnIndex: activeCell.columnIndex,
|
|
1741
|
+
endColumnIndex: activeCell.columnIndex,
|
|
1742
|
+
};
|
|
1743
|
+
setPaintFormats(captureFormats(sheetId, range));
|
|
1744
|
+
}, [captureFormats]);
|
|
1745
|
+
const onApplyPaintFormat = useCallback((sheetId, activeCell, selections) => {
|
|
1746
|
+
if (!paintFormats.length)
|
|
1747
|
+
return;
|
|
1748
|
+
const rowSpan = paintFormats.length - 1;
|
|
1749
|
+
const colSpan = (paintFormats[0]?.length ?? 1) - 1;
|
|
1750
|
+
// The pattern tiles across the full dragged target (Excel painter semantics —
|
|
1751
|
+
// the store's change-formatting applier repeats the grid modulo its size).
|
|
1752
|
+
// Only header-click full-column/row targets are bounded — to the sheet's data
|
|
1753
|
+
// extent, never below one full pattern — so we never enumerate ~1M cells.
|
|
1754
|
+
const extent = engineRef.current
|
|
1755
|
+
?.sheetExtents?.()
|
|
1756
|
+
.find(([id]) => id === sheetId);
|
|
1757
|
+
const clampEnd = (start, end, span, dataEnd) => {
|
|
1758
|
+
if (end - start >= UNBOUNDED_PAINT_SPAN)
|
|
1759
|
+
return Math.min(end, Math.max(start + span, dataEnd));
|
|
1760
|
+
return end;
|
|
1761
|
+
};
|
|
1762
|
+
const target = selections && selections.length
|
|
1763
|
+
? selections.map((sel) => ({
|
|
1764
|
+
...sel,
|
|
1765
|
+
range: {
|
|
1766
|
+
...sel.range,
|
|
1767
|
+
endRowIndex: clampEnd(sel.range.startRowIndex, sel.range.endRowIndex, rowSpan, extent?.[1] ?? 0),
|
|
1768
|
+
endColumnIndex: clampEnd(sel.range.startColumnIndex, sel.range.endColumnIndex, colSpan, extent?.[2] ?? 0),
|
|
1769
|
+
},
|
|
1770
|
+
}))
|
|
1771
|
+
: [
|
|
1772
|
+
{
|
|
1773
|
+
range: {
|
|
1774
|
+
startRowIndex: activeCell.rowIndex,
|
|
1775
|
+
endRowIndex: activeCell.rowIndex + rowSpan,
|
|
1776
|
+
startColumnIndex: activeCell.columnIndex,
|
|
1777
|
+
endColumnIndex: activeCell.columnIndex + colSpan,
|
|
1778
|
+
},
|
|
1779
|
+
},
|
|
1780
|
+
];
|
|
1781
|
+
onCommand({
|
|
1782
|
+
command: "change-formatting",
|
|
1783
|
+
sheetId,
|
|
1784
|
+
activeCell,
|
|
1785
|
+
selections: target,
|
|
1786
|
+
cellFormat: paintFormats,
|
|
1787
|
+
options: { replace: true },
|
|
1788
|
+
});
|
|
1789
|
+
setPaintFormats([]); // single-use, like the JS reference
|
|
1790
|
+
}, [paintFormats, onCommand]);
|
|
1791
|
+
// Selection interceptor (parity with `useSpreadsheetState`'s `onChangeSelectionsInterceptor`): a
|
|
1792
|
+
// FINISHED grid selection while the painter is armed applies the captured pattern to the target
|
|
1793
|
+
// instead of updating the view selection. An empty selection list (a plain cell click) makes
|
|
1794
|
+
// `onApplyPaintFormat` derive the target from the active cell + the pattern's span.
|
|
1795
|
+
const onChangeSelectionsInterceptor = useCallback((sheetId, sel, finishedSelection) => {
|
|
1796
|
+
if (paintFormats.length && finishedSelection) {
|
|
1797
|
+
onApplyPaintFormat(sheetId, viewRef.current.activeCell, sel);
|
|
1798
|
+
return;
|
|
1799
|
+
}
|
|
1800
|
+
onChangeSelections(sheetId, sel);
|
|
1801
|
+
}, [paintFormats, onApplyPaintFormat, onChangeSelections]);
|
|
1802
|
+
const paintFormat = useCallback((sourceSheetRange, targetSheetRange) => {
|
|
1803
|
+
onCommand({
|
|
1804
|
+
command: "change-formatting",
|
|
1805
|
+
sheetId: targetSheetRange.sheetId,
|
|
1806
|
+
activeCell: {
|
|
1807
|
+
rowIndex: targetSheetRange.startRowIndex,
|
|
1808
|
+
columnIndex: targetSheetRange.startColumnIndex,
|
|
1809
|
+
},
|
|
1810
|
+
selections: [{ range: targetSheetRange }],
|
|
1811
|
+
cellFormat: captureFormats(sourceSheetRange.sheetId, sourceSheetRange),
|
|
1812
|
+
options: { replace: true },
|
|
1813
|
+
});
|
|
1814
|
+
}, [captureFormats, onCommand]);
|
|
1815
|
+
// Following a hyperlink: external URLs open a new tab (the only browser-safe action here); an
|
|
1816
|
+
// internal reference (`Sheet1!A1:B5`) NAVIGATES — switch to the target sheet, then move the cursor
|
|
1817
|
+
// + selection to the parsed range (the reference's `navigateToSheetRange`; the grid's own
|
|
1818
|
+
// active-cell tracking scrolls it into view).
|
|
1819
|
+
const onSelectLink = useCallback((sheetId, _activeCell, hyperlink) => {
|
|
1820
|
+
if (!hyperlink)
|
|
1821
|
+
return;
|
|
1822
|
+
const open = (url) => {
|
|
1823
|
+
if (typeof window !== "undefined")
|
|
1824
|
+
window.open(normalizeExternalUrl(url), "_blank", "noopener");
|
|
1825
|
+
};
|
|
1826
|
+
if (typeof hyperlink === "string")
|
|
1827
|
+
return open(hyperlink);
|
|
1828
|
+
if (hyperlink.kind === "external")
|
|
1829
|
+
return open(hyperlink.url);
|
|
1830
|
+
if (hyperlink.kind === "internal" && hyperlink.location) {
|
|
1831
|
+
const { sheetName, range } = parseInternalLocation(hyperlink.location);
|
|
1832
|
+
// A bare `A1` targets the cell's own sheet; an unknown sheet name is a dead link — no-op.
|
|
1833
|
+
const target = sheetName ? getSheetId(sheetName) : sheetId;
|
|
1834
|
+
if (target == null)
|
|
1835
|
+
return;
|
|
1836
|
+
if (target !== viewRef.current.activeSheetId)
|
|
1837
|
+
onChangeActiveSheet(target);
|
|
1838
|
+
if (range) {
|
|
1839
|
+
onChangeActiveCell(target, {
|
|
1840
|
+
rowIndex: range.startRowIndex,
|
|
1841
|
+
columnIndex: range.startColumnIndex,
|
|
1842
|
+
});
|
|
1843
|
+
onChangeSelections(target, [{ range }]);
|
|
1844
|
+
}
|
|
1845
|
+
}
|
|
1846
|
+
}, [getSheetId, onChangeActiveSheet, onChangeActiveCell, onChangeSelections]);
|
|
1847
|
+
// Cycle the active sheet (Ctrl+PageUp/PageDown in the grid) — pure view, no engine command.
|
|
1848
|
+
const onSelectNextSheet = useCallback((sheetId) => {
|
|
1849
|
+
const i = sheets.findIndex((s) => s.sheetId === sheetId);
|
|
1850
|
+
const next = sheets[(i + 1) % (sheets.length || 1)];
|
|
1851
|
+
if (next)
|
|
1852
|
+
onChangeActiveSheet(next.sheetId);
|
|
1853
|
+
}, [sheets, onChangeActiveSheet]);
|
|
1854
|
+
const onSelectPreviousSheet = useCallback((sheetId) => {
|
|
1855
|
+
const i = sheets.findIndex((s) => s.sheetId === sheetId);
|
|
1856
|
+
const prev = sheets[(i - 1 + sheets.length) % (sheets.length || 1)];
|
|
1857
|
+
if (prev)
|
|
1858
|
+
onChangeActiveSheet(prev.sheetId);
|
|
1859
|
+
}, [sheets, onChangeActiveSheet]);
|
|
1860
|
+
// ── Document facets (engine-owned; parsed once per facet change) ──
|
|
1861
|
+
// Parse the raw facets string lazily; identity changes ONLY when the string did (see `facetsJson`).
|
|
1862
|
+
const facets = useMemo(() => facetsJson === "{}"
|
|
1863
|
+
? EMPTY_FACETS
|
|
1864
|
+
: JSON.parse(facetsJson), [facetsJson]);
|
|
1865
|
+
// Whole-document facets pass straight through (stable while `facets` is stable).
|
|
1866
|
+
const conditionalFormats = facets.conditionalFormats;
|
|
1867
|
+
const tables = facets.tables;
|
|
1868
|
+
const namedRanges = facets.namedRanges;
|
|
1869
|
+
const protectedRanges = facets.protectedRanges;
|
|
1870
|
+
const embeds = facets.embeds;
|
|
1871
|
+
const slicers = facets.slicers;
|
|
1872
|
+
// Citations (workbook-level collection; a cell links to one via `citationId`). The grid renders the
|
|
1873
|
+
// overlay from this list — the engine owns it (unlike `useSpreadsheetState`, where it's an app prop).
|
|
1874
|
+
const citations = facets.citations;
|
|
1875
|
+
// Banded ranges + the basic filter are per-active-sheet (the grid draws only the active sheet's).
|
|
1876
|
+
// Standalone banded ranges (alternating colors) ride the engine's "banded" facet; a TABLE's visual
|
|
1877
|
+
// style is materialized on its facet entry as `bandedRange` (never as per-cell format), so derive
|
|
1878
|
+
// those here exactly like `useSpreadsheetState` — this single array is what the grid paints and the
|
|
1879
|
+
// copy path serializes table styling from.
|
|
1880
|
+
const bandedRanges = useMemo(() => {
|
|
1881
|
+
const tableBands = (tables ?? [])
|
|
1882
|
+
.filter((t) => t.sheetId === activeSheetId && t.bandedRange != null)
|
|
1883
|
+
.map((t) => ({
|
|
1884
|
+
bandedRangeId: `${t.id}-${t.theme}`,
|
|
1885
|
+
range: { ...t.range, sheetId: activeSheetId },
|
|
1886
|
+
...t.bandedRange,
|
|
1887
|
+
}));
|
|
1888
|
+
return [
|
|
1889
|
+
...facets.bandedRanges.filter((b) => b.range?.sheetId === activeSheetId),
|
|
1890
|
+
...tableBands,
|
|
1891
|
+
];
|
|
1892
|
+
}, [facets, tables, activeSheetId]);
|
|
1893
|
+
const basicFilter = useMemo(() => facets.basicFilters.find((f) => f.sheetId === activeSheetId)?.filter, [facets, activeSheetId]);
|
|
1894
|
+
// Body rows the engine hid for the active sheet (basic filter OR a table-column filter), as compact
|
|
1895
|
+
// [start,end] 1-based inclusive ranges. CanvasGrid derives `visibleRowRanges` from these — the
|
|
1896
|
+
// channel that makes a table/basic filter actually hide rows (and scales to 1M without per-row meta).
|
|
1897
|
+
const hostHiddenRowRanges = useMemo(() => (facets.hiddenRowRanges?.[String(activeSheetId)] ?? []), [facets, activeSheetId]);
|
|
1898
|
+
// The cell's data-validation rule — resolved by RANGE MEMBERSHIP (the engine stores rules with their
|
|
1899
|
+
// ranges, not an id/rule on the cell as `useSpreadsheetState` does), returning the canonical rule shape.
|
|
1900
|
+
const getDataValidation = useCallback((sheetId, rowIndex, columnIndex) => {
|
|
1901
|
+
const entry = facets.dataValidations.find((dv) => dv.ranges.some((r) => rangeContains(r, sheetId, rowIndex, columnIndex)));
|
|
1902
|
+
// `displayStyle` must ride along: the grid's `showValidationDropdownArrow` hides the
|
|
1903
|
+
// in-cell arrow for `"plain"` (imported OOXML `showDropDown="1"` — suppress-dropdown).
|
|
1904
|
+
return entry?.condition
|
|
1905
|
+
? {
|
|
1906
|
+
condition: entry.condition,
|
|
1907
|
+
displayStyle: entry.displayStyle,
|
|
1908
|
+
allowBlank: entry.allowBlank,
|
|
1909
|
+
}
|
|
1910
|
+
: undefined;
|
|
1911
|
+
}, [facets]);
|
|
1912
|
+
// ── Parity stubs that close over hook state (the rest are module-level) ──
|
|
1913
|
+
// Empty registries — the engine owns styles + shared strings; JS keeps no cellXfs/SST table.
|
|
1914
|
+
const cellXfsRegistry = useMemo(() => new CellXfsRegistry(), []);
|
|
1915
|
+
const sharedStringRegistry = useMemo(() => new SharedStringRegistry(), []);
|
|
1916
|
+
// ── Clipboard ──
|
|
1917
|
+
// Copy/cut WRITE the clipboard (the rich `application/json` + html/csv/plain) straight from the
|
|
1918
|
+
// engine's read accessors — a pure read, so it's identical to `useSpreadsheetState` and safe in
|
|
1919
|
+
// Option B (no document mutation). Cut writes the same clipboard; the source delete happens at paste.
|
|
1920
|
+
// Option B keeps styles in a SEPARATE `cellStyles` map, so `getCellData` returns value-only cells
|
|
1921
|
+
// (no `uf`). `useOnCopy` serializes the rich `application/json` clipboard straight from this cell data
|
|
1922
|
+
// and writes the effective format ONLY into the HTML representation — so the JSON cell carries no
|
|
1923
|
+
// format and paste lands values-only (the bug: bold/number-format/etc. silently dropped on copy↔paste).
|
|
1924
|
+
// Inject the cell's effective format as an inline `uf` here so it round-trips: `generateDataFromClipboard`
|
|
1925
|
+
// passes a non-`StyleReference` `uf` through untouched, and `onPaste` forwards it as the paste command's
|
|
1926
|
+
// per-cell `format`, which the engine applies via `set_style`.
|
|
1927
|
+
const getCellDataForCopy = useCallback((sheetId, rowIndex, columnIndex) => {
|
|
1928
|
+
const data = getCellData(sheetId, rowIndex, columnIndex);
|
|
1929
|
+
const uf = getEffectiveFormat(sheetId, rowIndex, columnIndex);
|
|
1930
|
+
if (!uf)
|
|
1931
|
+
return data;
|
|
1932
|
+
return { ...(data ?? {}), uf };
|
|
1933
|
+
}, [getCellData, getEffectiveFormat]);
|
|
1934
|
+
// `theme` makes `useOnCopy` emit inline styles into the text/html payload (fills, text colors,
|
|
1935
|
+
// borders — including the table/banded styling resolved from `bandedRanges` above), so pasting
|
|
1936
|
+
// into Excel/Word preserves what the grid renders.
|
|
1937
|
+
const onCopy = useOnCopy({
|
|
1938
|
+
theme: options.theme ?? defaultSpreadsheetTheme,
|
|
1939
|
+
merges,
|
|
1940
|
+
tables,
|
|
1941
|
+
bandedRanges,
|
|
1942
|
+
getEffectiveFormat,
|
|
1943
|
+
getFormattedValue,
|
|
1944
|
+
getColumnWidth: (sheetId, columnIndex) => getColumnWidth(sheetId, columnIndex) ?? 100,
|
|
1945
|
+
getRowHeight: (sheetId, rowIndex) => getRowHeight(sheetId, rowIndex) ?? 20,
|
|
1946
|
+
isColumnHidden: isHiddenColumn,
|
|
1947
|
+
isHiddenRow,
|
|
1948
|
+
getCellData: getCellDataForCopy,
|
|
1949
|
+
});
|
|
1950
|
+
// Paste resolves the BOUNDED clipboard UI-side (parse only) then hands the engine a `paste` command:
|
|
1951
|
+
// the engine places the rectangle and relocates relative formula refs by (target − source), and on a
|
|
1952
|
+
// cut clears the source. Formatting IS carried: `generateDataFromClipboard` resolves the clipboard's
|
|
1953
|
+
// cellXfs style-refs to an inline `CellFormat` on each parsed cell, which we forward per cell below;
|
|
1954
|
+
// the engine applies it via `set_style` (streams on `styleChanges`). Only a still-unresolved
|
|
1955
|
+
// `StyleReference` (no matching cellXfs entry) is skipped, since a bare ref can't cross to the engine.
|
|
1956
|
+
const onPaste = useCallback(async (e, sourceSheetId, destinationSheetId, copiedSelections, pasteActiveCell, pasteSelections, _finalSelections, isCutOperation) => {
|
|
1957
|
+
const parsed = await generateDataFromClipboard(e, pasteActiveCell, destinationSheetId, getSheetName(destinationSheetId), true, initial.current.locale);
|
|
1958
|
+
let results = parsed?.results ?? [];
|
|
1959
|
+
if (!results.length)
|
|
1960
|
+
return;
|
|
1961
|
+
// Paste Special. The grid stashes the mode under a synthetic `pasteSpecialType` clipboard key
|
|
1962
|
+
// (same as `useSpreadsheetState`). Transpose/Value/Formula transform the parsed cells UI-side
|
|
1963
|
+
// (the engine can't "resolve a formula to its value"); Formatting rides a `format_only` flag the
|
|
1964
|
+
// engine honors (apply each cell's format, leave destination values intact).
|
|
1965
|
+
const pasteSpecialType = e?.clipboardData?.getData("pasteSpecialType");
|
|
1966
|
+
if (pasteSpecialType === "Transposed") {
|
|
1967
|
+
results = transpose(results);
|
|
1968
|
+
}
|
|
1969
|
+
else if (pasteSpecialType === "Value") {
|
|
1970
|
+
results = pickValue(results, destinationSheetId, initial.current.locale);
|
|
1971
|
+
}
|
|
1972
|
+
else if (pasteSpecialType === "Formula") {
|
|
1973
|
+
results = pickFormula(results, destinationSheetId, initial.current.locale);
|
|
1974
|
+
}
|
|
1975
|
+
const formatOnly = pasteSpecialType === "Formatting";
|
|
1976
|
+
// Destination top-left = the paste selection's start (else the active cell).
|
|
1977
|
+
const to = pasteSelections?.[pasteSelections.length - 1]?.range;
|
|
1978
|
+
const target = to
|
|
1979
|
+
? { rowIndex: to.startRowIndex, columnIndex: to.startColumnIndex }
|
|
1980
|
+
: pasteActiveCell;
|
|
1981
|
+
// The copied range top-left, kept for the cut source-clear below.
|
|
1982
|
+
const from = copiedSelections?.[0]?.range;
|
|
1983
|
+
// `generateDataFromClipboard` has ALREADY re-anchored each relative formula ref to its destination
|
|
1984
|
+
// (its `moveFormula(sourceCell → destinationCell)` pass overwrites the cell's `fv`), so the formula
|
|
1985
|
+
// text in `cells` is destination-shifted on arrival. The engine's paste then shifts relative refs by
|
|
1986
|
+
// (target − source); passing the real copy origin as `source` would apply that shift a SECOND time and
|
|
1987
|
+
// DOUBLE the offset (`=A1` from B1 pasted to B2 → `=A3` instead of `=A2`). Pin `source` to `target` so
|
|
1988
|
+
// the engine's shift is a zero-delta no-op and the already-correct formula is preserved.
|
|
1989
|
+
const source = target;
|
|
1990
|
+
const cells = results.map((row) => row.map((cell) => {
|
|
1991
|
+
if (!cell)
|
|
1992
|
+
return null;
|
|
1993
|
+
const ue = getCellUserEnteredValue(cell);
|
|
1994
|
+
const num = getExtendedValueNumber(ue);
|
|
1995
|
+
const bool = getExtendedValueBool(ue);
|
|
1996
|
+
// Formula text wins, else the literal as a string (`String(0)`/`"FALSE"` survive).
|
|
1997
|
+
const value = getExtendedValueFormula(ue) ??
|
|
1998
|
+
getExtendedValueString(ue) ??
|
|
1999
|
+
(num != null
|
|
2000
|
+
? String(num)
|
|
2001
|
+
: bool != null
|
|
2002
|
+
? bool
|
|
2003
|
+
? "TRUE"
|
|
2004
|
+
: "FALSE"
|
|
2005
|
+
: null);
|
|
2006
|
+
// Carry the copied cell's format. A `StyleReference` can't cross to the engine (it owns
|
|
2007
|
+
// styles inline), so skip refs — engine-copied cells already have an inline `CellFormat`.
|
|
2008
|
+
const fmt = getCellUserEnteredFormat(cell);
|
|
2009
|
+
const format = fmt && !isStyleReference(fmt) ? fmt : undefined;
|
|
2010
|
+
// Carry the copied cell's hyperlink so a pasted link stays clickable (Excel/Sheets
|
|
2011
|
+
// parity). The engine stores the target as a plain string; resolve `HyperlinkValue`
|
|
2012
|
+
// to its external URL / internal location here.
|
|
2013
|
+
const hl = cell.hyperlink;
|
|
2014
|
+
const hyperlink = typeof hl === "string"
|
|
2015
|
+
? hl
|
|
2016
|
+
: hl == null
|
|
2017
|
+
? undefined
|
|
2018
|
+
: hl.kind === "external"
|
|
2019
|
+
? hl.url
|
|
2020
|
+
: hl.location;
|
|
2021
|
+
return { value, format, hyperlink };
|
|
2022
|
+
}));
|
|
2023
|
+
onCommand({
|
|
2024
|
+
command: "paste",
|
|
2025
|
+
sheetId: destinationSheetId,
|
|
2026
|
+
target,
|
|
2027
|
+
source,
|
|
2028
|
+
cells,
|
|
2029
|
+
cut: isCutOperation && from && sourceSheetId != null
|
|
2030
|
+
? { sheetId: sourceSheetId, range: from }
|
|
2031
|
+
: null,
|
|
2032
|
+
formatOnly,
|
|
2033
|
+
});
|
|
2034
|
+
}, [getSheetName, getCellData, onCommand]);
|
|
2035
|
+
return useMemo(() => ({
|
|
2036
|
+
...state,
|
|
2037
|
+
...handlers,
|
|
2038
|
+
ready,
|
|
2039
|
+
engine: engineRef.current,
|
|
2040
|
+
canUndo: hist.canUndo,
|
|
2041
|
+
canRedo: hist.canRedo,
|
|
2042
|
+
onCommand,
|
|
2043
|
+
undo,
|
|
2044
|
+
redo,
|
|
2045
|
+
onUndo: undo,
|
|
2046
|
+
onRedo: redo,
|
|
2047
|
+
onMoveChart,
|
|
2048
|
+
onResizeChart,
|
|
2049
|
+
onCreateConditionalFormattingRule,
|
|
2050
|
+
// Repeat last formatting (the action itself is recorded in `onCommand`).
|
|
2051
|
+
onRepeatFormatting,
|
|
2052
|
+
// Format painter + hyperlink follow + the citation overlay (engine-owned).
|
|
2053
|
+
onSavePaintFormat,
|
|
2054
|
+
onApplyPaintFormat,
|
|
2055
|
+
paintFormat,
|
|
2056
|
+
isPaintFormatActive: paintFormats.length > 0,
|
|
2057
|
+
onSelectLink,
|
|
2058
|
+
citations,
|
|
2059
|
+
load,
|
|
2060
|
+
importXlsx,
|
|
2061
|
+
setEffectiveValue,
|
|
2062
|
+
registerCustomFunction,
|
|
2063
|
+
activeSheetId,
|
|
2064
|
+
activeCell,
|
|
2065
|
+
selections,
|
|
2066
|
+
activeCellBySheetId,
|
|
2067
|
+
selectionsBySheetId,
|
|
2068
|
+
onChangeActiveSheet,
|
|
2069
|
+
onChangeActiveCell,
|
|
2070
|
+
onChangeSelections: onChangeSelectionsInterceptor,
|
|
2071
|
+
rowCount,
|
|
2072
|
+
columnCount,
|
|
2073
|
+
frozenRowCount,
|
|
2074
|
+
frozenColumnCount,
|
|
2075
|
+
rowMetadata,
|
|
2076
|
+
columnMetadata,
|
|
2077
|
+
merges,
|
|
2078
|
+
getCellData,
|
|
2079
|
+
getFormattedValue,
|
|
2080
|
+
getUserEnteredValue,
|
|
2081
|
+
getEffectiveValue,
|
|
2082
|
+
getEffectiveExtendedValue,
|
|
2083
|
+
getEffectiveFormat,
|
|
2084
|
+
getHyperlink,
|
|
2085
|
+
getNote,
|
|
2086
|
+
getRowHeight,
|
|
2087
|
+
getColumnWidth,
|
|
2088
|
+
isHiddenRow,
|
|
2089
|
+
isHiddenColumn,
|
|
2090
|
+
getSheetName,
|
|
2091
|
+
getSheetId,
|
|
2092
|
+
getSheetCount,
|
|
2093
|
+
getSheetIndex,
|
|
2094
|
+
getSheetRowCount,
|
|
2095
|
+
getSheetColumnCount,
|
|
2096
|
+
getDataRowCount,
|
|
2097
|
+
findAll,
|
|
2098
|
+
getSeriesValuesFromRange,
|
|
2099
|
+
getDomainValuesFromRange,
|
|
2100
|
+
getColumnarDataFromRange,
|
|
2101
|
+
getTextFormatRuns,
|
|
2102
|
+
getUserEnteredExtendedValue,
|
|
2103
|
+
getUserEnteredFormat,
|
|
2104
|
+
getErrorValue,
|
|
2105
|
+
getMentionsFromCell,
|
|
2106
|
+
getDataColumnCount,
|
|
2107
|
+
getNonEmptyRowCount,
|
|
2108
|
+
getNonEmptyColumnCount,
|
|
2109
|
+
onSelectNextSheet,
|
|
2110
|
+
onSelectPreviousSheet,
|
|
2111
|
+
// Sheet-view toggles + zoom come from the ACTIVE sheet's imported `<sheetView>` (reconciled
|
|
2112
|
+
// onto each Sheet), defaulting to Excel's on/100% when the file didn't specify.
|
|
2113
|
+
showGridLines: state.sheets.find((s) => s.sheetId === activeSheetId)?.showGridLines ??
|
|
2114
|
+
true,
|
|
2115
|
+
showRowColHeaders: state.sheets.find((s) => s.sheetId === activeSheetId)
|
|
2116
|
+
?.showRowColHeaders ?? true,
|
|
2117
|
+
zoomScale: state.sheets.find((s) => s.sheetId === activeSheetId)?.zoomScale ?? 1,
|
|
2118
|
+
// Undefined when the file didn't specify — CanvasGrid falls back to its own default.
|
|
2119
|
+
defaultRowHeight: state.sheets.find((s) => s.sheetId === activeSheetId)
|
|
2120
|
+
?.defaultRowHeight ?? undefined,
|
|
2121
|
+
isDarkMode: false,
|
|
2122
|
+
bandedRanges,
|
|
2123
|
+
basicFilter,
|
|
2124
|
+
hostHiddenRowRanges,
|
|
2125
|
+
arrows,
|
|
2126
|
+
conditionalFormats,
|
|
2127
|
+
tables,
|
|
2128
|
+
namedRanges,
|
|
2129
|
+
protectedRanges,
|
|
2130
|
+
embeds,
|
|
2131
|
+
slicers,
|
|
2132
|
+
getDataValidation,
|
|
2133
|
+
// ── Drop-in parity stubs (inert in Option B — see EngineSpreadsheetParityStubs) ──
|
|
2134
|
+
cellXfsRegistry,
|
|
2135
|
+
sharedStringRegistry,
|
|
2136
|
+
spreadsheetColors: EMPTY_COLORS,
|
|
2137
|
+
isImportingExcelfile: false,
|
|
2138
|
+
onChangeCellXfs,
|
|
2139
|
+
addFormulaToGraph,
|
|
2140
|
+
enqueueCalculation,
|
|
2141
|
+
enqueueGraphOperation,
|
|
2142
|
+
updateDependencyGraph,
|
|
2143
|
+
clearEvaluatedCellsCache,
|
|
2144
|
+
calculateNow,
|
|
2145
|
+
onRequestCalculate,
|
|
2146
|
+
// Engine-computed "Filter by color" swatches — pass to CanvasGrid's `getFilterColors`.
|
|
2147
|
+
getFilterColors,
|
|
2148
|
+
getDependencyGraph,
|
|
2149
|
+
getDependents,
|
|
2150
|
+
getPrecedents,
|
|
2151
|
+
onTraceDependents,
|
|
2152
|
+
onTracePrecedents,
|
|
2153
|
+
onRemoveArrows,
|
|
2154
|
+
onViewPortChange,
|
|
2155
|
+
evaluateConditionalFormatting,
|
|
2156
|
+
evaluateConditionalFormattingForViewport,
|
|
2157
|
+
evaluateDataValidations,
|
|
2158
|
+
evaluateDataValidationsForViewport,
|
|
2159
|
+
reevaluateDerivationsForCells,
|
|
2160
|
+
applyPatch,
|
|
2161
|
+
generateStatePatches,
|
|
2162
|
+
createHistory,
|
|
2163
|
+
onClearHistory,
|
|
2164
|
+
getFormattingFromRange,
|
|
2165
|
+
getFormulasFromRange,
|
|
2166
|
+
getUserEnteredValuesFromRange,
|
|
2167
|
+
getSheetProperties,
|
|
2168
|
+
importExcelFile,
|
|
2169
|
+
importCSVFile,
|
|
2170
|
+
importCSVFileLegacy,
|
|
2171
|
+
onUpdateSheet,
|
|
2172
|
+
onRequestAddRows,
|
|
2173
|
+
onChangeBatchStream,
|
|
2174
|
+
clearStreamPreview,
|
|
2175
|
+
onDeleteDataValidationRules,
|
|
2176
|
+
onCopy,
|
|
2177
|
+
onPaste,
|
|
2178
|
+
}), [
|
|
2179
|
+
state,
|
|
2180
|
+
handlers,
|
|
2181
|
+
ready,
|
|
2182
|
+
hist,
|
|
2183
|
+
onCommand,
|
|
2184
|
+
undo,
|
|
2185
|
+
redo,
|
|
2186
|
+
onMoveChart,
|
|
2187
|
+
onResizeChart,
|
|
2188
|
+
onCreateConditionalFormattingRule,
|
|
2189
|
+
onRepeatFormatting,
|
|
2190
|
+
onSavePaintFormat,
|
|
2191
|
+
onApplyPaintFormat,
|
|
2192
|
+
paintFormat,
|
|
2193
|
+
paintFormats,
|
|
2194
|
+
onSelectLink,
|
|
2195
|
+
citations,
|
|
2196
|
+
load,
|
|
2197
|
+
importXlsx,
|
|
2198
|
+
setEffectiveValue,
|
|
2199
|
+
registerCustomFunction,
|
|
2200
|
+
activeSheetId,
|
|
2201
|
+
activeCell,
|
|
2202
|
+
selections,
|
|
2203
|
+
onChangeActiveSheet,
|
|
2204
|
+
onChangeActiveCell,
|
|
2205
|
+
onChangeSelectionsInterceptor,
|
|
2206
|
+
onViewPortChange,
|
|
2207
|
+
rowCount,
|
|
2208
|
+
columnCount,
|
|
2209
|
+
frozenRowCount,
|
|
2210
|
+
frozenColumnCount,
|
|
2211
|
+
rowMetadata,
|
|
2212
|
+
columnMetadata,
|
|
2213
|
+
merges,
|
|
2214
|
+
getCellData,
|
|
2215
|
+
getFormattedValue,
|
|
2216
|
+
getUserEnteredValue,
|
|
2217
|
+
getEffectiveValue,
|
|
2218
|
+
getEffectiveExtendedValue,
|
|
2219
|
+
getEffectiveFormat,
|
|
2220
|
+
getHyperlink,
|
|
2221
|
+
getNote,
|
|
2222
|
+
getRowHeight,
|
|
2223
|
+
getColumnWidth,
|
|
2224
|
+
isHiddenRow,
|
|
2225
|
+
isHiddenColumn,
|
|
2226
|
+
getSheetName,
|
|
2227
|
+
getSheetId,
|
|
2228
|
+
getSheetCount,
|
|
2229
|
+
getSheetIndex,
|
|
2230
|
+
getSheetRowCount,
|
|
2231
|
+
getSheetColumnCount,
|
|
2232
|
+
getDataRowCount,
|
|
2233
|
+
findAll,
|
|
2234
|
+
getSeriesValuesFromRange,
|
|
2235
|
+
getDomainValuesFromRange,
|
|
2236
|
+
getColumnarDataFromRange,
|
|
2237
|
+
getTextFormatRuns,
|
|
2238
|
+
getUserEnteredExtendedValue,
|
|
2239
|
+
getUserEnteredFormat,
|
|
2240
|
+
getErrorValue,
|
|
2241
|
+
getMentionsFromCell,
|
|
2242
|
+
getDataColumnCount,
|
|
2243
|
+
getNonEmptyRowCount,
|
|
2244
|
+
getNonEmptyColumnCount,
|
|
2245
|
+
onSelectNextSheet,
|
|
2246
|
+
onSelectPreviousSheet,
|
|
2247
|
+
bandedRanges,
|
|
2248
|
+
basicFilter,
|
|
2249
|
+
hostHiddenRowRanges,
|
|
2250
|
+
arrows,
|
|
2251
|
+
conditionalFormats,
|
|
2252
|
+
tables,
|
|
2253
|
+
namedRanges,
|
|
2254
|
+
protectedRanges,
|
|
2255
|
+
embeds,
|
|
2256
|
+
slicers,
|
|
2257
|
+
getDataValidation,
|
|
2258
|
+
activeCellBySheetId,
|
|
2259
|
+
selectionsBySheetId,
|
|
2260
|
+
cellXfsRegistry,
|
|
2261
|
+
sharedStringRegistry,
|
|
2262
|
+
getUserEnteredValuesFromRange,
|
|
2263
|
+
getFormulasFromRange,
|
|
2264
|
+
getFormattingFromRange,
|
|
2265
|
+
getPrecedents,
|
|
2266
|
+
getDependents,
|
|
2267
|
+
onTraceDependents,
|
|
2268
|
+
onTracePrecedents,
|
|
2269
|
+
onRemoveArrows,
|
|
2270
|
+
onUpdateSheet,
|
|
2271
|
+
onChangeBatchStream,
|
|
2272
|
+
clearStreamPreview,
|
|
2273
|
+
onRequestAddRows,
|
|
2274
|
+
onDeleteDataValidationRules,
|
|
2275
|
+
onClearHistory,
|
|
2276
|
+
onCopy,
|
|
2277
|
+
onPaste,
|
|
2278
|
+
]);
|
|
2279
|
+
}
|