@rowsncolumns/y-spreadsheet 14.0.32

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/LICENSE.md +100 -0
  2. package/Readme.md +77 -0
  3. package/dist/cjs/helpers.d.ts +179 -0
  4. package/dist/cjs/helpers.d.ts.map +1 -0
  5. package/dist/cjs/helpers.js +1635 -0
  6. package/dist/cjs/index.d.ts +6 -0
  7. package/dist/cjs/index.d.ts.map +1 -0
  8. package/dist/cjs/index.js +8 -0
  9. package/dist/cjs/observables.d.ts +10 -0
  10. package/dist/cjs/observables.d.ts.map +1 -0
  11. package/dist/cjs/observables.js +90 -0
  12. package/dist/cjs/profiling/batch-profile.spec.d.ts +2 -0
  13. package/dist/cjs/profiling/batch-profile.spec.d.ts.map +1 -0
  14. package/dist/cjs/profiling/batch-profile.spec.js +175 -0
  15. package/dist/cjs/tsconfig.cjs.tsbuildinfo +1 -0
  16. package/dist/cjs/types.d.ts +62 -0
  17. package/dist/cjs/types.d.ts.map +1 -0
  18. package/dist/cjs/types.js +2 -0
  19. package/dist/cjs/use-yspreadsheet-v2.d.ts +152 -0
  20. package/dist/cjs/use-yspreadsheet-v2.d.ts.map +1 -0
  21. package/dist/cjs/use-yspreadsheet-v2.js +980 -0
  22. package/dist/cjs/use-yspreadsheet.d.ts +39 -0
  23. package/dist/cjs/use-yspreadsheet.d.ts.map +1 -0
  24. package/dist/cjs/use-yspreadsheet.js +430 -0
  25. package/dist/cjs/vitest.config.d.ts +3 -0
  26. package/dist/cjs/vitest.config.d.ts.map +1 -0
  27. package/dist/cjs/vitest.config.js +23 -0
  28. package/dist/esm/helpers.d.ts +179 -0
  29. package/dist/esm/helpers.d.ts.map +1 -0
  30. package/dist/esm/helpers.js +1346 -0
  31. package/dist/esm/index.d.ts +6 -0
  32. package/dist/esm/index.d.ts.map +1 -0
  33. package/dist/esm/index.js +5 -0
  34. package/dist/esm/observables.d.ts +10 -0
  35. package/dist/esm/observables.d.ts.map +1 -0
  36. package/dist/esm/observables.js +51 -0
  37. package/dist/esm/profiling/batch-profile.spec.d.ts +2 -0
  38. package/dist/esm/profiling/batch-profile.spec.d.ts.map +1 -0
  39. package/dist/esm/profiling/batch-profile.spec.js +131 -0
  40. package/dist/esm/tsconfig.esm.tsbuildinfo +1 -0
  41. package/dist/esm/types.d.ts +62 -0
  42. package/dist/esm/types.d.ts.map +1 -0
  43. package/dist/esm/types.js +1 -0
  44. package/dist/esm/use-yspreadsheet-v2.d.ts +152 -0
  45. package/dist/esm/use-yspreadsheet-v2.d.ts.map +1 -0
  46. package/dist/esm/use-yspreadsheet-v2.js +814 -0
  47. package/dist/esm/use-yspreadsheet.d.ts +39 -0
  48. package/dist/esm/use-yspreadsheet.d.ts.map +1 -0
  49. package/dist/esm/use-yspreadsheet.js +304 -0
  50. package/dist/esm/vitest.config.d.ts +3 -0
  51. package/dist/esm/vitest.config.d.ts.map +1 -0
  52. package/dist/esm/vitest.config.js +21 -0
  53. package/package.json +54 -0
@@ -0,0 +1,1346 @@
1
+ import { cellToAddress, getCellEffectiveValue, getCellUserEnteredValue, getExtendedValueBool, getExtendedValueError, getExtendedValueFormula, getExtendedValueNumber, getExtendedValueString, } from "@rowsncolumns/utils";
2
+ import * as Y from "yjs";
3
+ import cloneDeep from "lodash/cloneDeep";
4
+ import isEqual from "lodash/isEqual";
5
+ import set from "lodash/set";
6
+ import get from "lodash/get";
7
+ import unset from "lodash/unset";
8
+ /**
9
+ * Apply patches to yjs
10
+ * @param doc
11
+ * @param userId
12
+ * @param patches
13
+ * @param source
14
+ * @param sheetDataVersion - Version of sheet data format ("v2" or "v3"). Falls back to docMeta if undefined.
15
+ */
16
+ export const applyPatchesToYDoc = (doc, userId, patches, source, sheetDataVersion) => {
17
+ const ySheetDataV2 = doc.getMap("sheetDataV2");
18
+ const ySheetDataV3 = doc.getMap("sheetDataV3");
19
+ const yDocMeta = doc.getMap("docMeta");
20
+ const yRecalcCells = doc.getArray("recalcCells");
21
+ const ySheets = doc.getArray("sheets");
22
+ const yTables = doc.getArray("tables");
23
+ const yEmbeds = doc.getArray("embeds");
24
+ const yCharts = doc.getArray("charts");
25
+ const yConditionalFormats = doc.getArray("conditionalFormats");
26
+ const yDataValidations = doc.getArray("dataValidations");
27
+ const yNamedRanges = doc.getArray("namedRanges");
28
+ const ySharedStringsMap = doc.getMap("sharedStringsMap");
29
+ const yProtectedRanges = doc.getArray("protectedRanges");
30
+ const yCellXfs = doc.getMap("cellXfs");
31
+ const yDisableRecalc = doc.getMap("disableRecalc");
32
+ // Check version flag to determine which sheet data format to use
33
+ // Use param if provided, otherwise fall back to docMeta for backwards compatibility
34
+ const resolvedVersion = sheetDataVersion ?? yDocMeta.get("sheetDataVersion");
35
+ const isV3 = resolvedVersion === "v3";
36
+ for (let [patch, type = "redo"] of patches) {
37
+ type = type ?? "redo";
38
+ const patchKey = type === "redo" ? "patches" : "inversePatches";
39
+ if (patch.sheetData) {
40
+ const patches = patch.sheetData?.[patchKey];
41
+ if (isV3) {
42
+ addSheetDataPatchesV3(ySheetDataV3, patches);
43
+ }
44
+ else {
45
+ addSheetDataPatches(ySheetDataV2, patches);
46
+ }
47
+ }
48
+ if (patch.sheets) {
49
+ const patches = patch.sheets[patchKey];
50
+ updateArrayType(ySheets, patches);
51
+ }
52
+ if (patch.tables) {
53
+ const patches = patch.tables[patchKey];
54
+ updateArrayType(yTables, patches);
55
+ }
56
+ if (patch.embeds) {
57
+ const patches = patch.embeds[patchKey];
58
+ updateArrayType(yEmbeds, patches);
59
+ }
60
+ if (patch.charts) {
61
+ const patches = patch.charts[patchKey];
62
+ updateArrayType(yCharts, patches);
63
+ }
64
+ if (patch.conditionalFormats) {
65
+ const patches = patch.conditionalFormats[patchKey];
66
+ updateArrayType(yConditionalFormats, patches);
67
+ }
68
+ if (patch.dataValidations) {
69
+ const patches = patch.dataValidations[patchKey];
70
+ updateArrayType(yDataValidations, patches);
71
+ }
72
+ if (patch.namedRanges) {
73
+ const patches = patch.namedRanges[patchKey];
74
+ updateArrayType(yNamedRanges, patches);
75
+ }
76
+ if (patch.sharedStrings) {
77
+ const patches = patch.sharedStrings[patchKey];
78
+ // Always use Map for shared strings
79
+ applyMapPatches(ySharedStringsMap, patches);
80
+ }
81
+ if (patch.protectedRanges) {
82
+ const patches = patch.protectedRanges[patchKey];
83
+ updateArrayType(yProtectedRanges, patches);
84
+ }
85
+ if (patch.cellXfs) {
86
+ const patches = patch.cellXfs[patchKey];
87
+ applyMapPatches(yCellXfs, patches);
88
+ }
89
+ // Recalc cells
90
+ if (patch.recalcCells?.[type]) {
91
+ // Removing this
92
+ // multiple calls to update_sheet_range deletes recalc cells
93
+ // yRecalcCells.delete(0, yRecalcCells.length);
94
+ yRecalcCells.push([
95
+ {
96
+ userId,
97
+ patches: Array.from(patch.recalcCells[type]).map((value) => {
98
+ return [...value, source ?? "agent"];
99
+ }),
100
+ },
101
+ ]);
102
+ }
103
+ }
104
+ // Set disable recalc flag (only if it's currently truthy, to avoid redundant Y.Map writes)
105
+ if (yDisableRecalc.get("disableRecalc") !== false) {
106
+ yDisableRecalc.set("disableRecalc", false);
107
+ }
108
+ };
109
+ /**
110
+ * Add sheet data to a yjs map using immer patches
111
+ *
112
+ * Optimized for bulk operations (paste, import) by consolidating cell-level
113
+ * patches before applying to Y.js. This reduces overhead by:
114
+ * 1. Deduplicating patches targeting the same cell (later patches win)
115
+ * 2. Grouping updates by sheet/row for efficient batch application
116
+ */
117
+ export const addSheetDataPatches = (ySheetData, patches) => {
118
+ // Optimization: Pre-process and consolidate cell-level patches
119
+ // For bulk operations like paste (10k+ rows), this dramatically reduces overhead
120
+ // Key format: "sheetId:rowIndex:columnIndex"
121
+ const cellUpdates = new Map();
122
+ const otherPatches = [];
123
+ // First pass: categorize and consolidate patches
124
+ for (const patch of patches) {
125
+ const { op, path, value } = patch;
126
+ const [sheetIdRaw] = path;
127
+ // Handle undefined sheetId separately (full replace)
128
+ if (typeof sheetIdRaw === "undefined") {
129
+ otherPatches.push(patch);
130
+ continue;
131
+ }
132
+ // Use string representation for sheet ID to handle both string and numeric IDs
133
+ const sheetIdStr = String(sheetIdRaw);
134
+ if (op === "replace" || op === "add") {
135
+ // [sheetId, rowIndex, 'values', columnIndex] - Single cell update (most common in paste)
136
+ if (path.length === 4) {
137
+ const [, rowIndex, , columnIndex] = path;
138
+ const rowIdx = Number(rowIndex);
139
+ const colIdx = Number(columnIndex);
140
+ const key = `${sheetIdStr}:${rowIdx}:${colIdx}`;
141
+ // Later patches override earlier ones for the same cell
142
+ cellUpdates.set(key, { sheetIdStr, rowIdx, colIdx, value });
143
+ }
144
+ else {
145
+ // Row-level or sheet-level updates
146
+ otherPatches.push(patch);
147
+ }
148
+ }
149
+ else if (op === "remove") {
150
+ if (path.length === 4) {
151
+ const [, rowIndex, , columnIndex] = path;
152
+ const rowIdx = Number(rowIndex);
153
+ const colIdx = Number(columnIndex);
154
+ const key = `${sheetIdStr}:${rowIdx}:${colIdx}`;
155
+ cellUpdates.set(key, { sheetIdStr, rowIdx, colIdx, value: null });
156
+ }
157
+ else {
158
+ otherPatches.push(patch);
159
+ }
160
+ }
161
+ else {
162
+ otherPatches.push(patch);
163
+ }
164
+ }
165
+ // Second pass: Group cell updates by sheet and row for efficient batch application
166
+ // This minimizes Y.js operations by batching updates to the same row
167
+ const sheetRowUpdates = new Map();
168
+ for (const [, { sheetIdStr, rowIdx, colIdx, value }] of cellUpdates) {
169
+ if (!sheetRowUpdates.has(sheetIdStr)) {
170
+ sheetRowUpdates.set(sheetIdStr, new Map());
171
+ }
172
+ const rowUpdates = sheetRowUpdates.get(sheetIdStr);
173
+ if (!rowUpdates.has(rowIdx)) {
174
+ rowUpdates.set(rowIdx, new Map());
175
+ }
176
+ rowUpdates.get(rowIdx).set(colIdx, value);
177
+ }
178
+ // Apply batched cell updates
179
+ for (const [sheetIdStr, rowUpdates] of sheetRowUpdates) {
180
+ const ysheet = getSheetFromYSheetData(ySheetData, sheetIdStr);
181
+ for (const [rowIdx, colUpdates] of rowUpdates) {
182
+ const rowData = getRowDataFromSheet(ysheet, rowIdx);
183
+ for (const [colIdx, value] of colUpdates) {
184
+ setCellDataInRowData(rowData, colIdx, value);
185
+ }
186
+ }
187
+ }
188
+ // Process non-cell-level patches using original logic
189
+ const processPatch = ({ op, path, value }) => {
190
+ let [sheetId] = path;
191
+ if (typeof sheetId === "undefined") {
192
+ // Replace. User is replacing the entire data
193
+ if (op === "replace") {
194
+ // Remove all sheet data
195
+ ySheetData.clear();
196
+ }
197
+ // Assign values
198
+ for (const curSheetId in value) {
199
+ let sheetId = Number(curSheetId);
200
+ processPatch({
201
+ op: "replace",
202
+ path: [sheetId],
203
+ value: value[curSheetId],
204
+ });
205
+ }
206
+ return;
207
+ }
208
+ if (op === "replace" || op === "add") {
209
+ const ysheet = getSheetFromYSheetData(ySheetData, sheetId);
210
+ // Cell-level patches (path.length === 4) are already handled above via consolidation
211
+ // [sheetId, rowIndex, 'values'] - Replace values array directly
212
+ if (path.length === 3 && path[2] === "values") {
213
+ const [, rowIndex] = path;
214
+ const rowData = getRowDataFromSheet(ysheet, rowIndex);
215
+ // Clear existing row data
216
+ rowData.delete(0, rowData.length);
217
+ if (Array.isArray(value)) {
218
+ // Value is the values array directly
219
+ // Sort keys to maintain order during insertion
220
+ Object.entries(value)
221
+ .sort(([a], [b]) => Number(a) - Number(b))
222
+ .forEach(([columnIndex, cellData]) => {
223
+ setCellDataInRowData(rowData, Number(columnIndex), cellData);
224
+ });
225
+ }
226
+ }
227
+ // [sheetId, rowIndex] - Entire row update
228
+ else if (path.length === 2) {
229
+ const [, rowIndex] = path;
230
+ if (value?.values) {
231
+ // Replace entire row with new values
232
+ const rowData = getRowDataFromSheet(ysheet, rowIndex);
233
+ // Clear existing row data
234
+ rowData.delete(0, rowData.length);
235
+ // Add new cell values
236
+ // Sort keys to maintain order during insertion
237
+ Object.entries(value.values)
238
+ .sort(([a], [b]) => Number(a) - Number(b))
239
+ .forEach(([columnIndex, cellData]) => {
240
+ setCellDataInRowData(rowData, Number(columnIndex), cellData);
241
+ });
242
+ }
243
+ else {
244
+ // Remove row if value is null/undefined
245
+ removeRowData(ysheet, rowIndex);
246
+ }
247
+ }
248
+ // [sheetId] - Entire sheet update
249
+ else if (path.length === 1) {
250
+ if (value) {
251
+ // Clear entire sheet
252
+ ysheet.delete(0, ysheet.length);
253
+ // Add all new data
254
+ Object.entries(value)
255
+ .sort(([a], [b]) => Number(a) - Number(b))
256
+ .forEach(([rowIndex, rowData]) => {
257
+ if (rowData?.values) {
258
+ const rowDataArray = getRowDataFromSheet(ysheet, Number(rowIndex));
259
+ Object.entries(rowData.values)
260
+ .sort(([a], [b]) => Number(a) - Number(b))
261
+ .forEach(([columnIndex, cellData]) => {
262
+ setCellDataInRowData(rowDataArray, Number(columnIndex), cellData);
263
+ });
264
+ }
265
+ });
266
+ }
267
+ }
268
+ }
269
+ else if (op === "remove") {
270
+ // Cell-level removes (path.length === 4) are already handled above via consolidation
271
+ // [sheetId, rowIndex] - Remove entire row
272
+ if (path.length === 2) {
273
+ const [, rowIndex] = path;
274
+ const ysheet = getSheetFromYSheetData(ySheetData, sheetId);
275
+ removeRowData(ysheet, rowIndex);
276
+ }
277
+ // [sheetId] - Remove entire sheet
278
+ else if (path.length === 1) {
279
+ deleteSheetFromYSheetData(ySheetData, sheetId);
280
+ }
281
+ }
282
+ };
283
+ // Only process non-cell-level patches (cell-level already handled via consolidation)
284
+ otherPatches.forEach(processPatch);
285
+ };
286
+ export const getSheetFromYSheetData = (ySheetData, sheetId) => {
287
+ const id = String(sheetId);
288
+ let sheet = ySheetData.get(id);
289
+ if (!sheet) {
290
+ sheet = new Y.Array();
291
+ ySheetData.set(id, sheet);
292
+ }
293
+ return sheet;
294
+ };
295
+ export const deleteSheetFromYSheetData = (ySheetData, sheetId) => {
296
+ const id = String(sheetId);
297
+ ySheetData.delete(id);
298
+ };
299
+ export const getRowDataFromSheet = (ysheet, rowIndex) => {
300
+ const index = Number(rowIndex);
301
+ if (index < 0) {
302
+ throw new Error("Row index cannot be negative");
303
+ }
304
+ // Extend array if needed
305
+ if (index >= ysheet.length) {
306
+ const padding = new Array(index - ysheet.length).fill(null);
307
+ ysheet.insert(ysheet.length, padding);
308
+ ysheet.insert(index, [null]); // Insert null first
309
+ // Create new row data
310
+ const newRowData = new Y.Map();
311
+ newRowData.set("values", new Y.Array());
312
+ // Replace the null
313
+ ysheet.insert(index, [newRowData]);
314
+ ysheet.delete(index + 1, 1);
315
+ // Delete the initially inserted null (wait, index shifts)
316
+ // index is target.
317
+ // Insert padding up to index. length = index.
318
+ // Insert null at index. length = index+1.
319
+ // Replace null at index.
320
+ // Insert newRowData at index. length = index+2.
321
+ // Delete at index+1. length = index+1.
322
+ // Correct.
323
+ return newRowData.get("values");
324
+ }
325
+ // Get or create existing row data
326
+ let rowData = ysheet.get(index);
327
+ if (!rowData || !rowData.get("values")) {
328
+ // Create new row data if it doesn't exist or is incomplete
329
+ const newRowData = new Y.Map();
330
+ newRowData.set("values", new Y.Array());
331
+ // Replace the existing item
332
+ ysheet.insert(index, [newRowData]);
333
+ ysheet.delete(index + 1, 1);
334
+ return newRowData.get("values");
335
+ }
336
+ return rowData.get("values");
337
+ };
338
+ export const setCellDataInRowData = (rowData, columnIndex, cellData = null) => {
339
+ const index = Number(columnIndex);
340
+ // Extend array if needed
341
+ if (index >= rowData.length) {
342
+ const padding = new Array(index - rowData.length).fill(null);
343
+ if (padding.length > 0) {
344
+ rowData.insert(rowData.length, padding);
345
+ }
346
+ }
347
+ if (index === rowData.length) {
348
+ rowData.insert(index, [cellData]);
349
+ }
350
+ else {
351
+ // Replace cell data
352
+ rowData.insert(index, [cellData]);
353
+ rowData.delete(index + 1, 1);
354
+ }
355
+ };
356
+ export const removeRowData = (ysheet, rowIndex) => {
357
+ const index = Number(rowIndex);
358
+ // Only remove if index is valid
359
+ if (index >= 0 && index < ysheet.length) {
360
+ ysheet.insert(index, [null]);
361
+ ysheet.delete(index + 1, 1);
362
+ }
363
+ };
364
+ /**
365
+ * Read a cell's data from the V2 Y.js structure without creating any
366
+ * missing sheet/row/values containers (unlike getSheetFromYSheetData /
367
+ * getRowDataFromSheet, which lazily create them).
368
+ */
369
+ export const getCellDataFromYSheetData = (ySheetData, sheetId, rowIndex, columnIndex) => {
370
+ const sheet = ySheetData.get(String(sheetId));
371
+ const rowIdx = Number(rowIndex);
372
+ if (!sheet || rowIdx < 0 || rowIdx >= sheet.length) {
373
+ return undefined;
374
+ }
375
+ const row = sheet.get(rowIdx);
376
+ const values = row?.get("values");
377
+ const colIdx = Number(columnIndex);
378
+ if (!values || colIdx < 0 || colIdx >= values.length) {
379
+ return undefined;
380
+ }
381
+ return values.get(colIdx);
382
+ };
383
+ /**
384
+ * Extract the error effective value from cell data, if any.
385
+ * SheetCell.setErrorValue persists formula errors as
386
+ * `ev.ev = { type: "Error", name, message }` (legacy long form:
387
+ * `effectiveValue.errorValue`).
388
+ */
389
+ export const getCellDataErrorValue = (cellData) => {
390
+ return getExtendedValueError(getCellEffectiveValue(cellData));
391
+ };
392
+ /**
393
+ * Keep only the cell-level sheetData patches
394
+ * (`[sheetId, rowIndex, "values", columnIndex]`) whose new cell data carries
395
+ * an error effective value (#VALUE!, #NAME?, …). Used to let calc-result
396
+ * errors bypass the leader gate: errors must reach the Y.Doc even from
397
+ * non-leader clients, otherwise they render blank after reload.
398
+ */
399
+ export const filterErrorCellPatches = (patches) => {
400
+ return patches.filter((patch) => (patch.op === "add" || patch.op === "replace") &&
401
+ patch.path.length === 4 &&
402
+ patch.path[2] === "values" &&
403
+ Boolean(getCellDataErrorValue(patch.value)));
404
+ };
405
+ /**
406
+ * Normalize an extended value (short or legacy long keys) into the short-key
407
+ * shape so semantically identical values compare equal regardless of which
408
+ * key shape produced them.
409
+ */
410
+ const normalizeExtendedValue = (extValue) => {
411
+ if (extValue === null || extValue === undefined) {
412
+ return undefined;
413
+ }
414
+ const normalized = {};
415
+ const numberValue = getExtendedValueNumber(extValue);
416
+ if (numberValue !== undefined) {
417
+ normalized.nv = numberValue;
418
+ }
419
+ const stringValue = getExtendedValueString(extValue);
420
+ if (stringValue !== undefined) {
421
+ normalized.sv = stringValue;
422
+ }
423
+ const boolValue = getExtendedValueBool(extValue);
424
+ if (boolValue !== undefined) {
425
+ normalized.bv = boolValue;
426
+ }
427
+ const formulaValue = getExtendedValueFormula(extValue);
428
+ if (formulaValue !== undefined) {
429
+ normalized.fv = formulaValue;
430
+ }
431
+ const errorValue = getExtendedValueError(extValue);
432
+ if (errorValue !== undefined) {
433
+ normalized.ev = errorValue;
434
+ }
435
+ return normalized;
436
+ };
437
+ /**
438
+ * Containment guard for the non-leader error-persistence path
439
+ * (use-yspreadsheet-v2 onBroadcastPatch, gated disableRecalc branch).
440
+ * A gated error cell patch may be written ONLY when all of these hold:
441
+ *
442
+ * 1. The incoming cell data carries an error effective value.
443
+ * 2. The current doc cell holds no effective value at all (the
444
+ * blank-after-reload case) or its effective value is itself an error.
445
+ * A non-error result (number/string/bool/formula result) is never
446
+ * overwritten — a stale non-leader error must not clobber a newer
447
+ * leader-written success.
448
+ * 3. If the doc cell exists, its user-entered value (`ue`, short or legacy
449
+ * key shape) matches the incoming patch's — a cell edited after the
450
+ * calculation started is never touched.
451
+ * 4. The persisted error differs from the incoming one (identical errors
452
+ * are dropped so concurrent non-leaders don't append redundant
453
+ * Y.js updates).
454
+ */
455
+ export const shouldPersistErrorCellPatch = (currentCellData, incomingCellData) => {
456
+ const incomingError = getCellDataErrorValue(incomingCellData);
457
+ if (!incomingError) {
458
+ return false;
459
+ }
460
+ const currentEffectiveValue = getCellEffectiveValue(currentCellData);
461
+ const currentError = getExtendedValueError(currentEffectiveValue);
462
+ if (currentEffectiveValue !== null &&
463
+ currentEffectiveValue !== undefined &&
464
+ !currentError) {
465
+ return false;
466
+ }
467
+ if (currentCellData !== null && currentCellData !== undefined) {
468
+ const currentUserEnteredValue = normalizeExtendedValue(getCellUserEnteredValue(currentCellData));
469
+ const incomingUserEnteredValue = normalizeExtendedValue(getCellUserEnteredValue(incomingCellData));
470
+ if (!isEqual(currentUserEnteredValue, incomingUserEnteredValue)) {
471
+ return false;
472
+ }
473
+ }
474
+ return !isEqual(currentError, incomingError);
475
+ };
476
+ /**
477
+ * Build the cell data to write for a gated error patch that passed
478
+ * shouldPersistErrorCellPatch. If the doc cell doesn't exist yet there is
479
+ * nothing to destroy and the incoming cell data is written as-is. If it
480
+ * does exist, ONLY the error effective value is merged in — every other
481
+ * field of the doc cell (ue, formats, notes, …) is preserved so this write
482
+ * path can never remove doc state it didn't produce. The effective value is
483
+ * written into whichever key shape(s) the doc cell already uses (short `ev`
484
+ * by default).
485
+ */
486
+ export const mergeErrorEffectiveValueIntoCellData = (currentCellData, incomingCellData) => {
487
+ if (currentCellData === null || currentCellData === undefined) {
488
+ return incomingCellData;
489
+ }
490
+ const incomingEffectiveValue = getCellEffectiveValue(incomingCellData);
491
+ const merged = { ...currentCellData };
492
+ const hasLegacyKey = "effectiveValue" in merged;
493
+ if (hasLegacyKey) {
494
+ merged.effectiveValue = incomingEffectiveValue;
495
+ }
496
+ if ("ev" in merged || !hasLegacyKey) {
497
+ merged.ev = incomingEffectiveValue;
498
+ }
499
+ return merged;
500
+ };
501
+ /**
502
+ * Update array type with immer patches
503
+ */
504
+ export const updateArrayType = (yarray, patches) => {
505
+ // Consecutive nested-path patches targeting the same array item are
506
+ // consolidated into one cloneDeep + one Y.Array write. An immer splice on
507
+ // a nested array (e.g. a sheet's rowMetadata on row insert/delete) emits
508
+ // one patch per shifted entry; writing each patch individually clones and
509
+ // re-serializes the entire item per entry — O(n²) on large sheets, which
510
+ // freezes the tab.
511
+ let pending = null;
512
+ const flushPending = () => {
513
+ if (!pending) {
514
+ return;
515
+ }
516
+ const { index, item } = pending;
517
+ pending = null;
518
+ ensureArraySize(yarray, index);
519
+ yarray.insert(index, [item]);
520
+ yarray.delete(index + 1, 1);
521
+ };
522
+ patches.forEach(({ op, path, value }) => {
523
+ const [arrayIndex, ...nestedPath] = path;
524
+ const index = Number(arrayIndex);
525
+ if (isNaN(index)) {
526
+ flushPending();
527
+ // User is replacing the entire array
528
+ if (op === "replace") {
529
+ yarray.delete(0, yarray.length);
530
+ yarray.push(value);
531
+ }
532
+ return;
533
+ }
534
+ if (op === "replace" || op === "add") {
535
+ if (nestedPath.length === 0) {
536
+ flushPending();
537
+ // Direct array item replacement
538
+ ensureArraySize(yarray, index);
539
+ yarray.insert(index, [value]);
540
+ yarray.delete(index + 1, 1);
541
+ }
542
+ else {
543
+ // Nested property update within array item
544
+ let updatedItem;
545
+ if (pending && pending.index === index) {
546
+ updatedItem = pending.item;
547
+ }
548
+ else {
549
+ flushPending();
550
+ const item = index < yarray.length ? yarray.get(index) : {};
551
+ const baseItem = item === null || item === undefined ? {} : item;
552
+ // Clone the item and use lodash to set the nested property
553
+ updatedItem = cloneDeep(baseItem);
554
+ pending = { index, item: updatedItem };
555
+ }
556
+ set(updatedItem, nestedPath, value);
557
+ }
558
+ }
559
+ else if (op === "remove") {
560
+ if (nestedPath.length === 0) {
561
+ flushPending();
562
+ // Remove entire array item
563
+ if (index >= 0 && index < yarray.length) {
564
+ yarray.delete(index, 1);
565
+ }
566
+ }
567
+ else {
568
+ // Remove nested property from array item
569
+ let updatedItem = null;
570
+ if (pending && pending.index === index) {
571
+ if (pending.item && typeof pending.item === "object") {
572
+ updatedItem = pending.item;
573
+ }
574
+ }
575
+ else {
576
+ flushPending();
577
+ if (index >= 0 && index < yarray.length) {
578
+ const item = yarray.get(index);
579
+ if (item && typeof item === "object") {
580
+ updatedItem = cloneDeep(item);
581
+ pending = { index, item: updatedItem };
582
+ }
583
+ }
584
+ }
585
+ if (updatedItem) {
586
+ // Check if we're removing from an array
587
+ const parentPath = nestedPath.slice(0, -1);
588
+ const lastKey = nestedPath[nestedPath.length - 1];
589
+ if (parentPath.length > 0) {
590
+ const parentValue = get(updatedItem, parentPath);
591
+ if (Array.isArray(parentValue) && typeof lastKey === "number") {
592
+ // Remove array element using splice
593
+ parentValue.splice(lastKey, 1);
594
+ }
595
+ else {
596
+ // Remove object property
597
+ unset(updatedItem, nestedPath);
598
+ }
599
+ }
600
+ else {
601
+ // Direct property removal
602
+ unset(updatedItem, nestedPath);
603
+ }
604
+ }
605
+ }
606
+ }
607
+ });
608
+ flushPending();
609
+ };
610
+ /**
611
+ * Helper function to ensure array is large enough
612
+ */
613
+ function ensureArraySize(yarray, index) {
614
+ if (index >= yarray.length) {
615
+ const gaps = new Array(index - yarray.length).fill(null);
616
+ yarray.insert(yarray.length, gaps);
617
+ yarray.insert(index, [null]); // Insert placeholder that will be replaced
618
+ }
619
+ }
620
+ /**
621
+ * Normalize values to always be an array, converting object notation if needed
622
+ */
623
+ function normalizeValuesToArray(values) {
624
+ if (!values) {
625
+ return [];
626
+ }
627
+ if (Array.isArray(values)) {
628
+ return [...values];
629
+ }
630
+ // Convert object notation to array
631
+ const entries = Object.entries(values);
632
+ if (entries.length === 0) {
633
+ return [];
634
+ }
635
+ const maxIndex = Math.max(...entries.map(([key]) => Number(key)));
636
+ const result = new Array(maxIndex + 1).fill(null);
637
+ entries.forEach(([key, value]) => {
638
+ result[Number(key)] = value;
639
+ });
640
+ return result;
641
+ }
642
+ /**
643
+ * Validate and clean sheets data
644
+ */
645
+ export const validateSheets = (sheets) => {
646
+ return sheets.filter(Boolean).map((sheet) => ({
647
+ ...sheet,
648
+ // Ensure merges is always an array and filter out falsy values
649
+ merges: sheet.merges?.filter(Boolean),
650
+ }));
651
+ };
652
+ /**
653
+ * Map operations
654
+ */
655
+ export const applyMapPatches = (yMap, patches) => {
656
+ for (const { op, path, value } of patches) {
657
+ if (!path || !path.length) {
658
+ if (op === "remove") {
659
+ // Only clear if there's something to clear
660
+ if (yMap.size > 0)
661
+ yMap.clear();
662
+ }
663
+ else if (op === "replace") {
664
+ // Diff against current contents instead of clear-and-rewrite, so keys
665
+ // whose references haven't changed don't generate Y.Map tombstones.
666
+ const incoming = new Map(value);
667
+ for (const k of Array.from(yMap.keys())) {
668
+ if (!incoming.has(k))
669
+ yMap.delete(k);
670
+ }
671
+ for (const [k, v] of incoming) {
672
+ if (yMap.get(k) !== v)
673
+ yMap.set(k, v);
674
+ }
675
+ }
676
+ continue;
677
+ }
678
+ const index = path[0];
679
+ if (op === "remove") {
680
+ if (yMap.has(index))
681
+ yMap.delete(index);
682
+ }
683
+ else if (op === "add" || op === "replace") {
684
+ if (yMap.get(index) !== value)
685
+ yMap.set(index, value);
686
+ }
687
+ }
688
+ };
689
+ /**
690
+ * Process events to find changes in sheet data
691
+ * Returns a new sheet data object with only the changes applied if possible, or null if a full reload is needed
692
+ */
693
+ export const processSheetDataEvents = (events, currentSheetData, ySheetDataV2) => {
694
+ const changes = new Map();
695
+ const fullReloadSheets = new Set();
696
+ for (const event of events) {
697
+ const path = event.path;
698
+ if (path.length > 0) {
699
+ const sheetId = String(path[0]);
700
+ if (!changes.has(sheetId)) {
701
+ changes.set(sheetId, new Map());
702
+ }
703
+ const rows = changes.get(sheetId);
704
+ // Path structure: [sheetId, rowIndex, "values", colIndex] or [sheetId, rowIndex]
705
+ if (path.length >= 4 && path[2] === "values") {
706
+ const rowIndex = Number(path[1]);
707
+ const columnIndex = Number(path[3]);
708
+ if (!isNaN(rowIndex) && !isNaN(columnIndex)) {
709
+ const existing = rows.get(rowIndex);
710
+ if (existing) {
711
+ if (!existing.full) {
712
+ existing.columns.add(columnIndex);
713
+ }
714
+ }
715
+ else {
716
+ rows.set(rowIndex, {
717
+ full: false,
718
+ columns: new Set([columnIndex]),
719
+ });
720
+ }
721
+ }
722
+ }
723
+ else if (path.length >= 2) {
724
+ const rowIndex = Number(path[1]);
725
+ if (!isNaN(rowIndex)) {
726
+ const existing = rows.get(rowIndex);
727
+ if (existing) {
728
+ existing.full = true;
729
+ existing.columns.clear();
730
+ }
731
+ else {
732
+ rows.set(rowIndex, { full: true, columns: new Set() });
733
+ }
734
+ }
735
+ }
736
+ else if (path.length === 1) {
737
+ if (event.isYArrayEvent && event.delta) {
738
+ let index = 0;
739
+ event.delta.forEach((op) => {
740
+ if (op.retain) {
741
+ index += op.retain;
742
+ }
743
+ if (op.insert) {
744
+ const count = Array.isArray(op.insert) ? op.insert.length : 1;
745
+ for (let i = 0; i < count; i++) {
746
+ const rowIndex = index + i;
747
+ const existing = rows.get(rowIndex);
748
+ if (existing) {
749
+ existing.full = true;
750
+ existing.columns.clear();
751
+ }
752
+ else {
753
+ rows.set(rowIndex, { full: true, columns: new Set() });
754
+ }
755
+ }
756
+ index += count;
757
+ }
758
+ if (op.delete) {
759
+ fullReloadSheets.add(sheetId); // Mark full sheet reload
760
+ }
761
+ });
762
+ }
763
+ }
764
+ }
765
+ }
766
+ if (changes.size > 0 || fullReloadSheets.size > 0) {
767
+ const next = { ...currentSheetData };
768
+ let hasChanges = false;
769
+ fullReloadSheets.forEach((sheetId) => {
770
+ const ySheet = ySheetDataV2.get(sheetId);
771
+ if (!ySheet)
772
+ return;
773
+ const sheetData = ySheet.toJSON();
774
+ next[sheetId] = sheetData;
775
+ hasChanges = true;
776
+ });
777
+ changes.forEach((rowIndices, sheetId) => {
778
+ const ySheet = ySheetDataV2.get(sheetId);
779
+ if (!ySheet)
780
+ return;
781
+ // If full sheet reload needed
782
+ if (fullReloadSheets.has(sheetId)) {
783
+ const sheetData = ySheet.toJSON();
784
+ next[sheetId] = sheetData;
785
+ hasChanges = true;
786
+ return;
787
+ }
788
+ // Partial update
789
+ if (!next[sheetId]) {
790
+ next[sheetId] = [];
791
+ }
792
+ if (next[sheetId].length < ySheet.length) {
793
+ if (next[sheetId].length !== ySheet.length) {
794
+ const sheetData = ySheet.toJSON();
795
+ next[sheetId] = sheetData;
796
+ hasChanges = true;
797
+ return;
798
+ }
799
+ }
800
+ const nextSheet = [...next[sheetId]];
801
+ rowIndices.forEach((change, rowIndex) => {
802
+ if (rowIndex < 0 || rowIndex >= ySheet.length)
803
+ return;
804
+ const yRow = ySheet.get(rowIndex);
805
+ if (change.full) {
806
+ let normalizedRow = null;
807
+ if (yRow) {
808
+ const rowJson = yRow.toJSON();
809
+ normalizedRow = rowJson;
810
+ }
811
+ nextSheet[rowIndex] = normalizedRow;
812
+ }
813
+ else {
814
+ const currentRow = nextSheet[rowIndex] ?? null;
815
+ if (!yRow) {
816
+ if (currentRow) {
817
+ nextSheet[rowIndex] = currentRow;
818
+ }
819
+ return;
820
+ }
821
+ const yValues = yRow.get("values");
822
+ if (!yValues) {
823
+ if (currentRow) {
824
+ nextSheet[rowIndex] = currentRow;
825
+ }
826
+ return;
827
+ }
828
+ const baseRow = currentRow ?? yRow.toJSON();
829
+ if (!baseRow) {
830
+ return;
831
+ }
832
+ const baseValues = baseRow.values;
833
+ // Always normalize to array to ensure .entries() works in calculation code
834
+ const mergedValues = normalizeValuesToArray(baseValues);
835
+ change.columns.forEach((columnIndex) => {
836
+ const nextValue = yValues.get(columnIndex);
837
+ mergedValues[columnIndex] =
838
+ nextValue === undefined ? null : nextValue;
839
+ });
840
+ nextSheet[rowIndex] = {
841
+ ...baseRow,
842
+ values: mergedValues,
843
+ };
844
+ }
845
+ });
846
+ next[sheetId] = nextSheet;
847
+ hasChanges = true;
848
+ });
849
+ return hasChanges ? next : null;
850
+ }
851
+ return null;
852
+ };
853
+ // ============================================================================
854
+ // V3 Helper Functions (Flat Map Structure)
855
+ // ============================================================================
856
+ /**
857
+ * Convert letter to column index (1-based)
858
+ * A = 1, B = 2, ..., Z = 26, AA = 27, etc.
859
+ */
860
+ const letterToColumnIndex = (letters) => {
861
+ return letters
862
+ .toUpperCase()
863
+ .split("")
864
+ .reduce((r, a) => r * 26 + parseInt(a, 36) - 9, 0);
865
+ };
866
+ /**
867
+ * Create a V3 cell key from sheet ID, row and column indices
868
+ * Format: `${sheetId}!${A1Address}` (e.g., "1!A1", "2!B5")
869
+ */
870
+ export const createCellKeyV3 = (sheetId, rowIndex, columnIndex) => {
871
+ const address = cellToAddress({ rowIndex, columnIndex }) ??
872
+ `${String.fromCharCode(65 + columnIndex - 1)}${rowIndex}`;
873
+ return `${sheetId}!${address}`;
874
+ };
875
+ /**
876
+ * Parse a V3 cell key to extract sheet ID and position information
877
+ * Example: parseCellKeyV3("1!A1") => { sheetId: 1, address: "A1", rowIndex: 1, columnIndex: 1 }
878
+ */
879
+ export const parseCellKeyV3 = (cellKey) => {
880
+ const match = cellKey.match(/^(\d+)!([A-Z]+)(\d+)$/i);
881
+ if (!match)
882
+ return null;
883
+ const [, sheetIdStr, columnLetters, rowIndexStr] = match;
884
+ return {
885
+ sheetId: Number(sheetIdStr),
886
+ address: `${columnLetters.toUpperCase()}${rowIndexStr}`,
887
+ rowIndex: Number(rowIndexStr),
888
+ columnIndex: letterToColumnIndex(columnLetters),
889
+ };
890
+ };
891
+ /**
892
+ * Create a CellDataV3 object with position and sheetId
893
+ */
894
+ export const createCellDataV3 = (value, sheetId, rowIndex, columnIndex) => ({
895
+ value,
896
+ sId: sheetId,
897
+ r: rowIndex,
898
+ c: columnIndex,
899
+ });
900
+ /**
901
+ * Convert V2 SheetData to V3 flat Map format
902
+ * V2: { sheetId: [rows with values arrays] }
903
+ * V3: Map<cellKey, CellDataV3>
904
+ */
905
+ export const sheetDataV2ToV3 = (v2Data) => {
906
+ const v3Map = new Map();
907
+ for (const sheetIdStr in v2Data) {
908
+ const sheetId = Number(sheetIdStr);
909
+ const rows = v2Data[sheetId];
910
+ if (!rows)
911
+ continue;
912
+ rows.forEach((row, rowIndex) => {
913
+ if (!row?.values)
914
+ return;
915
+ row.values.forEach((cellData, colIndex) => {
916
+ if (cellData) {
917
+ const key = createCellKeyV3(sheetId, rowIndex, colIndex);
918
+ v3Map.set(key, createCellDataV3(cellData, sheetId, rowIndex, colIndex));
919
+ }
920
+ });
921
+ });
922
+ }
923
+ return v3Map;
924
+ };
925
+ /**
926
+ * Convert V3 flat Map format to V2 SheetData
927
+ * V3: Map<cellKey, CellDataV3>
928
+ * V2: { sheetId: [rows with values arrays] }
929
+ */
930
+ export const sheetDataV3ToV2 = (v3Data) => {
931
+ const sheetData = {};
932
+ for (const [_, cellDataV3] of v3Data) {
933
+ if (!cellDataV3)
934
+ continue;
935
+ const { sId: sheetId, r: rowIdx, c: colIdx, value } = cellDataV3;
936
+ // Ensure sheet exists
937
+ if (!sheetData[sheetId]) {
938
+ sheetData[sheetId] = [];
939
+ }
940
+ // Ensure row exists
941
+ while (sheetData[sheetId].length <= rowIdx) {
942
+ sheetData[sheetId].push(null);
943
+ }
944
+ if (!sheetData[sheetId][rowIdx]) {
945
+ sheetData[sheetId][rowIdx] = { values: [] };
946
+ }
947
+ const row = sheetData[sheetId][rowIdx];
948
+ if (!row.values)
949
+ row.values = [];
950
+ // Ensure values array is long enough
951
+ while (row.values.length <= colIdx) {
952
+ row.values.push(null);
953
+ }
954
+ row.values[colIdx] = value;
955
+ }
956
+ return sheetData;
957
+ };
958
+ /**
959
+ * Get a cell from V3 flat structure
960
+ */
961
+ export const getCellV3 = (ySheetDataV3, sheetId, rowIndex, columnIndex) => {
962
+ const key = createCellKeyV3(sheetId, rowIndex, columnIndex);
963
+ return ySheetDataV3.get(key);
964
+ };
965
+ /**
966
+ * Set a cell in V3 flat structure
967
+ */
968
+ export const setCellV3 = (ySheetDataV3, sheetId, rowIndex, columnIndex, value) => {
969
+ const key = createCellKeyV3(sheetId, rowIndex, columnIndex);
970
+ ySheetDataV3.set(key, createCellDataV3(value, sheetId, rowIndex, columnIndex));
971
+ };
972
+ /**
973
+ * Delete a cell from V3 flat structure
974
+ */
975
+ export const deleteCellV3 = (ySheetDataV3, sheetId, rowIndex, columnIndex) => {
976
+ const key = createCellKeyV3(sheetId, rowIndex, columnIndex);
977
+ ySheetDataV3.delete(key);
978
+ };
979
+ /**
980
+ * Delete all cells for a sheet from V3 flat structure
981
+ */
982
+ export const deleteSheetV3 = (ySheetDataV3, sheetId) => {
983
+ const prefix = `${sheetId}!`;
984
+ for (const key of ySheetDataV3.keys()) {
985
+ if (key.startsWith(prefix)) {
986
+ ySheetDataV3.delete(key);
987
+ }
988
+ }
989
+ };
990
+ /**
991
+ * Get V3 sheet data map from Y.Doc
992
+ */
993
+ export const getSheetDataV3 = (doc) => {
994
+ return doc.getMap("sheetDataV3");
995
+ };
996
+ /**
997
+ * Apply immer patches to V3 sheet data (flat map structure)
998
+ *
999
+ * Optimized for bulk operations (paste, import) by consolidating cell-level
1000
+ * patches before applying to Y.js. This reduces overhead by:
1001
+ * 1. Deduplicating patches targeting the same cell (later patches win)
1002
+ * 2. Processing all cell updates in a single pass
1003
+ */
1004
+ export const addSheetDataPatchesV3 = (ySheetDataV3, patches) => {
1005
+ // Optimization: Pre-process and consolidate cell-level patches
1006
+ // For bulk operations like paste (10k+ rows), this dramatically reduces overhead
1007
+ const cellUpdates = new Map();
1008
+ const otherPatches = [];
1009
+ // First pass: categorize and consolidate patches
1010
+ for (const patch of patches) {
1011
+ const { op, path, value } = patch;
1012
+ const [sheetIdRaw] = path;
1013
+ // Handle undefined sheetId separately (full replace)
1014
+ if (typeof sheetIdRaw === "undefined") {
1015
+ otherPatches.push(patch);
1016
+ continue;
1017
+ }
1018
+ const sheetId = Number(sheetIdRaw);
1019
+ if (op === "replace" || op === "add") {
1020
+ // [sheetId, rowIndex, 'values', columnIndex] - Single cell update (most common in paste)
1021
+ if (path.length === 4) {
1022
+ const [, rowIndex, , columnIndex] = path;
1023
+ const rowIdx = Number(rowIndex);
1024
+ const colIdx = Number(columnIndex);
1025
+ const key = createCellKeyV3(sheetId, rowIdx, colIdx);
1026
+ // Later patches override earlier ones for the same cell
1027
+ cellUpdates.set(key, { sheetId, rowIdx, colIdx, value });
1028
+ }
1029
+ else {
1030
+ // Row-level or sheet-level updates
1031
+ otherPatches.push(patch);
1032
+ }
1033
+ }
1034
+ else if (op === "remove") {
1035
+ if (path.length === 4) {
1036
+ const [, rowIndex, , columnIndex] = path;
1037
+ const rowIdx = Number(rowIndex);
1038
+ const colIdx = Number(columnIndex);
1039
+ const key = createCellKeyV3(sheetId, rowIdx, colIdx);
1040
+ cellUpdates.set(key, { sheetId, rowIdx, colIdx, value: null });
1041
+ }
1042
+ else {
1043
+ otherPatches.push(patch);
1044
+ }
1045
+ }
1046
+ else {
1047
+ otherPatches.push(patch);
1048
+ }
1049
+ }
1050
+ // Second pass: Apply consolidated cell updates in batch
1051
+ // This is much faster than processing each patch individually
1052
+ for (const [key, { sheetId, rowIdx, colIdx, value }] of cellUpdates) {
1053
+ if (value) {
1054
+ ySheetDataV3.set(key, createCellDataV3(value, sheetId, rowIdx, colIdx));
1055
+ }
1056
+ else {
1057
+ ySheetDataV3.delete(key);
1058
+ }
1059
+ }
1060
+ // Row-targeted patches (e.g. the per-shifted-row patches an immer array
1061
+ // splice emits for a row insert/delete) previously scanned the entire flat
1062
+ // cell map once per patch — O(patches × totalCells), which freezes the tab
1063
+ // when inserting a row near the top of a large sheet. Build a
1064
+ // row → cell-keys index once per batch instead, and keep it in sync as
1065
+ // patches apply.
1066
+ let rowCellKeyIndex = null;
1067
+ const rowIndexKey = (sheetId, rowIdx) => `${sheetId}:${rowIdx}`;
1068
+ const ensureRowCellKeyIndex = () => {
1069
+ if (rowCellKeyIndex) {
1070
+ return rowCellKeyIndex;
1071
+ }
1072
+ rowCellKeyIndex = new Map();
1073
+ for (const [key, cellData] of ySheetDataV3.entries()) {
1074
+ const rk = rowIndexKey(cellData?.sId, cellData?.r);
1075
+ let keys = rowCellKeyIndex.get(rk);
1076
+ if (!keys) {
1077
+ keys = new Set();
1078
+ rowCellKeyIndex.set(rk, keys);
1079
+ }
1080
+ keys.add(key);
1081
+ }
1082
+ return rowCellKeyIndex;
1083
+ };
1084
+ const trackCellKeySet = (sheetId, rowIdx, key) => {
1085
+ if (!rowCellKeyIndex) {
1086
+ return;
1087
+ }
1088
+ const rk = rowIndexKey(sheetId, rowIdx);
1089
+ let keys = rowCellKeyIndex.get(rk);
1090
+ if (!keys) {
1091
+ keys = new Set();
1092
+ rowCellKeyIndex.set(rk, keys);
1093
+ }
1094
+ keys.add(key);
1095
+ };
1096
+ const deleteRowCells = (sheetId, rowIdx) => {
1097
+ const keys = ensureRowCellKeyIndex().get(rowIndexKey(sheetId, rowIdx));
1098
+ if (!keys) {
1099
+ return;
1100
+ }
1101
+ for (const key of keys) {
1102
+ ySheetDataV3.delete(key);
1103
+ }
1104
+ rowCellKeyIndex.delete(rowIndexKey(sheetId, rowIdx));
1105
+ };
1106
+ const dropSheetFromIndex = (sheetId) => {
1107
+ if (!rowCellKeyIndex) {
1108
+ return;
1109
+ }
1110
+ const prefix = `${sheetId}:`;
1111
+ for (const rk of Array.from(rowCellKeyIndex.keys())) {
1112
+ if (rk.startsWith(prefix)) {
1113
+ rowCellKeyIndex.delete(rk);
1114
+ }
1115
+ }
1116
+ };
1117
+ // Process non-cell-level patches using original logic
1118
+ const processPatch = ({ op, path, value }) => {
1119
+ const [sheetIdRaw] = path;
1120
+ if (typeof sheetIdRaw === "undefined") {
1121
+ // Replace entire data
1122
+ if (op === "replace") {
1123
+ // Clear all cells
1124
+ for (const key of ySheetDataV3.keys()) {
1125
+ ySheetDataV3.delete(key);
1126
+ }
1127
+ if (rowCellKeyIndex) {
1128
+ rowCellKeyIndex.clear();
1129
+ }
1130
+ }
1131
+ // Assign values from the replacement object
1132
+ for (const curSheetId in value) {
1133
+ const sheetId = Number(curSheetId);
1134
+ const rows = value[curSheetId];
1135
+ if (!rows)
1136
+ continue;
1137
+ Object.entries(rows).forEach(([rowIndexStr, rowData]) => {
1138
+ if (!rowData?.values)
1139
+ return;
1140
+ const rowIdx = Number(rowIndexStr);
1141
+ Object.entries(rowData.values).forEach(([colIndexStr, cellData]) => {
1142
+ if (cellData) {
1143
+ const colIdx = Number(colIndexStr);
1144
+ const key = createCellKeyV3(sheetId, rowIdx, colIdx);
1145
+ ySheetDataV3.set(key, createCellDataV3(cellData, sheetId, rowIdx, colIdx));
1146
+ trackCellKeySet(sheetId, rowIdx, key);
1147
+ }
1148
+ });
1149
+ });
1150
+ }
1151
+ return;
1152
+ }
1153
+ const sheetId = Number(sheetIdRaw);
1154
+ if (op === "replace" || op === "add") {
1155
+ // Cell-level patches (path.length === 4) are already handled above via consolidation
1156
+ // [sheetId, rowIndex, 'values', columnIndex, ...nestedPath] - Deep nested property update
1157
+ if (path.length > 4) {
1158
+ const [, rowIndex, , columnIndex, ...nestedPath] = path;
1159
+ const rowIdx = Number(rowIndex);
1160
+ const colIdx = Number(columnIndex);
1161
+ const key = createCellKeyV3(sheetId, rowIdx, colIdx);
1162
+ // Get existing cell data or create empty
1163
+ const existing = ySheetDataV3.get(key);
1164
+ const cellData = existing?.value ? cloneDeep(existing.value) : {};
1165
+ // Apply nested property change
1166
+ set(cellData, nestedPath, value);
1167
+ ySheetDataV3.set(key, createCellDataV3(cellData, sheetId, rowIdx, colIdx));
1168
+ trackCellKeySet(sheetId, rowIdx, key);
1169
+ }
1170
+ // [sheetId, rowIndex, 'values'] - Replace values array
1171
+ else if (path.length === 3 && path[2] === "values") {
1172
+ const [, rowIndex] = path;
1173
+ const rowIdx = Number(rowIndex);
1174
+ // Delete existing cells for this row
1175
+ deleteRowCells(sheetId, rowIdx);
1176
+ // Add new cells
1177
+ if (Array.isArray(value)) {
1178
+ value.forEach((cellData, colIndex) => {
1179
+ if (cellData) {
1180
+ const key = createCellKeyV3(sheetId, rowIdx, colIndex);
1181
+ ySheetDataV3.set(key, createCellDataV3(cellData, sheetId, rowIdx, colIndex));
1182
+ trackCellKeySet(sheetId, rowIdx, key);
1183
+ }
1184
+ });
1185
+ }
1186
+ }
1187
+ // [sheetId, rowIndex] - Entire row update
1188
+ else if (path.length === 2) {
1189
+ const [, rowIndex] = path;
1190
+ const rowIdx = Number(rowIndex);
1191
+ // Delete existing cells for this row
1192
+ deleteRowCells(sheetId, rowIdx);
1193
+ // Add new cells from row data
1194
+ if (value?.values) {
1195
+ Object.entries(value.values).forEach(([colIndexStr, cellData]) => {
1196
+ if (cellData) {
1197
+ const colIndex = Number(colIndexStr);
1198
+ const key = createCellKeyV3(sheetId, rowIdx, colIndex);
1199
+ ySheetDataV3.set(key, createCellDataV3(cellData, sheetId, rowIdx, colIndex));
1200
+ trackCellKeySet(sheetId, rowIdx, key);
1201
+ }
1202
+ });
1203
+ }
1204
+ }
1205
+ // [sheetId] - Entire sheet update
1206
+ else if (path.length === 1) {
1207
+ // Delete all cells for this sheet
1208
+ const prefix = `${sheetId}!`;
1209
+ for (const key of ySheetDataV3.keys()) {
1210
+ if (key.startsWith(prefix)) {
1211
+ ySheetDataV3.delete(key);
1212
+ }
1213
+ }
1214
+ dropSheetFromIndex(sheetId);
1215
+ // Add all new data
1216
+ if (value) {
1217
+ Object.entries(value).forEach(([rowIndexStr, rowData]) => {
1218
+ if (rowData?.values) {
1219
+ const rowIdx = Number(rowIndexStr);
1220
+ Object.entries(rowData.values).forEach(([colIndexStr, cellData]) => {
1221
+ if (cellData) {
1222
+ const colIndex = Number(colIndexStr);
1223
+ const key = createCellKeyV3(sheetId, rowIdx, colIndex);
1224
+ ySheetDataV3.set(key, createCellDataV3(cellData, sheetId, rowIdx, colIndex));
1225
+ trackCellKeySet(sheetId, rowIdx, key);
1226
+ }
1227
+ });
1228
+ }
1229
+ });
1230
+ }
1231
+ }
1232
+ }
1233
+ else if (op === "remove") {
1234
+ // Cell-level removes (path.length === 4) are already handled above via consolidation
1235
+ // [sheetId, rowIndex, values, columnIndex, ...nestedPath] - Remove nested property
1236
+ if (path.length > 4) {
1237
+ const [, rowIndex, , columnIndex, ...nestedPath] = path;
1238
+ const rowIdx = Number(rowIndex);
1239
+ const colIdx = Number(columnIndex);
1240
+ const key = createCellKeyV3(sheetId, rowIdx, colIdx);
1241
+ const existing = ySheetDataV3.get(key);
1242
+ if (existing?.value && typeof existing.value === "object") {
1243
+ const cellData = cloneDeep(existing.value);
1244
+ unset(cellData, nestedPath);
1245
+ ySheetDataV3.set(key, createCellDataV3(cellData, sheetId, rowIdx, colIdx));
1246
+ trackCellKeySet(sheetId, rowIdx, key);
1247
+ }
1248
+ }
1249
+ // [sheetId, rowIndex] - Remove entire row
1250
+ else if (path.length === 2) {
1251
+ const [, rowIndex] = path;
1252
+ const rowIdx = Number(rowIndex);
1253
+ deleteRowCells(sheetId, rowIdx);
1254
+ }
1255
+ // [sheetId] - Remove entire sheet
1256
+ else if (path.length === 1) {
1257
+ const prefix = `${sheetId}!`;
1258
+ for (const key of ySheetDataV3.keys()) {
1259
+ if (key.startsWith(prefix)) {
1260
+ ySheetDataV3.delete(key);
1261
+ }
1262
+ }
1263
+ dropSheetFromIndex(sheetId);
1264
+ }
1265
+ }
1266
+ };
1267
+ // Only process non-cell-level patches (cell-level already handled via consolidation)
1268
+ otherPatches.forEach(processPatch);
1269
+ };
1270
+ /**
1271
+ * Process V3 events and return updated sheet data
1272
+ * @param changedKeys - Pre-extracted changes from Yjs event
1273
+ * @param currentSheetData - Current sheet data state
1274
+ * @param ySheetDataV3 - The Y.Map for V3 data
1275
+ */
1276
+ export const processSheetDataEventsV3 = (changedKeys, currentSheetData, ySheetDataV3) => {
1277
+ if (changedKeys.size === 0)
1278
+ return null;
1279
+ const next = { ...currentSheetData };
1280
+ // Track which sheets/rows have been cloned to avoid re-cloning
1281
+ const clonedSheets = new Set();
1282
+ const clonedRows = new Map(); // "sheetId:rowIdx" -> cloned
1283
+ let hasChanges = false;
1284
+ changedKeys.forEach((change, cellKey) => {
1285
+ const parsed = parseCellKeyV3(cellKey);
1286
+ if (!parsed)
1287
+ return;
1288
+ const { sheetId, rowIndex, columnIndex } = parsed;
1289
+ const rowIdx = rowIndex;
1290
+ const colIdx = columnIndex;
1291
+ const rowKey = `${sheetId}:${rowIdx}`;
1292
+ // Ensure sheet exists and is cloned (to avoid mutating frozen React state)
1293
+ if (!next[sheetId]) {
1294
+ next[sheetId] = [];
1295
+ clonedSheets.add(sheetId);
1296
+ }
1297
+ else if (!clonedSheets.has(sheetId)) {
1298
+ // Clone the sheet array to avoid mutating frozen state
1299
+ next[sheetId] = [...next[sheetId]];
1300
+ clonedSheets.add(sheetId);
1301
+ }
1302
+ const sheet = next[sheetId];
1303
+ // Extend array efficiently using length assignment instead of while loop
1304
+ if (sheet.length <= rowIdx) {
1305
+ sheet.length = rowIdx + 1;
1306
+ }
1307
+ if (change.action === "delete") {
1308
+ // Cell was deleted
1309
+ const row = sheet[rowIdx];
1310
+ if (row?.values && colIdx < row.values.length && row.values[colIdx]) {
1311
+ // Clone row values only once per row
1312
+ if (!clonedRows.has(rowKey)) {
1313
+ sheet[rowIdx] = { ...row, values: [...row.values] };
1314
+ clonedRows.set(rowKey, true);
1315
+ }
1316
+ sheet[rowIdx].values[colIdx] = null;
1317
+ hasChanges = true;
1318
+ }
1319
+ }
1320
+ else {
1321
+ // Cell was added or updated
1322
+ const cellDataV3 = ySheetDataV3.get(cellKey);
1323
+ if (cellDataV3) {
1324
+ // Ensure row exists
1325
+ if (!sheet[rowIdx]) {
1326
+ sheet[rowIdx] = { values: [] };
1327
+ clonedRows.set(rowKey, true);
1328
+ }
1329
+ else if (!clonedRows.has(rowKey)) {
1330
+ // Clone row values only once per row
1331
+ const row = sheet[rowIdx];
1332
+ sheet[rowIdx] = { ...row, values: row.values ? [...row.values] : [] };
1333
+ clonedRows.set(rowKey, true);
1334
+ }
1335
+ const values = sheet[rowIdx].values;
1336
+ // Extend array efficiently using length assignment
1337
+ if (values.length <= colIdx) {
1338
+ values.length = colIdx + 1;
1339
+ }
1340
+ values[colIdx] = cellDataV3.value;
1341
+ hasChanges = true;
1342
+ }
1343
+ }
1344
+ });
1345
+ return hasChanges ? next : null;
1346
+ };