@lotics/xlsx 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (90) hide show
  1. package/package.json +30 -0
  2. package/src/auto_sum.test.ts +71 -0
  3. package/src/auto_sum.ts +83 -0
  4. package/src/canvas_cf.ts +135 -0
  5. package/src/canvas_chart.ts +687 -0
  6. package/src/canvas_fill.ts +156 -0
  7. package/src/canvas_images.ts +99 -0
  8. package/src/canvas_layout.test.ts +159 -0
  9. package/src/canvas_layout.ts +239 -0
  10. package/src/canvas_renderer.ts +1010 -0
  11. package/src/canvas_shape.ts +242 -0
  12. package/src/canvas_sparkline.ts +163 -0
  13. package/src/canvas_text.ts +454 -0
  14. package/src/cell_editor.ts +558 -0
  15. package/src/clipboard.test.ts +145 -0
  16. package/src/clipboard.ts +341 -0
  17. package/src/command_history.ts +102 -0
  18. package/src/csv_parser.test.ts +130 -0
  19. package/src/csv_parser.ts +172 -0
  20. package/src/data_validation.test.ts +95 -0
  21. package/src/data_validation.ts +114 -0
  22. package/src/editing_layer.test.ts +309 -0
  23. package/src/excel_cf_evaluate.test.ts +293 -0
  24. package/src/excel_cf_evaluate.ts +719 -0
  25. package/src/excel_cf_icons.ts +108 -0
  26. package/src/excel_cf_types.ts +67 -0
  27. package/src/excel_numfmt.test.ts +607 -0
  28. package/src/excel_numfmt.ts +1033 -0
  29. package/src/excel_parser.test.ts +1061 -0
  30. package/src/excel_parser.ts +393 -0
  31. package/src/excel_pattern_fills.ts +64 -0
  32. package/src/excel_table_styles.ts +160 -0
  33. package/src/excel_utils.test.ts +108 -0
  34. package/src/excel_utils.ts +162 -0
  35. package/src/fast-formula-parser.d.ts +53 -0
  36. package/src/fill_logic.ts +257 -0
  37. package/src/fill_patterns.test.ts +90 -0
  38. package/src/fill_patterns.ts +71 -0
  39. package/src/flash_fill.test.ts +75 -0
  40. package/src/flash_fill.ts +221 -0
  41. package/src/formula_deps.ts +189 -0
  42. package/src/formula_engine.test.ts +348 -0
  43. package/src/formula_engine.ts +401 -0
  44. package/src/formula_highlight.test.ts +81 -0
  45. package/src/formula_highlight.ts +139 -0
  46. package/src/formula_suggest.ts +100 -0
  47. package/src/hidden_sheets.test.ts +49 -0
  48. package/src/index.ts +149 -0
  49. package/src/keyboard_nav.test.ts +394 -0
  50. package/src/keyboard_nav.ts +891 -0
  51. package/src/move_logic.ts +63 -0
  52. package/src/numfmt_type.test.ts +158 -0
  53. package/src/numfmt_type.ts +231 -0
  54. package/src/ooxml_cell_format.ts +70 -0
  55. package/src/ooxml_chart.test.ts +85 -0
  56. package/src/ooxml_chart.ts +347 -0
  57. package/src/ooxml_chart_types.ts +207 -0
  58. package/src/ooxml_color.ts +339 -0
  59. package/src/ooxml_drawing.test.ts +87 -0
  60. package/src/ooxml_drawing.ts +287 -0
  61. package/src/ooxml_pivot.test.ts +195 -0
  62. package/src/ooxml_pivot.ts +468 -0
  63. package/src/ooxml_pivot_types.ts +165 -0
  64. package/src/ooxml_shape.ts +355 -0
  65. package/src/ooxml_sheet.ts +1271 -0
  66. package/src/ooxml_styles.ts +556 -0
  67. package/src/ooxml_table.test.ts +70 -0
  68. package/src/ooxml_table.ts +131 -0
  69. package/src/ooxml_workbook.test.ts +40 -0
  70. package/src/ooxml_workbook.ts +259 -0
  71. package/src/ooxml_zip.ts +33 -0
  72. package/src/pivot_model.ts +237 -0
  73. package/src/pivot_recompute.test.ts +210 -0
  74. package/src/pivot_recompute.ts +413 -0
  75. package/src/selection_model.ts +364 -0
  76. package/src/spreadsheet_commands.test.ts +772 -0
  77. package/src/spreadsheet_commands.ts +1805 -0
  78. package/src/structured_refs.test.ts +128 -0
  79. package/src/structured_refs.ts +160 -0
  80. package/src/style_helpers.test.ts +33 -0
  81. package/src/style_helpers.ts +30 -0
  82. package/src/template_builder.test.ts +139 -0
  83. package/src/template_builder.ts +36 -0
  84. package/src/types.ts +239 -0
  85. package/src/workbook_model.test.ts +536 -0
  86. package/src/workbook_model.ts +911 -0
  87. package/src/xlsx_round_trip.test.ts +279 -0
  88. package/src/xlsx_writer.test.ts +488 -0
  89. package/src/xlsx_writer.ts +1549 -0
  90. package/src/xml_entities.ts +23 -0
@@ -0,0 +1,1805 @@
1
+ // =============================================================================
2
+ // Spreadsheet Commands
3
+ // =============================================================================
4
+ //
5
+ // Reversible command implementations for all spreadsheet mutations.
6
+ // Each command captures enough state to undo itself.
7
+ // Used with CommandHistory for undo/redo support.
8
+ // =============================================================================
9
+
10
+ import type { Command } from "./command_history";
11
+ import { SheetModel } from "./workbook_model";
12
+ import type { WorkbookModel, CellValue } from "./workbook_model";
13
+ import type { CellStyle, CellContent } from "./types";
14
+ import type { ParsedConditionalFormat } from "./excel_cf_types";
15
+ import type { FormulaEngine } from "./formula_engine";
16
+ import type { PivotTableModel } from "./pivot_model";
17
+ import type { ParsedPivotTable } from "./ooxml_pivot_types";
18
+ import { rowColToRef, refToRowCol } from "./excel_utils";
19
+ import { adjustFormulaRefsForRowShift, adjustFormulaRefsForColShift } from "./clipboard";
20
+
21
+ // =============================================================================
22
+ // Set Cell Value
23
+ // =============================================================================
24
+
25
+ export function setCellValueCommand(
26
+ workbook: WorkbookModel,
27
+ engine: FormulaEngine | undefined,
28
+ sheetIndex: number,
29
+ row: number,
30
+ col: number,
31
+ newValue: string,
32
+ ): Command {
33
+ const sheet = workbook.sheets[sheetIndex];
34
+ const ref = rowColToRef(row, col);
35
+ const oldCell = sheet.getCell(ref);
36
+ const oldValue = oldCell?.value ?? null;
37
+ const oldFormula = oldCell?.formula;
38
+ // Determine if this is a formula
39
+ const isFormula = newValue.startsWith("=");
40
+ const formula = isFormula ? newValue.slice(1) : undefined;
41
+ const value: CellValue = isFormula
42
+ ? null // Will be computed by formula engine
43
+ : parseInputValue(newValue);
44
+
45
+ const styleIndex = oldCell?.styleIndex ?? 0;
46
+ const numFmtCode = oldCell?.numFmtCode ?? "General";
47
+
48
+ return {
49
+ description: `Set ${ref} = ${newValue}`,
50
+ execute() {
51
+ sheet.set(ref, value, sheet.styles?.get(styleIndex), numFmtCode, formula);
52
+ if (engine) {
53
+ engine.onCellChanged(sheet.name, ref, formula);
54
+ }
55
+ workbook.emit({
56
+ type: "cell_value",
57
+ sheet: sheetIndex,
58
+ ref,
59
+ oldValue,
60
+ newValue: value,
61
+ });
62
+ },
63
+ undo() {
64
+ if (oldValue === null && !oldFormula) {
65
+ sheet.deleteCell(ref);
66
+ } else {
67
+ sheet.set(ref, oldValue, sheet.styles?.get(styleIndex), numFmtCode, oldFormula);
68
+ }
69
+ if (engine) {
70
+ engine.onCellChanged(sheet.name, ref, oldFormula);
71
+ }
72
+ workbook.emit({
73
+ type: "cell_value",
74
+ sheet: sheetIndex,
75
+ ref,
76
+ oldValue: value,
77
+ newValue: oldValue,
78
+ });
79
+ },
80
+ };
81
+ }
82
+
83
+ // =============================================================================
84
+ // Set Cell Style
85
+ // =============================================================================
86
+
87
+ export function setCellStyleCommand(
88
+ workbook: WorkbookModel,
89
+ sheetIndex: number,
90
+ row: number,
91
+ col: number,
92
+ styleOverride: Partial<CellStyle>,
93
+ ): Command {
94
+ const sheet = workbook.sheets[sheetIndex];
95
+ const ref = rowColToRef(row, col);
96
+ const cell = sheet.getCell(ref);
97
+ const oldStyleIndex = cell?.styleIndex ?? 0;
98
+ const newStyleIndex = workbook.styles.merge(oldStyleIndex, styleOverride);
99
+
100
+ return {
101
+ description: `Style ${ref}`,
102
+ execute() {
103
+ let c = sheet.getCell(ref);
104
+ if (!c) {
105
+ c = sheet.set(ref, null, sheet.styles?.get(newStyleIndex));
106
+ } else {
107
+ c.styleIndex = newStyleIndex;
108
+ }
109
+ workbook.emit({
110
+ type: "cell_style",
111
+ sheet: sheetIndex,
112
+ ref,
113
+ oldStyle: oldStyleIndex,
114
+ newStyle: newStyleIndex,
115
+ });
116
+ },
117
+ undo() {
118
+ let c = sheet.getCell(ref);
119
+ if (!c) {
120
+ c = sheet.set(ref, null, sheet.styles?.get(oldStyleIndex));
121
+ } else {
122
+ c.styleIndex = oldStyleIndex;
123
+ }
124
+ workbook.emit({
125
+ type: "cell_style",
126
+ sheet: sheetIndex,
127
+ ref,
128
+ oldStyle: newStyleIndex,
129
+ newStyle: oldStyleIndex,
130
+ });
131
+ },
132
+ };
133
+ }
134
+
135
+ // =============================================================================
136
+ // Set Range Style (apply style to all cells in a range)
137
+ // =============================================================================
138
+
139
+ export function setRangeStyleCommand(
140
+ workbook: WorkbookModel,
141
+ sheetIndex: number,
142
+ startRow: number,
143
+ startCol: number,
144
+ endRow: number,
145
+ endCol: number,
146
+ styleOverride: Partial<CellStyle>,
147
+ ): Command {
148
+ const commands: Command[] = [];
149
+ for (let r = startRow; r <= endRow; r++) {
150
+ for (let c = startCol; c <= endCol; c++) {
151
+ commands.push(setCellStyleCommand(workbook, sheetIndex, r, c, styleOverride));
152
+ }
153
+ }
154
+
155
+ const rangeRef = `${rowColToRef(startRow, startCol)}:${rowColToRef(endRow, endCol)}`;
156
+
157
+ return {
158
+ description: `Style ${rangeRef}`,
159
+ execute() {
160
+ for (const cmd of commands) cmd.execute();
161
+ },
162
+ undo() {
163
+ for (let i = commands.length - 1; i >= 0; i--) commands[i].undo();
164
+ },
165
+ };
166
+ }
167
+
168
+ // =============================================================================
169
+ // Delete Cell
170
+ // =============================================================================
171
+
172
+ export function deleteCellCommand(
173
+ workbook: WorkbookModel,
174
+ engine: FormulaEngine | undefined,
175
+ sheetIndex: number,
176
+ row: number,
177
+ col: number,
178
+ ): Command {
179
+ const sheet = workbook.sheets[sheetIndex];
180
+ const ref = rowColToRef(row, col);
181
+ const oldCell = sheet.getCell(ref);
182
+ const snapshot = oldCell
183
+ ? {
184
+ value: oldCell.value,
185
+ displayValue: oldCell.displayValue,
186
+ formula: oldCell.formula,
187
+ styleIndex: oldCell.styleIndex,
188
+ numFmtCode: oldCell.numFmtCode,
189
+ content: oldCell.content,
190
+ richText: oldCell.richText,
191
+ }
192
+ : null;
193
+
194
+ return {
195
+ description: `Clear ${ref}`,
196
+ execute() {
197
+ sheet.deleteCell(ref);
198
+ if (engine) {
199
+ engine.onCellChanged(sheet.name, ref);
200
+ }
201
+ workbook.emit({
202
+ type: "cell_value",
203
+ sheet: sheetIndex,
204
+ ref,
205
+ oldValue: snapshot?.value ?? null,
206
+ newValue: null,
207
+ });
208
+ },
209
+ undo() {
210
+ if (snapshot) {
211
+ sheet.set(ref, snapshot.value, sheet.styles?.get(snapshot.styleIndex), snapshot.numFmtCode, snapshot.formula);
212
+ }
213
+ if (engine) {
214
+ engine.onCellChanged(sheet.name, ref, snapshot?.formula);
215
+ }
216
+ workbook.emit({
217
+ type: "cell_value",
218
+ sheet: sheetIndex,
219
+ ref,
220
+ oldValue: null,
221
+ newValue: snapshot?.value ?? null,
222
+ });
223
+ },
224
+ };
225
+ }
226
+
227
+ // =============================================================================
228
+ // Clear Range (delete all cells in a range)
229
+ // =============================================================================
230
+
231
+ export function clearRangeCommand(
232
+ workbook: WorkbookModel,
233
+ engine: FormulaEngine | undefined,
234
+ sheetIndex: number,
235
+ startRow: number,
236
+ startCol: number,
237
+ endRow: number,
238
+ endCol: number,
239
+ ): Command {
240
+ const commands: Command[] = [];
241
+ const sheet = workbook.sheets[sheetIndex];
242
+ for (let r = startRow; r <= endRow; r++) {
243
+ for (let c = startCol; c <= endCol; c++) {
244
+ const ref = rowColToRef(r, c);
245
+ if (sheet.getCell(ref)) {
246
+ commands.push(deleteCellCommand(workbook, engine, sheetIndex, r, c));
247
+ }
248
+ }
249
+ }
250
+
251
+ const rangeRef = `${rowColToRef(startRow, startCol)}:${rowColToRef(endRow, endCol)}`;
252
+
253
+ return {
254
+ description: `Clear ${rangeRef}`,
255
+ execute() {
256
+ for (const cmd of commands) cmd.execute();
257
+ },
258
+ undo() {
259
+ for (let i = commands.length - 1; i >= 0; i--) commands[i].undo();
260
+ },
261
+ };
262
+ }
263
+
264
+ // =============================================================================
265
+ // Merge Cells
266
+ // =============================================================================
267
+
268
+ export function mergeCellsCommand(
269
+ workbook: WorkbookModel,
270
+ sheetIndex: number,
271
+ startRow: number,
272
+ startCol: number,
273
+ endRow: number,
274
+ endCol: number,
275
+ ): Command {
276
+ const sheet = workbook.sheets[sheetIndex];
277
+ const mergeRef = `${rowColToRef(startRow, startCol)}:${rowColToRef(endRow, endCol)}`;
278
+
279
+ return {
280
+ description: `Merge ${mergeRef}`,
281
+ execute() {
282
+ if (!sheet.mergedCells.includes(mergeRef)) {
283
+ sheet.mergedCells.push(mergeRef);
284
+ }
285
+ workbook.emit({ type: "merge", sheet: sheetIndex, ref: mergeRef });
286
+ },
287
+ undo() {
288
+ const idx = sheet.mergedCells.indexOf(mergeRef);
289
+ if (idx >= 0) sheet.mergedCells.splice(idx, 1);
290
+ workbook.emit({ type: "unmerge", sheet: sheetIndex, ref: mergeRef });
291
+ },
292
+ };
293
+ }
294
+
295
+ // =============================================================================
296
+ // Unmerge Cells
297
+ // =============================================================================
298
+
299
+ export function unmergeCellsCommand(
300
+ workbook: WorkbookModel,
301
+ sheetIndex: number,
302
+ mergeRef: string,
303
+ ): Command {
304
+ const sheet = workbook.sheets[sheetIndex];
305
+
306
+ return {
307
+ description: `Unmerge ${mergeRef}`,
308
+ execute() {
309
+ const idx = sheet.mergedCells.indexOf(mergeRef);
310
+ if (idx >= 0) sheet.mergedCells.splice(idx, 1);
311
+ workbook.emit({ type: "unmerge", sheet: sheetIndex, ref: mergeRef });
312
+ },
313
+ undo() {
314
+ if (!sheet.mergedCells.includes(mergeRef)) {
315
+ sheet.mergedCells.push(mergeRef);
316
+ }
317
+ workbook.emit({ type: "merge", sheet: sheetIndex, ref: mergeRef });
318
+ },
319
+ };
320
+ }
321
+
322
+ // =============================================================================
323
+ // Insert Rows
324
+ // =============================================================================
325
+
326
+ export function insertRowsCommand(
327
+ workbook: WorkbookModel,
328
+ sheetIndex: number,
329
+ at: number,
330
+ count: number,
331
+ ): Command {
332
+ const sheet = workbook.sheets[sheetIndex];
333
+ // Snapshot merged ranges so undo can restore them. Cheaper than recomputing
334
+ // the inverse of `adjustMergedCellsForRowInsert` and avoids edge cases when
335
+ // inserts split existing ranges.
336
+ let mergeSnapshot: string[] = [];
337
+
338
+ return {
339
+ description: `Insert ${count} row${count > 1 ? "s" : ""} at ${at}`,
340
+ execute() {
341
+ mergeSnapshot = [...sheet.mergedCells];
342
+ // Shift existing cells down
343
+ const toMove: Array<{ ref: string; row: number; col: number }> = [];
344
+ for (const [ref] of sheet.cells) {
345
+ const rc = refToRowColSafe(ref);
346
+ if (rc && rc.row >= at) toMove.push({ ref, ...rc });
347
+ }
348
+ toMove.sort((a, b) => b.row - a.row); // bottom-up to avoid collisions
349
+ for (const { ref, row, col } of toMove) {
350
+ const cell = sheet.cells.get(ref)!;
351
+ sheet.cells.delete(ref);
352
+ sheet.cells.set(rowColToRef(row + count, col), cell);
353
+ }
354
+ // Shift row heights
355
+ const heightsToShift = Array.from(sheet.rowHeights.entries()).filter(([r]) => r >= at).sort((a, b) => b[0] - a[0]);
356
+ for (const [r, h] of heightsToShift) {
357
+ sheet.rowHeights.delete(r);
358
+ sheet.rowHeights.set(r + count, h);
359
+ }
360
+ // Adjust formula references and merged-cell ranges in lock-step.
361
+ adjustAllFormulasForRowShift(sheet, at, count);
362
+ adjustMergedCellsForRowInsert(sheet, at, count);
363
+ workbook.emit({ type: "rows_inserted", sheet: sheetIndex, at, count });
364
+ },
365
+ undo() {
366
+ sheet.mergedCells = [...mergeSnapshot];
367
+ // Shift cells back up
368
+ const toMove: Array<{ ref: string; row: number; col: number }> = [];
369
+ for (const [ref] of sheet.cells) {
370
+ const rc = refToRowColSafe(ref);
371
+ if (rc && rc.row >= at + count) toMove.push({ ref, ...rc });
372
+ }
373
+ // Delete inserted empty rows
374
+ for (const [ref] of sheet.cells) {
375
+ const rc = refToRowColSafe(ref);
376
+ if (rc && rc.row >= at && rc.row < at + count) sheet.cells.delete(ref);
377
+ }
378
+ toMove.sort((a, b) => a.row - b.row); // top-down
379
+ for (const { ref, row, col } of toMove) {
380
+ const cell = sheet.cells.get(ref)!;
381
+ sheet.cells.delete(ref);
382
+ sheet.cells.set(rowColToRef(row - count, col), cell);
383
+ }
384
+ // Shift row heights back
385
+ const heightsToShift = Array.from(sheet.rowHeights.entries()).filter(([r]) => r >= at + count).sort((a, b) => a[0] - b[0]);
386
+ for (const [r, h] of heightsToShift) {
387
+ sheet.rowHeights.delete(r);
388
+ sheet.rowHeights.set(r - count, h);
389
+ }
390
+ for (let r = at; r < at + count; r++) sheet.rowHeights.delete(r);
391
+ workbook.emit({ type: "rows_deleted", sheet: sheetIndex, at, count });
392
+ },
393
+ };
394
+ }
395
+
396
+ // =============================================================================
397
+ // Delete Rows
398
+ // =============================================================================
399
+
400
+ export function deleteRowsCommand(
401
+ workbook: WorkbookModel,
402
+ sheetIndex: number,
403
+ at: number,
404
+ count: number,
405
+ ): Command {
406
+ const sheet = workbook.sheets[sheetIndex];
407
+ // Snapshot deleted cells
408
+ const snapshot = new Map<string, { value: CellValue; displayValue: string; formula?: string; styleIndex: number; numFmtCode: string; content: CellContent }>();
409
+ for (const [ref, cell] of sheet.cells) {
410
+ const rc = refToRowColSafe(ref);
411
+ if (rc && rc.row >= at && rc.row < at + count) {
412
+ snapshot.set(ref, { value: cell.value, displayValue: cell.displayValue, formula: cell.formula, styleIndex: cell.styleIndex, numFmtCode: cell.numFmtCode, content: cell.content });
413
+ }
414
+ }
415
+ let mergeSnapshot: string[] = [];
416
+
417
+ return {
418
+ description: `Delete ${count} row${count > 1 ? "s" : ""} at ${at}`,
419
+ execute() {
420
+ mergeSnapshot = [...sheet.mergedCells];
421
+ // Delete cells in the range
422
+ for (const ref of snapshot.keys()) sheet.cells.delete(ref);
423
+ // Shift cells up
424
+ const toMove: Array<{ ref: string; row: number; col: number }> = [];
425
+ for (const [ref] of sheet.cells) {
426
+ const rc = refToRowColSafe(ref);
427
+ if (rc && rc.row >= at + count) toMove.push({ ref, ...rc });
428
+ }
429
+ toMove.sort((a, b) => a.row - b.row);
430
+ for (const { ref, row, col } of toMove) {
431
+ const cell = sheet.cells.get(ref)!;
432
+ sheet.cells.delete(ref);
433
+ sheet.cells.set(rowColToRef(row - count, col), cell);
434
+ }
435
+ adjustAllFormulasForRowShift(sheet, at, -count);
436
+ adjustMergedCellsForRowDelete(sheet, at, count);
437
+ workbook.emit({ type: "rows_deleted", sheet: sheetIndex, at, count });
438
+ },
439
+ undo() {
440
+ sheet.mergedCells = [...mergeSnapshot];
441
+ // Shift cells back down
442
+ const toMove: Array<{ ref: string; row: number; col: number }> = [];
443
+ for (const [ref] of sheet.cells) {
444
+ const rc = refToRowColSafe(ref);
445
+ if (rc && rc.row >= at) toMove.push({ ref, ...rc });
446
+ }
447
+ toMove.sort((a, b) => b.row - a.row);
448
+ for (const { ref, row, col } of toMove) {
449
+ const cell = sheet.cells.get(ref)!;
450
+ sheet.cells.delete(ref);
451
+ sheet.cells.set(rowColToRef(row + count, col), cell);
452
+ }
453
+ // Restore snapshot
454
+ for (const [ref, snap] of snapshot) {
455
+ sheet.set(ref, snap.value, sheet.styles?.get(snap.styleIndex), snap.numFmtCode, snap.formula);
456
+ }
457
+ workbook.emit({ type: "rows_inserted", sheet: sheetIndex, at, count });
458
+ },
459
+ };
460
+ }
461
+
462
+ // =============================================================================
463
+ // Insert Columns
464
+ // =============================================================================
465
+
466
+ export function insertColsCommand(
467
+ workbook: WorkbookModel,
468
+ sheetIndex: number,
469
+ at: number,
470
+ count: number,
471
+ ): Command {
472
+ const sheet = workbook.sheets[sheetIndex];
473
+ let mergeSnapshot: string[] = [];
474
+
475
+ return {
476
+ description: `Insert ${count} column${count > 1 ? "s" : ""} at ${at}`,
477
+ execute() {
478
+ mergeSnapshot = [...sheet.mergedCells];
479
+ const toMove: Array<{ ref: string; row: number; col: number }> = [];
480
+ for (const [ref] of sheet.cells) {
481
+ const rc = refToRowColSafe(ref);
482
+ if (rc && rc.col >= at) toMove.push({ ref, ...rc });
483
+ }
484
+ toMove.sort((a, b) => b.col - a.col);
485
+ for (const { ref, row, col } of toMove) {
486
+ const cell = sheet.cells.get(ref)!;
487
+ sheet.cells.delete(ref);
488
+ sheet.cells.set(rowColToRef(row, col + count), cell);
489
+ }
490
+ const widthsToShift = Array.from(sheet.colWidths.entries()).filter(([c]) => c >= at).sort((a, b) => b[0] - a[0]);
491
+ for (const [c, w] of widthsToShift) {
492
+ sheet.colWidths.delete(c);
493
+ sheet.colWidths.set(c + count, w);
494
+ }
495
+ adjustAllFormulasForColShift(sheet, at, count);
496
+ adjustMergedCellsForColInsert(sheet, at, count);
497
+ workbook.emit({ type: "cols_inserted", sheet: sheetIndex, at, count });
498
+ },
499
+ undo() {
500
+ sheet.mergedCells = [...mergeSnapshot];
501
+ const toMove: Array<{ ref: string; row: number; col: number }> = [];
502
+ for (const [ref] of sheet.cells) {
503
+ const rc = refToRowColSafe(ref);
504
+ if (rc && rc.col >= at + count) toMove.push({ ref, ...rc });
505
+ }
506
+ for (const [ref] of sheet.cells) {
507
+ const rc = refToRowColSafe(ref);
508
+ if (rc && rc.col >= at && rc.col < at + count) sheet.cells.delete(ref);
509
+ }
510
+ toMove.sort((a, b) => a.col - b.col);
511
+ for (const { ref, row, col } of toMove) {
512
+ const cell = sheet.cells.get(ref)!;
513
+ sheet.cells.delete(ref);
514
+ sheet.cells.set(rowColToRef(row, col - count), cell);
515
+ }
516
+ const widthsToShift = Array.from(sheet.colWidths.entries()).filter(([c]) => c >= at + count).sort((a, b) => a[0] - b[0]);
517
+ for (const [c, w] of widthsToShift) {
518
+ sheet.colWidths.delete(c);
519
+ sheet.colWidths.set(c - count, w);
520
+ }
521
+ for (let c = at; c < at + count; c++) sheet.colWidths.delete(c);
522
+ workbook.emit({ type: "cols_deleted", sheet: sheetIndex, at, count });
523
+ },
524
+ };
525
+ }
526
+
527
+ // =============================================================================
528
+ // Delete Columns
529
+ // =============================================================================
530
+
531
+ export function deleteColsCommand(
532
+ workbook: WorkbookModel,
533
+ sheetIndex: number,
534
+ at: number,
535
+ count: number,
536
+ ): Command {
537
+ const sheet = workbook.sheets[sheetIndex];
538
+ const snapshot = new Map<string, { value: CellValue; displayValue: string; formula?: string; styleIndex: number; numFmtCode: string; content: CellContent }>();
539
+ for (const [ref, cell] of sheet.cells) {
540
+ const rc = refToRowColSafe(ref);
541
+ if (rc && rc.col >= at && rc.col < at + count) {
542
+ snapshot.set(ref, { value: cell.value, displayValue: cell.displayValue, formula: cell.formula, styleIndex: cell.styleIndex, numFmtCode: cell.numFmtCode, content: cell.content });
543
+ }
544
+ }
545
+ let mergeSnapshot: string[] = [];
546
+
547
+ return {
548
+ description: `Delete ${count} column${count > 1 ? "s" : ""} at ${at}`,
549
+ execute() {
550
+ mergeSnapshot = [...sheet.mergedCells];
551
+ for (const ref of snapshot.keys()) sheet.cells.delete(ref);
552
+ const toMove: Array<{ ref: string; row: number; col: number }> = [];
553
+ for (const [ref] of sheet.cells) {
554
+ const rc = refToRowColSafe(ref);
555
+ if (rc && rc.col >= at + count) toMove.push({ ref, ...rc });
556
+ }
557
+ toMove.sort((a, b) => a.col - b.col);
558
+ for (const { ref, row, col } of toMove) {
559
+ const cell = sheet.cells.get(ref)!;
560
+ sheet.cells.delete(ref);
561
+ sheet.cells.set(rowColToRef(row, col - count), cell);
562
+ }
563
+ adjustAllFormulasForColShift(sheet, at, -count);
564
+ adjustMergedCellsForColDelete(sheet, at, count);
565
+ workbook.emit({ type: "cols_deleted", sheet: sheetIndex, at, count });
566
+ },
567
+ undo() {
568
+ sheet.mergedCells = [...mergeSnapshot];
569
+ const toMove: Array<{ ref: string; row: number; col: number }> = [];
570
+ for (const [ref] of sheet.cells) {
571
+ const rc = refToRowColSafe(ref);
572
+ if (rc && rc.col >= at) toMove.push({ ref, ...rc });
573
+ }
574
+ toMove.sort((a, b) => b.col - a.col);
575
+ for (const { ref, row, col } of toMove) {
576
+ const cell = sheet.cells.get(ref)!;
577
+ sheet.cells.delete(ref);
578
+ sheet.cells.set(rowColToRef(row, col + count), cell);
579
+ }
580
+ for (const [ref, snap] of snapshot) {
581
+ sheet.set(ref, snap.value, sheet.styles?.get(snap.styleIndex), snap.numFmtCode, snap.formula);
582
+ }
583
+ workbook.emit({ type: "cols_inserted", sheet: sheetIndex, at, count });
584
+ },
585
+ };
586
+ }
587
+
588
+ // =============================================================================
589
+ // Set Column Width
590
+ // =============================================================================
591
+
592
+ export function setColWidthCommand(
593
+ workbook: WorkbookModel,
594
+ sheetIndex: number,
595
+ col: number,
596
+ newWidth: number,
597
+ ): Command {
598
+ const sheet = workbook.sheets[sheetIndex];
599
+ const oldWidth = sheet.colWidths.get(col) ?? sheet.defaultColWidth;
600
+
601
+ return {
602
+ description: `Resize column ${col}`,
603
+ execute() {
604
+ sheet.colWidths.set(col, newWidth);
605
+ },
606
+ undo() {
607
+ if (oldWidth === sheet.defaultColWidth) {
608
+ sheet.colWidths.delete(col);
609
+ } else {
610
+ sheet.colWidths.set(col, oldWidth);
611
+ }
612
+ },
613
+ };
614
+ }
615
+
616
+ // =============================================================================
617
+ // Set Row Height
618
+ // =============================================================================
619
+
620
+ export function setRowHeightCommand(
621
+ workbook: WorkbookModel,
622
+ sheetIndex: number,
623
+ row: number,
624
+ newHeight: number,
625
+ ): Command {
626
+ const sheet = workbook.sheets[sheetIndex];
627
+ const oldHeight = sheet.rowHeights.get(row) ?? sheet.defaultRowHeight;
628
+
629
+ return {
630
+ description: `Resize row ${row}`,
631
+ execute() {
632
+ sheet.rowHeights.set(row, newHeight);
633
+ },
634
+ undo() {
635
+ if (oldHeight === sheet.defaultRowHeight) {
636
+ sheet.rowHeights.delete(row);
637
+ } else {
638
+ sheet.rowHeights.set(row, oldHeight);
639
+ }
640
+ },
641
+ };
642
+ }
643
+
644
+ // =============================================================================
645
+ // Set Named Range
646
+ // =============================================================================
647
+
648
+ export function setNamedRangeCommand(
649
+ workbook: WorkbookModel,
650
+ name: string,
651
+ expr: string,
652
+ ): Command {
653
+ const oldExpr = workbook.namedRanges.get(name);
654
+
655
+ return {
656
+ description: `Define name ${name} = ${expr}`,
657
+ execute() {
658
+ workbook.namedRanges.set(name, expr);
659
+ },
660
+ undo() {
661
+ if (oldExpr === undefined) {
662
+ workbook.namedRanges.delete(name);
663
+ } else {
664
+ workbook.namedRanges.set(name, oldExpr);
665
+ }
666
+ },
667
+ };
668
+ }
669
+
670
+ // =============================================================================
671
+ // Set Range Number Format
672
+ // =============================================================================
673
+
674
+ export function setRangeNumFmtCommand(
675
+ workbook: WorkbookModel,
676
+ sheetIndex: number,
677
+ startRow: number,
678
+ startCol: number,
679
+ endRow: number,
680
+ endCol: number,
681
+ numFmtCode: string,
682
+ ): Command {
683
+ const sheet = workbook.sheets[sheetIndex];
684
+ const snapshot = new Map<string, { value: CellValue; styleIndex: number; numFmtCode: string; formula?: string } | null>();
685
+ for (let r = startRow; r <= endRow; r++) {
686
+ for (let c = startCol; c <= endCol; c++) {
687
+ const ref = rowColToRef(r, c);
688
+ const cell = sheet.getCell(ref);
689
+ snapshot.set(ref, cell ? { value: cell.value, styleIndex: cell.styleIndex, numFmtCode: cell.numFmtCode, formula: cell.formula } : null);
690
+ }
691
+ }
692
+ const rangeRef = `${rowColToRef(startRow, startCol)}:${rowColToRef(endRow, endCol)}`;
693
+
694
+ return {
695
+ description: `Format ${rangeRef} as ${numFmtCode}`,
696
+ execute() {
697
+ for (let r = startRow; r <= endRow; r++) {
698
+ for (let c = startCol; c <= endCol; c++) {
699
+ const ref = rowColToRef(r, c);
700
+ const cell = sheet.getCell(ref);
701
+ if (cell) {
702
+ sheet.set(ref, cell.value, sheet.styles?.get(cell.styleIndex), numFmtCode, cell.formula);
703
+ } else {
704
+ sheet.set(ref, null, undefined, numFmtCode);
705
+ }
706
+ }
707
+ }
708
+ },
709
+ undo() {
710
+ for (const [ref, snap] of snapshot) {
711
+ if (snap) {
712
+ sheet.set(ref, snap.value, sheet.styles?.get(snap.styleIndex), snap.numFmtCode, snap.formula);
713
+ } else {
714
+ sheet.deleteCell(ref);
715
+ }
716
+ }
717
+ },
718
+ };
719
+ }
720
+
721
+ // =============================================================================
722
+ // Sheet Management
723
+ // =============================================================================
724
+
725
+ export function addSheetCommand(workbook: WorkbookModel, name: string): Command {
726
+ return {
727
+ description: `Add sheet ${name}`,
728
+ execute() {
729
+ workbook.sheets.push(new SheetModel(name, workbook.styles));
730
+ workbook.emit({ type: "sheet_added", index: workbook.sheets.length - 1 });
731
+ },
732
+ undo() {
733
+ const index = workbook.sheets.length - 1;
734
+ workbook.sheets.pop();
735
+ workbook.emit({ type: "sheet_deleted", index });
736
+ },
737
+ };
738
+ }
739
+
740
+ export function deleteSheetCommand(workbook: WorkbookModel, index: number): Command {
741
+ const snapshot = workbook.sheets[index];
742
+ return {
743
+ description: `Delete sheet ${snapshot.name}`,
744
+ execute() {
745
+ workbook.sheets.splice(index, 1);
746
+ workbook.emit({ type: "sheet_deleted", index });
747
+ },
748
+ undo() {
749
+ workbook.sheets.splice(index, 0, snapshot);
750
+ workbook.emit({ type: "sheet_added", index });
751
+ },
752
+ };
753
+ }
754
+
755
+ export function renameSheetCommand(workbook: WorkbookModel, index: number, newName: string): Command {
756
+ const oldName = workbook.sheets[index].name;
757
+ return {
758
+ description: `Rename sheet ${oldName} → ${newName}`,
759
+ execute() {
760
+ workbook.sheets[index].name = newName;
761
+ workbook.emit({ type: "sheet_renamed", index, oldName, newName });
762
+ },
763
+ undo() {
764
+ workbook.sheets[index].name = oldName;
765
+ workbook.emit({ type: "sheet_renamed", index, oldName: newName, newName: oldName });
766
+ },
767
+ };
768
+ }
769
+
770
+ // =============================================================================
771
+ // Batch Command
772
+ // =============================================================================
773
+
774
+ export function batchCommand(
775
+ description: string,
776
+ commands: Command[],
777
+ ): Command {
778
+ return {
779
+ description,
780
+ execute() {
781
+ for (const cmd of commands) cmd.execute();
782
+ },
783
+ undo() {
784
+ for (let i = commands.length - 1; i >= 0; i--) commands[i].undo();
785
+ },
786
+ };
787
+ }
788
+
789
+ // =============================================================================
790
+ // Set Hidden Rows
791
+ // =============================================================================
792
+
793
+ // =============================================================================
794
+ // Add Table — convert a range to a styled Excel Table
795
+ // =============================================================================
796
+
797
+ export function addTableCommand(
798
+ workbook: WorkbookModel,
799
+ sheetIndex: number,
800
+ table: import("./ooxml_chart_types").ParsedTable,
801
+ ): Command {
802
+ const sheet = workbook.sheets[sheetIndex];
803
+ return {
804
+ description: `Insert table ${table.name}`,
805
+ execute() {
806
+ sheet.tables.push(table);
807
+ },
808
+ undo() {
809
+ const idx = sheet.tables.findIndex((t) => t.name === table.name);
810
+ if (idx >= 0) sheet.tables.splice(idx, 1);
811
+ },
812
+ };
813
+ }
814
+
815
+ export function removeTableCommand(
816
+ workbook: WorkbookModel,
817
+ sheetIndex: number,
818
+ tableName: string,
819
+ ): Command {
820
+ const sheet = workbook.sheets[sheetIndex];
821
+ let savedIdx = -1;
822
+ let savedTable: import("./ooxml_chart_types").ParsedTable | undefined;
823
+ return {
824
+ description: `Convert table ${tableName} to range`,
825
+ execute() {
826
+ savedIdx = sheet.tables.findIndex((t) => t.name === tableName);
827
+ if (savedIdx >= 0) {
828
+ savedTable = sheet.tables[savedIdx];
829
+ sheet.tables.splice(savedIdx, 1);
830
+ }
831
+ },
832
+ undo() {
833
+ if (savedTable && savedIdx >= 0) {
834
+ sheet.tables.splice(savedIdx, 0, savedTable);
835
+ }
836
+ },
837
+ };
838
+ }
839
+
840
+ export function setSheetTabColorCommand(
841
+ workbook: WorkbookModel,
842
+ sheetIndex: number,
843
+ newColor: string | undefined,
844
+ ): Command {
845
+ const sheet = workbook.sheets[sheetIndex];
846
+ const oldColor = sheet.view.tabColor;
847
+
848
+ return {
849
+ description: `Set tab color`,
850
+ execute() {
851
+ sheet.view.tabColor = newColor;
852
+ },
853
+ undo() {
854
+ sheet.view.tabColor = oldColor;
855
+ },
856
+ };
857
+ }
858
+
859
+ // =============================================================================
860
+ // Set Hidden Rows
861
+ // =============================================================================
862
+
863
+ export function setHiddenRowsCommand(
864
+ workbook: WorkbookModel,
865
+ sheetIndex: number,
866
+ newHidden: Set<number>,
867
+ ): Command {
868
+ const sheet = workbook.sheets[sheetIndex];
869
+ const oldHidden = new Set(sheet.hiddenRows);
870
+
871
+ return {
872
+ description: `Set hidden rows`,
873
+ execute() {
874
+ sheet.hiddenRows = new Set(newHidden);
875
+ },
876
+ undo() {
877
+ sheet.hiddenRows = new Set(oldHidden);
878
+ },
879
+ };
880
+ }
881
+
882
+ // =============================================================================
883
+ // Set Hidden Cols
884
+ // =============================================================================
885
+
886
+ export function setHiddenColsCommand(
887
+ workbook: WorkbookModel,
888
+ sheetIndex: number,
889
+ newHidden: Set<number>,
890
+ ): Command {
891
+ const sheet = workbook.sheets[sheetIndex];
892
+ const oldHidden = new Set(sheet.hiddenCols);
893
+
894
+ return {
895
+ description: `Set hidden cols`,
896
+ execute() {
897
+ sheet.hiddenCols = new Set(newHidden);
898
+ },
899
+ undo() {
900
+ sheet.hiddenCols = new Set(oldHidden);
901
+ },
902
+ };
903
+ }
904
+
905
+ // =============================================================================
906
+ // Set Pivot Config
907
+ // =============================================================================
908
+
909
+ /**
910
+ * Replace a pivot's `config` (axis assignments, value aggregations, layout)
911
+ * and recompute. Captures the old config so undo restores it. The caller
912
+ * builds the next config; this command swaps it atomically.
913
+ */
914
+ export function setPivotConfigCommand(
915
+ workbook: WorkbookModel,
916
+ pivot: PivotTableModel,
917
+ newConfig: ParsedPivotTable,
918
+ ): Command {
919
+ const oldConfig = pivot.config;
920
+
921
+ return {
922
+ description: `Update pivot ${oldConfig.name}`,
923
+ execute() {
924
+ pivot.config = newConfig;
925
+ pivot.recompute(workbook);
926
+ },
927
+ undo() {
928
+ pivot.config = oldConfig;
929
+ pivot.recompute(workbook);
930
+ },
931
+ };
932
+ }
933
+
934
+ // =============================================================================
935
+ // Sort Column
936
+ // =============================================================================
937
+
938
+ export function sortColumnCommand(
939
+ workbook: WorkbookModel,
940
+ sheetIndex: number,
941
+ col: number,
942
+ ascending: boolean,
943
+ ): Command {
944
+ const sheet = workbook.sheets[sheetIndex];
945
+ const used = sheet.getUsedRange();
946
+
947
+ // Snapshot ALL cells in the used range before sorting
948
+ const snapshot = new Map<string, { value: CellValue; displayValue: string; formula?: string; styleIndex: number; numFmtCode: string; content: CellContent } | undefined>();
949
+ for (let r = used.minRow; r <= used.maxRow; r++) {
950
+ for (let c = used.minCol; c <= used.maxCol; c++) {
951
+ const ref = rowColToRef(r, c);
952
+ const cell = sheet.getCell(ref);
953
+ if (cell) {
954
+ snapshot.set(ref, { value: cell.value, displayValue: cell.displayValue, formula: cell.formula, styleIndex: cell.styleIndex, numFmtCode: cell.numFmtCode, content: cell.content });
955
+ } else {
956
+ snapshot.set(ref, undefined);
957
+ }
958
+ }
959
+ }
960
+
961
+ return {
962
+ description: `Sort column ${col} ${ascending ? "asc" : "desc"}`,
963
+ execute() {
964
+ sortColumnInPlace(sheet, col, ascending);
965
+ },
966
+ undo() {
967
+ // Restore all cells from snapshot
968
+ for (const [ref, snap] of snapshot) {
969
+ if (snap) {
970
+ sheet.set(ref, snap.value, sheet.styles?.get(snap.styleIndex), snap.numFmtCode, snap.formula);
971
+ } else {
972
+ sheet.deleteCell(ref);
973
+ }
974
+ }
975
+ },
976
+ };
977
+ }
978
+
979
+ // =============================================================================
980
+ // Multi-Column Sort
981
+ // =============================================================================
982
+
983
+ export interface SortSpec {
984
+ col: number;
985
+ ascending: boolean;
986
+ }
987
+
988
+ export function multiSortCommand(
989
+ workbook: WorkbookModel,
990
+ sheetIndex: number,
991
+ sortSpecs: SortSpec[],
992
+ hasHeader: boolean,
993
+ ): Command {
994
+ const sheet = workbook.sheets[sheetIndex];
995
+ const used = sheet.getUsedRange();
996
+
997
+ // Snapshot ALL cells in the used range before sorting
998
+ const snapshot = new Map<string, { value: CellValue; displayValue: string; formula?: string; styleIndex: number; numFmtCode: string; content: CellContent } | undefined>();
999
+ for (let r = used.minRow; r <= used.maxRow; r++) {
1000
+ for (let c = used.minCol; c <= used.maxCol; c++) {
1001
+ const ref = rowColToRef(r, c);
1002
+ const cell = sheet.getCell(ref);
1003
+ if (cell) {
1004
+ snapshot.set(ref, { value: cell.value, displayValue: cell.displayValue, formula: cell.formula, styleIndex: cell.styleIndex, numFmtCode: cell.numFmtCode, content: cell.content });
1005
+ } else {
1006
+ snapshot.set(ref, undefined);
1007
+ }
1008
+ }
1009
+ }
1010
+
1011
+ return {
1012
+ description: `Multi-sort by ${sortSpecs.length} keys`,
1013
+ execute() {
1014
+ multiSortInPlace(sheet, sortSpecs, hasHeader ? used.minRow + 1 : used.minRow);
1015
+ },
1016
+ undo() {
1017
+ for (const [ref, snap] of snapshot) {
1018
+ if (snap) {
1019
+ sheet.set(ref, snap.value, sheet.styles?.get(snap.styleIndex), snap.numFmtCode, snap.formula);
1020
+ } else {
1021
+ sheet.deleteCell(ref);
1022
+ }
1023
+ }
1024
+ },
1025
+ };
1026
+ }
1027
+
1028
+ // =============================================================================
1029
+ // Set Freeze Panes
1030
+ // =============================================================================
1031
+
1032
+ export function setFreezePanesCommand(
1033
+ workbook: WorkbookModel,
1034
+ sheetIndex: number,
1035
+ newFreeze: { row: number; col: number } | undefined,
1036
+ ): Command {
1037
+ const sheet = workbook.sheets[sheetIndex];
1038
+ const oldFreeze = sheet.freeze ? { ...sheet.freeze } : undefined;
1039
+
1040
+ return {
1041
+ description: newFreeze ? `Freeze at row ${newFreeze.row}, col ${newFreeze.col}` : "Unfreeze panes",
1042
+ execute() {
1043
+ sheet.freeze = newFreeze ? { ...newFreeze } : undefined;
1044
+ },
1045
+ undo() {
1046
+ sheet.freeze = oldFreeze ? { ...oldFreeze } : undefined;
1047
+ },
1048
+ };
1049
+ }
1050
+
1051
+ // =============================================================================
1052
+ // Set Zoom
1053
+ // =============================================================================
1054
+
1055
+ export function setZoomCommand(
1056
+ workbook: WorkbookModel,
1057
+ sheetIndex: number,
1058
+ newZoom: number,
1059
+ ): Command {
1060
+ const sheet = workbook.sheets[sheetIndex];
1061
+ const oldZoom = sheet.view.zoomScale;
1062
+
1063
+ return {
1064
+ description: `Zoom ${oldZoom}% → ${newZoom}%`,
1065
+ execute() {
1066
+ sheet.view.zoomScale = newZoom;
1067
+ },
1068
+ undo() {
1069
+ sheet.view.zoomScale = oldZoom;
1070
+ },
1071
+ };
1072
+ }
1073
+
1074
+ // =============================================================================
1075
+ // Add Conditional Format
1076
+ // =============================================================================
1077
+
1078
+ export function addConditionalFormatCommand(
1079
+ workbook: WorkbookModel,
1080
+ sheetIndex: number,
1081
+ cf: ParsedConditionalFormat,
1082
+ ): Command {
1083
+ const sheet = workbook.sheets[sheetIndex];
1084
+
1085
+ return {
1086
+ description: `Add CF rule on ${cf.ref}`,
1087
+ execute() {
1088
+ sheet.conditionalFormats.push(cf);
1089
+ },
1090
+ undo() {
1091
+ sheet.conditionalFormats.pop();
1092
+ },
1093
+ };
1094
+ }
1095
+
1096
+ // =============================================================================
1097
+ // Delete Conditional Format
1098
+ // =============================================================================
1099
+
1100
+ export function deleteConditionalFormatCommand(
1101
+ workbook: WorkbookModel,
1102
+ sheetIndex: number,
1103
+ index: number,
1104
+ ): Command {
1105
+ const sheet = workbook.sheets[sheetIndex];
1106
+ const snapshot = sheet.conditionalFormats[index];
1107
+
1108
+ return {
1109
+ description: `Delete CF rule #${index}`,
1110
+ execute() {
1111
+ sheet.conditionalFormats.splice(index, 1);
1112
+ },
1113
+ undo() {
1114
+ sheet.conditionalFormats.splice(index, 0, snapshot);
1115
+ },
1116
+ };
1117
+ }
1118
+
1119
+ // =============================================================================
1120
+ // Reorder Sheets
1121
+ // =============================================================================
1122
+
1123
+ export function reorderSheetsCommand(
1124
+ workbook: WorkbookModel,
1125
+ fromIndex: number,
1126
+ toIndex: number,
1127
+ ): Command {
1128
+ return {
1129
+ description: `Move sheet ${fromIndex} → ${toIndex}`,
1130
+ execute() {
1131
+ const [sheet] = workbook.sheets.splice(fromIndex, 1);
1132
+ workbook.sheets.splice(toIndex, 0, sheet);
1133
+ },
1134
+ undo() {
1135
+ const [sheet] = workbook.sheets.splice(toIndex, 1);
1136
+ workbook.sheets.splice(fromIndex, 0, sheet);
1137
+ },
1138
+ };
1139
+ }
1140
+
1141
+ // =============================================================================
1142
+ // Insert Cells (Shift Down)
1143
+ // =============================================================================
1144
+
1145
+ export function insertCellsShiftDownCommand(
1146
+ workbook: WorkbookModel,
1147
+ sheetIndex: number,
1148
+ startRow: number,
1149
+ startCol: number,
1150
+ endRow: number,
1151
+ endCol: number,
1152
+ ): Command {
1153
+ const sheet = workbook.sheets[sheetIndex];
1154
+ const height = endRow - startRow + 1;
1155
+
1156
+ return {
1157
+ description: `Insert cells shift down ${rowColToRef(startRow, startCol)}:${rowColToRef(endRow, endCol)}`,
1158
+ execute() {
1159
+ // For each column in the range, shift cells down by `height` starting from startRow
1160
+ for (let c = startCol; c <= endCol; c++) {
1161
+ // Collect cells at or below startRow in this column
1162
+ const toMove: Array<{ row: number; ref: string }> = [];
1163
+ for (const [ref] of sheet.cells) {
1164
+ const rc = refToRowColSafe(ref);
1165
+ if (rc && rc.col === c && rc.row >= startRow) toMove.push({ row: rc.row, ref });
1166
+ }
1167
+ toMove.sort((a, b) => b.row - a.row); // bottom-up
1168
+ for (const { ref, row } of toMove) {
1169
+ const cell = sheet.cells.get(ref)!;
1170
+ sheet.cells.delete(ref);
1171
+ sheet.cells.set(rowColToRef(row + height, c), cell);
1172
+ }
1173
+ }
1174
+ },
1175
+ undo() {
1176
+ for (let c = startCol; c <= endCol; c++) {
1177
+ // Delete the inserted empty cells
1178
+ for (let r = startRow; r <= endRow; r++) {
1179
+ sheet.deleteCell(rowColToRef(r, c));
1180
+ }
1181
+ // Shift cells back up
1182
+ const toMove: Array<{ row: number; ref: string }> = [];
1183
+ for (const [ref] of sheet.cells) {
1184
+ const rc = refToRowColSafe(ref);
1185
+ if (rc && rc.col === c && rc.row >= startRow + height) toMove.push({ row: rc.row, ref });
1186
+ }
1187
+ toMove.sort((a, b) => a.row - b.row); // top-down
1188
+ for (const { ref, row } of toMove) {
1189
+ const cell = sheet.cells.get(ref)!;
1190
+ sheet.cells.delete(ref);
1191
+ sheet.cells.set(rowColToRef(row - height, c), cell);
1192
+ }
1193
+ }
1194
+ },
1195
+ };
1196
+ }
1197
+
1198
+ // =============================================================================
1199
+ // Insert Cells (Shift Right)
1200
+ // =============================================================================
1201
+
1202
+ export function insertCellsShiftRightCommand(
1203
+ workbook: WorkbookModel,
1204
+ sheetIndex: number,
1205
+ startRow: number,
1206
+ startCol: number,
1207
+ endRow: number,
1208
+ endCol: number,
1209
+ ): Command {
1210
+ const sheet = workbook.sheets[sheetIndex];
1211
+ const width = endCol - startCol + 1;
1212
+
1213
+ return {
1214
+ description: `Insert cells shift right ${rowColToRef(startRow, startCol)}:${rowColToRef(endRow, endCol)}`,
1215
+ execute() {
1216
+ for (let r = startRow; r <= endRow; r++) {
1217
+ const toMove: Array<{ col: number; ref: string }> = [];
1218
+ for (const [ref] of sheet.cells) {
1219
+ const rc = refToRowColSafe(ref);
1220
+ if (rc && rc.row === r && rc.col >= startCol) toMove.push({ col: rc.col, ref });
1221
+ }
1222
+ toMove.sort((a, b) => b.col - a.col); // right-to-left
1223
+ for (const { ref, col } of toMove) {
1224
+ const cell = sheet.cells.get(ref)!;
1225
+ sheet.cells.delete(ref);
1226
+ sheet.cells.set(rowColToRef(r, col + width), cell);
1227
+ }
1228
+ }
1229
+ },
1230
+ undo() {
1231
+ for (let r = startRow; r <= endRow; r++) {
1232
+ for (let c = startCol; c <= endCol; c++) {
1233
+ sheet.deleteCell(rowColToRef(r, c));
1234
+ }
1235
+ const toMove: Array<{ col: number; ref: string }> = [];
1236
+ for (const [ref] of sheet.cells) {
1237
+ const rc = refToRowColSafe(ref);
1238
+ if (rc && rc.row === r && rc.col >= startCol + width) toMove.push({ col: rc.col, ref });
1239
+ }
1240
+ toMove.sort((a, b) => a.col - b.col); // left-to-right
1241
+ for (const { ref, col } of toMove) {
1242
+ const cell = sheet.cells.get(ref)!;
1243
+ sheet.cells.delete(ref);
1244
+ sheet.cells.set(rowColToRef(r, col - width), cell);
1245
+ }
1246
+ }
1247
+ },
1248
+ };
1249
+ }
1250
+
1251
+ // =============================================================================
1252
+ // Delete Cells (Shift Up)
1253
+ // =============================================================================
1254
+
1255
+ export function deleteCellsShiftUpCommand(
1256
+ workbook: WorkbookModel,
1257
+ sheetIndex: number,
1258
+ startRow: number,
1259
+ startCol: number,
1260
+ endRow: number,
1261
+ endCol: number,
1262
+ ): Command {
1263
+ const sheet = workbook.sheets[sheetIndex];
1264
+ const height = endRow - startRow + 1;
1265
+
1266
+ // Snapshot deleted cells
1267
+ const snapshot = new Map<string, { value: CellValue; displayValue: string; formula?: string; styleIndex: number; numFmtCode: string; content: CellContent }>();
1268
+ for (let r = startRow; r <= endRow; r++) {
1269
+ for (let c = startCol; c <= endCol; c++) {
1270
+ const ref = rowColToRef(r, c);
1271
+ const cell = sheet.getCell(ref);
1272
+ if (cell) {
1273
+ snapshot.set(ref, { value: cell.value, displayValue: cell.displayValue, formula: cell.formula, styleIndex: cell.styleIndex, numFmtCode: cell.numFmtCode, content: cell.content });
1274
+ }
1275
+ }
1276
+ }
1277
+
1278
+ return {
1279
+ description: `Delete cells shift up ${rowColToRef(startRow, startCol)}:${rowColToRef(endRow, endCol)}`,
1280
+ execute() {
1281
+ for (let c = startCol; c <= endCol; c++) {
1282
+ // Delete cells in the range
1283
+ for (let r = startRow; r <= endRow; r++) {
1284
+ sheet.deleteCell(rowColToRef(r, c));
1285
+ }
1286
+ // Shift cells up
1287
+ const toMove: Array<{ row: number; ref: string }> = [];
1288
+ for (const [ref] of sheet.cells) {
1289
+ const rc = refToRowColSafe(ref);
1290
+ if (rc && rc.col === c && rc.row > endRow) toMove.push({ row: rc.row, ref });
1291
+ }
1292
+ toMove.sort((a, b) => a.row - b.row); // top-down
1293
+ for (const { ref, row } of toMove) {
1294
+ const cell = sheet.cells.get(ref)!;
1295
+ sheet.cells.delete(ref);
1296
+ sheet.cells.set(rowColToRef(row - height, c), cell);
1297
+ }
1298
+ }
1299
+ },
1300
+ undo() {
1301
+ for (let c = startCol; c <= endCol; c++) {
1302
+ // Shift cells back down
1303
+ const toMove: Array<{ row: number; ref: string }> = [];
1304
+ for (const [ref] of sheet.cells) {
1305
+ const rc = refToRowColSafe(ref);
1306
+ if (rc && rc.col === c && rc.row >= startRow) toMove.push({ row: rc.row, ref });
1307
+ }
1308
+ toMove.sort((a, b) => b.row - a.row); // bottom-up
1309
+ for (const { ref, row } of toMove) {
1310
+ const cell = sheet.cells.get(ref)!;
1311
+ sheet.cells.delete(ref);
1312
+ sheet.cells.set(rowColToRef(row + height, c), cell);
1313
+ }
1314
+ }
1315
+ // Restore snapshot
1316
+ for (const [ref, snap] of snapshot) {
1317
+ sheet.set(ref, snap.value, sheet.styles?.get(snap.styleIndex), snap.numFmtCode, snap.formula);
1318
+ }
1319
+ },
1320
+ };
1321
+ }
1322
+
1323
+ // =============================================================================
1324
+ // Delete Cells (Shift Left)
1325
+ // =============================================================================
1326
+
1327
+ export function deleteCellsShiftLeftCommand(
1328
+ workbook: WorkbookModel,
1329
+ sheetIndex: number,
1330
+ startRow: number,
1331
+ startCol: number,
1332
+ endRow: number,
1333
+ endCol: number,
1334
+ ): Command {
1335
+ const sheet = workbook.sheets[sheetIndex];
1336
+ const width = endCol - startCol + 1;
1337
+
1338
+ // Snapshot deleted cells
1339
+ const snapshot = new Map<string, { value: CellValue; displayValue: string; formula?: string; styleIndex: number; numFmtCode: string; content: CellContent }>();
1340
+ for (let r = startRow; r <= endRow; r++) {
1341
+ for (let c = startCol; c <= endCol; c++) {
1342
+ const ref = rowColToRef(r, c);
1343
+ const cell = sheet.getCell(ref);
1344
+ if (cell) {
1345
+ snapshot.set(ref, { value: cell.value, displayValue: cell.displayValue, formula: cell.formula, styleIndex: cell.styleIndex, numFmtCode: cell.numFmtCode, content: cell.content });
1346
+ }
1347
+ }
1348
+ }
1349
+
1350
+ return {
1351
+ description: `Delete cells shift left ${rowColToRef(startRow, startCol)}:${rowColToRef(endRow, endCol)}`,
1352
+ execute() {
1353
+ for (let r = startRow; r <= endRow; r++) {
1354
+ for (let c = startCol; c <= endCol; c++) {
1355
+ sheet.deleteCell(rowColToRef(r, c));
1356
+ }
1357
+ const toMove: Array<{ col: number; ref: string }> = [];
1358
+ for (const [ref] of sheet.cells) {
1359
+ const rc = refToRowColSafe(ref);
1360
+ if (rc && rc.row === r && rc.col > endCol) toMove.push({ col: rc.col, ref });
1361
+ }
1362
+ toMove.sort((a, b) => a.col - b.col); // left-to-right
1363
+ for (const { ref, col } of toMove) {
1364
+ const cell = sheet.cells.get(ref)!;
1365
+ sheet.cells.delete(ref);
1366
+ sheet.cells.set(rowColToRef(r, col - width), cell);
1367
+ }
1368
+ }
1369
+ },
1370
+ undo() {
1371
+ for (let r = startRow; r <= endRow; r++) {
1372
+ const toMove: Array<{ col: number; ref: string }> = [];
1373
+ for (const [ref] of sheet.cells) {
1374
+ const rc = refToRowColSafe(ref);
1375
+ if (rc && rc.row === r && rc.col >= startCol) toMove.push({ col: rc.col, ref });
1376
+ }
1377
+ toMove.sort((a, b) => b.col - a.col); // right-to-left
1378
+ for (const { ref, col } of toMove) {
1379
+ const cell = sheet.cells.get(ref)!;
1380
+ sheet.cells.delete(ref);
1381
+ sheet.cells.set(rowColToRef(r, col + width), cell);
1382
+ }
1383
+ }
1384
+ for (const [ref, snap] of snapshot) {
1385
+ sheet.set(ref, snap.value, sheet.styles?.get(snap.styleIndex), snap.numFmtCode, snap.formula);
1386
+ }
1387
+ },
1388
+ };
1389
+ }
1390
+
1391
+ // =============================================================================
1392
+ // Set Show Grid Lines
1393
+ // =============================================================================
1394
+
1395
+ export function setShowGridLinesCommand(
1396
+ workbook: WorkbookModel,
1397
+ sheetIndex: number,
1398
+ show: boolean,
1399
+ ): Command {
1400
+ const sheet = workbook.sheets[sheetIndex];
1401
+ const oldValue = sheet.view.showGridLines;
1402
+
1403
+ return {
1404
+ description: show ? "Show grid lines" : "Hide grid lines",
1405
+ execute() {
1406
+ sheet.view.showGridLines = show;
1407
+ },
1408
+ undo() {
1409
+ sheet.view.showGridLines = oldValue;
1410
+ },
1411
+ };
1412
+ }
1413
+
1414
+ // =============================================================================
1415
+ // Set Show Headers
1416
+ // =============================================================================
1417
+
1418
+ export function setShowHeadersCommand(
1419
+ workbook: WorkbookModel,
1420
+ sheetIndex: number,
1421
+ show: boolean,
1422
+ ): Command {
1423
+ const sheet = workbook.sheets[sheetIndex];
1424
+ const oldValue = sheet.view.showHeaders;
1425
+
1426
+ return {
1427
+ description: show ? "Show headers" : "Hide headers",
1428
+ execute() {
1429
+ sheet.view.showHeaders = show;
1430
+ },
1431
+ undo() {
1432
+ sheet.view.showHeaders = oldValue;
1433
+ },
1434
+ };
1435
+ }
1436
+
1437
+ // =============================================================================
1438
+ // Set Hyperlink
1439
+ // =============================================================================
1440
+
1441
+ export function setHyperlinkCommand(
1442
+ workbook: WorkbookModel,
1443
+ sheetIndex: number,
1444
+ ref: string,
1445
+ url: string | undefined,
1446
+ ): Command {
1447
+ const sheet = workbook.sheets[sheetIndex];
1448
+ const oldUrl = sheet.hyperlinks.get(ref);
1449
+ const cell = sheet.getCell(ref);
1450
+ const oldContent = cell?.content;
1451
+
1452
+ return {
1453
+ description: url ? `Set hyperlink on ${ref}` : `Remove hyperlink from ${ref}`,
1454
+ execute() {
1455
+ if (url) {
1456
+ sheet.hyperlinks.set(ref, url);
1457
+ } else {
1458
+ sheet.hyperlinks.delete(ref);
1459
+ }
1460
+ // Rebuild content to reflect hyperlink change
1461
+ const c = sheet.getCell(ref);
1462
+ if (c) {
1463
+ const displayValue = c.displayValue;
1464
+ if (url) {
1465
+ c.content = { type: "hyperlink", text: displayValue, url };
1466
+ } else {
1467
+ c.content = { type: "plain", text: displayValue };
1468
+ }
1469
+ }
1470
+ },
1471
+ undo() {
1472
+ if (oldUrl) {
1473
+ sheet.hyperlinks.set(ref, oldUrl);
1474
+ } else {
1475
+ sheet.hyperlinks.delete(ref);
1476
+ }
1477
+ const c = sheet.getCell(ref);
1478
+ if (c && oldContent) {
1479
+ c.content = oldContent;
1480
+ } else if (c) {
1481
+ c.content = { type: "plain", text: c.displayValue };
1482
+ }
1483
+ },
1484
+ };
1485
+ }
1486
+
1487
+ // =============================================================================
1488
+ // Helpers
1489
+ // =============================================================================
1490
+
1491
+ /** Parse user input into a typed CellValue. */
1492
+ function parseInputValue(input: string): CellValue {
1493
+ if (input === "") return null;
1494
+ if (input.toUpperCase() === "TRUE") return true;
1495
+ if (input.toUpperCase() === "FALSE") return false;
1496
+ const num = Number(input);
1497
+ if (!isNaN(num) && input.trim() !== "") return num;
1498
+ return input;
1499
+ }
1500
+
1501
+ function refToRowColSafe(ref: string): { row: number; col: number } | undefined {
1502
+ return refToRowCol(ref) ?? undefined;
1503
+ }
1504
+
1505
+ /** Adjust all formula references in a sheet for a row insert/delete. */
1506
+ function adjustAllFormulasForRowShift(sheet: SheetModel, atRow: number, delta: number): void {
1507
+ for (const cell of sheet.cells.values()) {
1508
+ if (cell.formula) {
1509
+ cell.formula = adjustFormulaRefsForRowShift(cell.formula, atRow, delta);
1510
+ }
1511
+ }
1512
+ }
1513
+
1514
+ /** Adjust all formula references in a sheet for a column insert/delete. */
1515
+ function adjustAllFormulasForColShift(sheet: SheetModel, atCol: number, delta: number): void {
1516
+ for (const cell of sheet.cells.values()) {
1517
+ if (cell.formula) {
1518
+ cell.formula = adjustFormulaRefsForColShift(cell.formula, atCol, delta);
1519
+ }
1520
+ }
1521
+ }
1522
+
1523
+ /** Sort sheet rows in-place by a column value. */
1524
+ function sortColumnInPlace(sheet: SheetModel, col: number, ascending: boolean): void {
1525
+ const used = sheet.getUsedRange();
1526
+ const rows: { row: number; val: number | string | null }[] = [];
1527
+ for (let r = used.minRow; r <= used.maxRow; r++) {
1528
+ const ref = rowColToRef(r, col);
1529
+ const cell = sheet.getCell(ref);
1530
+ const val = cell?.value ?? null;
1531
+ rows.push({ row: r, val: typeof val === "boolean" ? (val ? 1 : 0) : val });
1532
+ }
1533
+ rows.sort((a, b) => {
1534
+ if (a.val === null && b.val === null) return 0;
1535
+ if (a.val === null) return 1;
1536
+ if (b.val === null) return -1;
1537
+ if (typeof a.val === "number" && typeof b.val === "number") return ascending ? a.val - b.val : b.val - a.val;
1538
+ const sa = String(a.val).toLowerCase();
1539
+ const sb = String(b.val).toLowerCase();
1540
+ return ascending ? sa.localeCompare(sb) : sb.localeCompare(sa);
1541
+ });
1542
+ // Snapshot all rows, then write back in new order
1543
+ const allCells = new Map<string, Map<number, ReturnType<typeof sheet.getCell>>>();
1544
+ for (let r = used.minRow; r <= used.maxRow; r++) {
1545
+ const rowCells = new Map<number, ReturnType<typeof sheet.getCell>>();
1546
+ for (let c = used.minCol; c <= used.maxCol; c++) {
1547
+ const ref = rowColToRef(r, c);
1548
+ const cell = sheet.getCell(ref);
1549
+ if (cell) rowCells.set(c, { ...cell });
1550
+ sheet.deleteCell(ref);
1551
+ }
1552
+ allCells.set(String(r), rowCells);
1553
+ }
1554
+ for (let i = 0; i < rows.length; i++) {
1555
+ const sourceRow = rows[i].row;
1556
+ const targetRow = used.minRow + i;
1557
+ const rowCells = allCells.get(String(sourceRow));
1558
+ if (!rowCells) continue;
1559
+ for (const [c, cell] of rowCells) {
1560
+ if (cell) {
1561
+ const ref = rowColToRef(targetRow, c);
1562
+ sheet.set(ref, cell.value, sheet.styles?.get(cell.styleIndex), cell.numFmtCode, cell.formula);
1563
+ }
1564
+ }
1565
+ }
1566
+ }
1567
+
1568
+ /** Sort sheet rows in-place by multiple column keys (cascading comparison). */
1569
+ function multiSortInPlace(sheet: SheetModel, sortSpecs: SortSpec[], startRow: number): void {
1570
+ const used = sheet.getUsedRange();
1571
+
1572
+ // Build row index with sort key values
1573
+ const rowEntries: { row: number; vals: (number | string | null)[] }[] = [];
1574
+ for (let r = startRow; r <= used.maxRow; r++) {
1575
+ const vals = sortSpecs.map((spec) => {
1576
+ const ref = rowColToRef(r, spec.col);
1577
+ const cell = sheet.getCell(ref);
1578
+ const val = cell?.value ?? null;
1579
+ return typeof val === "boolean" ? (val ? 1 : 0) : val;
1580
+ });
1581
+ rowEntries.push({ row: r, vals });
1582
+ }
1583
+
1584
+ // Cascading comparison: try each sort key in order
1585
+ rowEntries.sort((a, b) => {
1586
+ for (let k = 0; k < sortSpecs.length; k++) {
1587
+ const aVal = a.vals[k];
1588
+ const bVal = b.vals[k];
1589
+ const asc = sortSpecs[k].ascending;
1590
+ if (aVal === null && bVal === null) continue;
1591
+ if (aVal === null) return 1;
1592
+ if (bVal === null) return -1;
1593
+ if (typeof aVal === "number" && typeof bVal === "number") {
1594
+ const diff = asc ? aVal - bVal : bVal - aVal;
1595
+ if (diff !== 0) return diff;
1596
+ continue;
1597
+ }
1598
+ const sa = String(aVal).toLowerCase();
1599
+ const sb = String(bVal).toLowerCase();
1600
+ const cmp = asc ? sa.localeCompare(sb) : sb.localeCompare(sa);
1601
+ if (cmp !== 0) return cmp;
1602
+ }
1603
+ return 0;
1604
+ });
1605
+
1606
+ // Snapshot all rows, then write back in new order
1607
+ const allCells = new Map<string, Map<number, ReturnType<typeof sheet.getCell>>>();
1608
+ for (let r = startRow; r <= used.maxRow; r++) {
1609
+ const rowCells = new Map<number, ReturnType<typeof sheet.getCell>>();
1610
+ for (let c = used.minCol; c <= used.maxCol; c++) {
1611
+ const ref = rowColToRef(r, c);
1612
+ const cell = sheet.getCell(ref);
1613
+ if (cell) rowCells.set(c, { ...cell });
1614
+ sheet.deleteCell(ref);
1615
+ }
1616
+ allCells.set(String(r), rowCells);
1617
+ }
1618
+ for (let i = 0; i < rowEntries.length; i++) {
1619
+ const sourceRow = rowEntries[i].row;
1620
+ const targetRow = startRow + i;
1621
+ const rowCells = allCells.get(String(sourceRow));
1622
+ if (!rowCells) continue;
1623
+ for (const [c, cell] of rowCells) {
1624
+ if (cell) {
1625
+ const ref = rowColToRef(targetRow, c);
1626
+ sheet.set(ref, cell.value, sheet.styles?.get(cell.styleIndex), cell.numFmtCode, cell.formula);
1627
+ }
1628
+ }
1629
+ }
1630
+ }
1631
+
1632
+ // =============================================================================
1633
+ // Merged-cell range adjustment
1634
+ // =============================================================================
1635
+ //
1636
+ // `insertRowsCommand` and `deleteRowsCommand` now own merged-cell range
1637
+ // shifting and contraction. Previously this lived as separate functions
1638
+ // callers had to remember to invoke (e.g. excel_placeholder_engine).
1639
+
1640
+ /** Shift merged-cell ranges to accommodate a row insertion at `atRow`. */
1641
+ export function adjustMergedCellsForRowInsert(sheet: SheetModel, atRow: number, count: number): void {
1642
+ sheet.mergedCells = sheet.mergedCells.map((range) => {
1643
+ const [startRef, endRef] = range.split(":");
1644
+ const start = refToRowCol(startRef);
1645
+ const end = refToRowCol(endRef);
1646
+ if (!start || !end) return range;
1647
+
1648
+ let r1 = start.row;
1649
+ let r2 = end.row;
1650
+
1651
+ if (r1 >= atRow) {
1652
+ r1 += count;
1653
+ r2 += count;
1654
+ } else if (r2 >= atRow) {
1655
+ r2 += count;
1656
+ }
1657
+
1658
+ return `${rowColToRef(r1, start.col)}:${rowColToRef(r2, end.col)}`;
1659
+ });
1660
+ }
1661
+
1662
+ /**
1663
+ * Adjust merged-cell ranges after a row deletion. Ranges fully inside the
1664
+ * deleted span are dropped, overlapping ranges are contracted, and ranges
1665
+ * entirely after the deletion shift up.
1666
+ */
1667
+ export function adjustMergedCellsForRowDelete(sheet: SheetModel, atRow: number, count: number): void {
1668
+ const endRow = atRow + count - 1;
1669
+ const updated: string[] = [];
1670
+ for (const range of sheet.mergedCells) {
1671
+ const [startRef, endRef] = range.split(":");
1672
+ const start = refToRowCol(startRef);
1673
+ const end = refToRowCol(endRef);
1674
+ if (!start || !end) {
1675
+ updated.push(range);
1676
+ continue;
1677
+ }
1678
+
1679
+ let r1 = start.row;
1680
+ let r2 = end.row;
1681
+
1682
+ if (r1 > endRow) {
1683
+ r1 -= count;
1684
+ r2 -= count;
1685
+ } else if (r2 < atRow) {
1686
+ // unchanged
1687
+ } else if (r1 >= atRow && r2 <= endRow) {
1688
+ continue;
1689
+ } else if (r1 < atRow && r2 > endRow) {
1690
+ r2 -= count;
1691
+ } else if (r1 < atRow) {
1692
+ r2 = atRow - 1;
1693
+ } else {
1694
+ r1 = atRow;
1695
+ r2 -= count;
1696
+ }
1697
+
1698
+ if (r2 < r1) continue;
1699
+ updated.push(`${rowColToRef(r1, start.col)}:${rowColToRef(r2, end.col)}`);
1700
+ }
1701
+ sheet.mergedCells = updated;
1702
+ }
1703
+
1704
+ /** Shift merged-cell ranges to accommodate a column insertion at `atCol`. */
1705
+ export function adjustMergedCellsForColInsert(sheet: SheetModel, atCol: number, count: number): void {
1706
+ sheet.mergedCells = sheet.mergedCells.map((range) => {
1707
+ const [startRef, endRef] = range.split(":");
1708
+ const start = refToRowCol(startRef);
1709
+ const end = refToRowCol(endRef);
1710
+ if (!start || !end) return range;
1711
+
1712
+ let c1 = start.col;
1713
+ let c2 = end.col;
1714
+
1715
+ if (c1 >= atCol) {
1716
+ c1 += count;
1717
+ c2 += count;
1718
+ } else if (c2 >= atCol) {
1719
+ c2 += count;
1720
+ }
1721
+
1722
+ return `${rowColToRef(start.row, c1)}:${rowColToRef(end.row, c2)}`;
1723
+ });
1724
+ }
1725
+
1726
+ /** Adjust merged-cell ranges after a column deletion. Mirrors the row variant. */
1727
+ export function adjustMergedCellsForColDelete(sheet: SheetModel, atCol: number, count: number): void {
1728
+ const endCol = atCol + count - 1;
1729
+ const updated: string[] = [];
1730
+ for (const range of sheet.mergedCells) {
1731
+ const [startRef, endRef] = range.split(":");
1732
+ const start = refToRowCol(startRef);
1733
+ const end = refToRowCol(endRef);
1734
+ if (!start || !end) {
1735
+ updated.push(range);
1736
+ continue;
1737
+ }
1738
+
1739
+ let c1 = start.col;
1740
+ let c2 = end.col;
1741
+
1742
+ if (c1 > endCol) {
1743
+ c1 -= count;
1744
+ c2 -= count;
1745
+ } else if (c2 < atCol) {
1746
+ // unchanged
1747
+ } else if (c1 >= atCol && c2 <= endCol) {
1748
+ continue;
1749
+ } else if (c1 < atCol && c2 > endCol) {
1750
+ c2 -= count;
1751
+ } else if (c1 < atCol) {
1752
+ c2 = atCol - 1;
1753
+ } else {
1754
+ c1 = atCol;
1755
+ c2 -= count;
1756
+ }
1757
+
1758
+ if (c2 < c1) continue;
1759
+ updated.push(`${rowColToRef(start.row, c1)}:${rowColToRef(end.row, c2)}`);
1760
+ }
1761
+ sheet.mergedCells = updated;
1762
+ }
1763
+
1764
+ // =============================================================================
1765
+ // Formula range expansion
1766
+ // =============================================================================
1767
+
1768
+ /**
1769
+ * After inserting rows inside a region used by a range formula (e.g.
1770
+ * `SUM(C5:C5)` with template row 5 + 9 inserted rows → `SUM(C5:C14)`),
1771
+ * widen any range whose end exactly matches the template's last row.
1772
+ *
1773
+ * Used by the Excel template placeholder engine when expanding `{{#each}}`
1774
+ * blocks. Lives in the command layer so future callers don't have to
1775
+ * reimplement it.
1776
+ */
1777
+ export function expandFormulaRangesForRowInsert(
1778
+ sheet: SheetModel,
1779
+ templateStartRow: number,
1780
+ templateEndRow: number,
1781
+ insertedCount: number,
1782
+ ): void {
1783
+ const rangeRe = /(\$?)([A-Z]+)(\$?)(\d+):(\$?)([A-Z]+)(\$?)(\d+)/g;
1784
+
1785
+ for (const [ref, cell] of sheet.cells) {
1786
+ if (!cell.formula) continue;
1787
+
1788
+ const newFormula = cell.formula.replace(
1789
+ rangeRe,
1790
+ (match, ca1, c1, ra1, r1s, ca2, c2, ra2, r2s) => {
1791
+ const r1 = parseInt(r1s, 10);
1792
+ const r2 = parseInt(r2s, 10);
1793
+
1794
+ if (r1 <= templateStartRow && r2 === templateEndRow) {
1795
+ return `${ca1}${c1}${ra1}${r1}:${ca2}${c2}${ra2}${r2 + insertedCount}`;
1796
+ }
1797
+ return match;
1798
+ },
1799
+ );
1800
+
1801
+ if (newFormula !== cell.formula) {
1802
+ sheet.set(ref, cell.value, sheet.styles?.get(cell.styleIndex), cell.numFmtCode, newFormula);
1803
+ }
1804
+ }
1805
+ }