@pagent-libs/core 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 (111) hide show
  1. package/README.md +32 -0
  2. package/dist/canvas/cell-renderer.d.ts +45 -0
  3. package/dist/canvas/cell-renderer.d.ts.map +1 -0
  4. package/dist/canvas/grid-renderer.d.ts +29 -0
  5. package/dist/canvas/grid-renderer.d.ts.map +1 -0
  6. package/dist/canvas/header-renderer.d.ts +58 -0
  7. package/dist/canvas/header-renderer.d.ts.map +1 -0
  8. package/dist/canvas/hit-testing.d.ts +81 -0
  9. package/dist/canvas/hit-testing.d.ts.map +1 -0
  10. package/dist/canvas/index.d.ts +9 -0
  11. package/dist/canvas/index.d.ts.map +1 -0
  12. package/dist/canvas/renderer.d.ts +140 -0
  13. package/dist/canvas/renderer.d.ts.map +1 -0
  14. package/dist/canvas/selection-renderer.d.ts +55 -0
  15. package/dist/canvas/selection-renderer.d.ts.map +1 -0
  16. package/dist/canvas/text-renderer.d.ts +49 -0
  17. package/dist/canvas/text-renderer.d.ts.map +1 -0
  18. package/dist/canvas/types.d.ts +200 -0
  19. package/dist/canvas/types.d.ts.map +1 -0
  20. package/dist/collaboration/firebase-provider.d.ts +13 -0
  21. package/dist/collaboration/firebase-provider.d.ts.map +1 -0
  22. package/dist/collaboration/index.d.ts +3 -0
  23. package/dist/collaboration/index.d.ts.map +1 -0
  24. package/dist/collaboration/types.d.ts +34 -0
  25. package/dist/collaboration/types.d.ts.map +1 -0
  26. package/dist/event-emitter.d.ts +13 -0
  27. package/dist/event-emitter.d.ts.map +1 -0
  28. package/dist/export/csv.d.ts +5 -0
  29. package/dist/export/csv.d.ts.map +1 -0
  30. package/dist/export/index.d.ts +2 -0
  31. package/dist/export/index.d.ts.map +1 -0
  32. package/dist/features/filter.d.ts +58 -0
  33. package/dist/features/filter.d.ts.map +1 -0
  34. package/dist/features/freeze.d.ts +86 -0
  35. package/dist/features/freeze.d.ts.map +1 -0
  36. package/dist/features/index.d.ts +4 -0
  37. package/dist/features/index.d.ts.map +1 -0
  38. package/dist/features/sort.d.ts +15 -0
  39. package/dist/features/sort.d.ts.map +1 -0
  40. package/dist/format-pool.d.ts +17 -0
  41. package/dist/format-pool.d.ts.map +1 -0
  42. package/dist/formula-graph.d.ts +12 -0
  43. package/dist/formula-graph.d.ts.map +1 -0
  44. package/dist/formula-parser/cell-reference.d.ts +7 -0
  45. package/dist/formula-parser/cell-reference.d.ts.map +1 -0
  46. package/dist/formula-parser/formula-adjust.d.ts +13 -0
  47. package/dist/formula-parser/formula-adjust.d.ts.map +1 -0
  48. package/dist/formula-parser/formula-ranges.d.ts +22 -0
  49. package/dist/formula-parser/formula-ranges.d.ts.map +1 -0
  50. package/dist/formula-parser/index.d.ts +6 -0
  51. package/dist/formula-parser/index.d.ts.map +1 -0
  52. package/dist/formula-parser/parser.d.ts +18 -0
  53. package/dist/formula-parser/parser.d.ts.map +1 -0
  54. package/dist/formula-parser/types.d.ts +33 -0
  55. package/dist/formula-parser/types.d.ts.map +1 -0
  56. package/dist/index.d.ts +15 -0
  57. package/dist/index.d.ts.map +1 -0
  58. package/dist/index.esm.js +5823 -0
  59. package/dist/index.esm.js.map +1 -0
  60. package/dist/index.js +5885 -0
  61. package/dist/index.js.map +1 -0
  62. package/dist/sheet.d.ts +119 -0
  63. package/dist/sheet.d.ts.map +1 -0
  64. package/dist/style-pool.d.ts +17 -0
  65. package/dist/style-pool.d.ts.map +1 -0
  66. package/dist/types.d.ts +260 -0
  67. package/dist/types.d.ts.map +1 -0
  68. package/dist/utils/cell-key.d.ts +7 -0
  69. package/dist/utils/cell-key.d.ts.map +1 -0
  70. package/dist/utils/format-utils.d.ts +75 -0
  71. package/dist/utils/format-utils.d.ts.map +1 -0
  72. package/dist/utils/range.d.ts +13 -0
  73. package/dist/utils/range.d.ts.map +1 -0
  74. package/dist/workbook.d.ts +155 -0
  75. package/dist/workbook.d.ts.map +1 -0
  76. package/package.json +46 -0
  77. package/src/canvas/cell-renderer.ts +181 -0
  78. package/src/canvas/grid-renderer.ts +238 -0
  79. package/src/canvas/header-renderer.ts +402 -0
  80. package/src/canvas/hit-testing.ts +537 -0
  81. package/src/canvas/index.ts +16 -0
  82. package/src/canvas/renderer.ts +1056 -0
  83. package/src/canvas/selection-renderer.ts +604 -0
  84. package/src/canvas/text-renderer.ts +321 -0
  85. package/src/canvas/types.ts +289 -0
  86. package/src/collaboration/firebase-provider.ts +48 -0
  87. package/src/collaboration/index.ts +5 -0
  88. package/src/collaboration/types.ts +38 -0
  89. package/src/event-emitter.ts +73 -0
  90. package/src/export/csv.ts +101 -0
  91. package/src/export/index.ts +4 -0
  92. package/src/features/filter.ts +231 -0
  93. package/src/features/freeze.ts +271 -0
  94. package/src/features/index.ts +5 -0
  95. package/src/features/sort.ts +282 -0
  96. package/src/format-pool.ts +61 -0
  97. package/src/formula-graph.ts +84 -0
  98. package/src/formula-parser/cell-reference.ts +99 -0
  99. package/src/formula-parser/formula-adjust.ts +129 -0
  100. package/src/formula-parser/formula-ranges.ts +159 -0
  101. package/src/formula-parser/index.ts +8 -0
  102. package/src/formula-parser/parser.ts +438 -0
  103. package/src/formula-parser/types.ts +39 -0
  104. package/src/index.ts +25 -0
  105. package/src/sheet.ts +502 -0
  106. package/src/style-pool.ts +62 -0
  107. package/src/types.ts +291 -0
  108. package/src/utils/cell-key.ts +19 -0
  109. package/src/utils/format-utils.ts +515 -0
  110. package/src/utils/range.ts +53 -0
  111. package/src/workbook.ts +1031 -0
@@ -0,0 +1,1031 @@
1
+ // Workbook model
2
+
3
+ import type { Workbook, Sheet, Cell, Selection, CellValue, SortOrder, CellFormat, CellStyle, EventType, EventHandler } from './types';
4
+ import { SheetImpl } from './sheet';
5
+ import { EventEmitter } from './event-emitter';
6
+ import { FormulaGraphImpl } from './formula-graph';
7
+ import { StylePool } from './style-pool';
8
+ import { FormatPool } from './format-pool';
9
+ import { SortManager } from './features/sort';
10
+ import { getCellKey, parseCellKey } from './utils/cell-key';
11
+ import { FormulaParser } from './formula-parser';
12
+ import type { RangeReference } from './formula-parser';
13
+
14
+ // Snapshot for undo/redo
15
+ interface WorkbookSnapshot {
16
+ sheets: Map<string, SheetSnapshot>;
17
+ activeSheetId: string;
18
+ selection: Selection;
19
+ }
20
+
21
+ interface SheetSnapshot {
22
+ id: string;
23
+ name: string;
24
+ cells: Map<string, Cell>;
25
+ config: Sheet['config'];
26
+ rowCount: number;
27
+ colCount: number;
28
+ }
29
+
30
+ export class WorkbookImpl implements Workbook {
31
+ id: string;
32
+ name: string;
33
+ sheets: Map<string, Sheet> = new Map();
34
+ activeSheetId: string;
35
+ defaultRowHeight: number;
36
+ defaultColWidth: number;
37
+
38
+ private events: EventEmitter = new EventEmitter();
39
+ private formulaGraph: FormulaGraphImpl = new FormulaGraphImpl();
40
+ private stylePool: StylePool = new StylePool();
41
+ private formatPool: FormatPool = new FormatPool();
42
+ private formulaParser: FormulaParser = new FormulaParser();
43
+ private selection: Selection = {
44
+ ranges: [],
45
+ activeCell: { row: 0, col: 0 },
46
+ };
47
+ private evaluatingCells: Set<string> = new Set(); // Track cells being evaluated to detect circular references
48
+ private undoStack: WorkbookSnapshot[] = [];
49
+ private redoStack: WorkbookSnapshot[] = [];
50
+ private maxHistorySize = 50; // Limit history size to prevent memory issues
51
+ private isUndoing = false; // Flag to prevent recording history during undo/redo
52
+ private isRedoing = false;
53
+ private isBatching = false; // Flag to track if we're in a batch operation
54
+
55
+ constructor(id: string, name: string) {
56
+ this.id = id;
57
+ this.name = name;
58
+ this.defaultRowHeight = 20;
59
+ this.defaultColWidth = 100;
60
+
61
+ // Create default sheet
62
+ const defaultSheet = this.addSheet('Sheet1');
63
+ this.activeSheetId = defaultSheet.id;
64
+ }
65
+
66
+ addSheet(name: string): Sheet {
67
+ const id = `sheet_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
68
+ const sheet = new SheetImpl(id, name);
69
+ this.sheets.set(id, sheet);
70
+ this.events.emit('sheetAdd', { sheetId: id, name });
71
+ return sheet;
72
+ }
73
+
74
+ deleteSheet(sheetId: string): void {
75
+ if (this.sheets.size <= 1) {
76
+ throw new Error('Cannot delete the last sheet');
77
+ }
78
+ this.sheets.delete(sheetId);
79
+ if (this.activeSheetId === sheetId) {
80
+ // Switch to first available sheet
81
+ this.activeSheetId = Array.from(this.sheets.keys())[0];
82
+ }
83
+ this.events.emit('sheetDelete', { sheetId });
84
+ }
85
+
86
+ getSheet(sheetId?: string): SheetImpl {
87
+ const id = sheetId ?? this.activeSheetId;
88
+ const sheet = this.sheets.get(id);
89
+ if (!sheet) {
90
+ throw new Error(`Sheet not found: ${id}`);
91
+ }
92
+ return sheet as SheetImpl;
93
+ }
94
+
95
+ setActiveSheet(sheetId: string): void {
96
+ if (!this.sheets.has(sheetId)) {
97
+ throw new Error(`Sheet not found: ${sheetId}`);
98
+ }
99
+ this.activeSheetId = sheetId;
100
+ this.events.emit('sheetChange', { sheetId });
101
+ }
102
+
103
+ renameSheet(sheetId: string, newName: string): void {
104
+ const sheet = this.getSheet(sheetId);
105
+ const oldName = sheet.name;
106
+ sheet.name = newName;
107
+ this.events.emit('sheetRename', { sheetId, oldName, newName });
108
+ }
109
+
110
+ getSheetIdByName(sheetName: string): string | undefined {
111
+ for (const [id, sheet] of this.sheets.entries()) {
112
+ if (sheet.name === sheetName) {
113
+ return id;
114
+ }
115
+ }
116
+ return undefined;
117
+ }
118
+
119
+ getCell(sheetId: string | undefined, row: number, col: number): Cell | undefined {
120
+ const sheet = this.getSheet(sheetId);
121
+ return sheet.getCell(row, col);
122
+ }
123
+
124
+ setCell(
125
+ sheetId: string | undefined,
126
+ row: number,
127
+ col: number,
128
+ cell: Partial<Cell>
129
+ ): void {
130
+ // Handle style pooling before storing
131
+ if (cell.styleId) {
132
+ // Style already pooled
133
+ } else if (cell.styleId === undefined && 'style' in cell) {
134
+ // Need to pool the style
135
+ const styleId = this.stylePool.getOrCreate(cell.style as CellStyle);
136
+ cell.styleId = styleId;
137
+ delete (cell as Partial<Cell> & { style?: CellStyle }).style;
138
+ }
139
+
140
+ // Handle format pooling before storing
141
+ if (cell.formatId) {
142
+ // Format already pooled
143
+ } else if (cell.formatId === undefined && 'format' in cell) {
144
+ // Clean the format before pooling to ensure consistent keys
145
+ const cleanedFormat = this.cleanFormat(cell.format as CellFormat);
146
+ const formatId = this.formatPool.getOrCreate(cleanedFormat);
147
+ cell.formatId = formatId;
148
+ delete (cell as Partial<Cell> & { format?: CellFormat }).format;
149
+ }
150
+
151
+ const sheet = this.getSheet(sheetId);
152
+ sheet.setCell(row, col, cell);
153
+
154
+ this.events.emit('cellChange', {
155
+ sheetId: sheet.id,
156
+ row,
157
+ col,
158
+ cellKey: getCellKey(row, col),
159
+ });
160
+ }
161
+
162
+ setCellValue(sheetId: string | undefined, row: number, col: number, value: unknown): void {
163
+ // Record history before making changes (unless we're undoing/redoing or in a batch)
164
+ if (!this.isUndoing && !this.isRedoing && !this.isBatching) {
165
+ const currentCell = this.getCell(sheetId, row, col);
166
+ const currentValue = currentCell?.value;
167
+ if (currentValue !== value) {
168
+ this.recordHistory();
169
+ }
170
+ }
171
+
172
+ const cellKey = getCellKey(row, col);
173
+ const hadFormula = this.formulaGraph.nodes.has(cellKey);
174
+
175
+ // Remove formula if setting a direct value
176
+ if (hadFormula) {
177
+ this.formulaGraph.removeFormula(cellKey);
178
+ }
179
+
180
+ this.setCell(sheetId, row, col, { value: value as string | number | boolean | null });
181
+
182
+ // Invalidate dependents of this cell
183
+ if (hadFormula || this.getCell(sheetId, row, col)?.value !== undefined) {
184
+ this.formulaGraph.invalidate(cellKey);
185
+ this.recalculateDependents(cellKey, sheetId);
186
+ }
187
+ }
188
+
189
+ getCellValue(sheetId: string | undefined, row: number, col: number): unknown {
190
+ const cell = this.getCell(sheetId, row, col);
191
+ return cell?.value ?? null;
192
+ }
193
+
194
+ setFormula(sheetId: string | undefined, row: number, col: number, formula: string): void {
195
+ // Record history before making changes (unless we're undoing/redoing or in a batch)
196
+ if (!this.isUndoing && !this.isRedoing && !this.isBatching) {
197
+ const currentCell = this.getCell(sheetId, row, col);
198
+ const currentFormula = currentCell?.formula;
199
+ if (currentFormula !== formula) {
200
+ this.recordHistory();
201
+ }
202
+ }
203
+
204
+ const cellKey = getCellKey(row, col);
205
+
206
+ // Parse formula to get dependencies
207
+ const parseResult = this.formulaParser.parse(formula, row, col);
208
+
209
+ if (parseResult.error) {
210
+ // Store error in cell value
211
+ this.setCell(sheetId, row, col, { formula, value: parseResult.error as CellValue });
212
+ return;
213
+ }
214
+
215
+ // Update formula graph with dependencies
216
+ this.formulaGraph.addFormula(cellKey, formula, parseResult.dependencies);
217
+
218
+ // Set cell with formula
219
+ this.setCell(sheetId, row, col, { formula });
220
+
221
+ // Evaluate and store result
222
+ this.evaluateFormula(sheetId, row, col);
223
+
224
+ // Recalculate dependents
225
+ this.recalculateDependents(cellKey, sheetId);
226
+ }
227
+
228
+ getSelection(): Selection {
229
+ return { ...this.selection };
230
+ }
231
+
232
+ setSelection(selection: Selection): void {
233
+ this.selection = selection;
234
+ this.events.emit('cellSelection', { selection });
235
+ }
236
+
237
+ batch(operations: () => void): void {
238
+ // Record history before batch operations (unless we're undoing/redoing)
239
+ if (!this.isUndoing && !this.isRedoing) {
240
+ this.isBatching = true;
241
+ this.recordHistory();
242
+ }
243
+ this.events.batch(operations);
244
+ this.isBatching = false;
245
+ }
246
+
247
+ on(event: EventType, handler: EventHandler): () => void {
248
+ return this.events.on(event, handler);
249
+ }
250
+
251
+ off(event: EventType, handler: EventHandler): void {
252
+ this.events.off(event, handler);
253
+ }
254
+
255
+ getFormulaGraph(): FormulaGraphImpl {
256
+ return this.formulaGraph;
257
+ }
258
+
259
+ getStylePool(): StylePool {
260
+ return this.stylePool;
261
+ }
262
+
263
+ getFormatPool(): FormatPool {
264
+ return this.formatPool;
265
+ }
266
+
267
+ /**
268
+ * Get the calculated value of a cell (evaluates formulas)
269
+ */
270
+ getCellCalculatedValue(sheetId: string | undefined, row: number, col: number): unknown {
271
+ const cell = this.getCell(sheetId, row, col);
272
+ const cellKey = getCellKey(row, col);
273
+
274
+ // If cell has a formula, evaluate it
275
+ if (cell?.formula) {
276
+ const formulaNode = this.formulaGraph.nodes.get(cellKey);
277
+
278
+ // Return cached value if available and not dirty
279
+ if (formulaNode && !formulaNode.isDirty && formulaNode.cachedValue !== undefined) {
280
+ return formulaNode.cachedValue;
281
+ }
282
+
283
+ // Check for circular reference BEFORE evaluating
284
+ if (this.evaluatingCells.has(cellKey)) {
285
+ // This cell is already being evaluated - circular reference!
286
+ return '#CIRCULAR!';
287
+ }
288
+
289
+ // Evaluate formula
290
+ return this.evaluateFormula(sheetId, row, col);
291
+ }
292
+
293
+ // Return direct value
294
+ // For empty cells, return 0 (standard spreadsheet behavior for numeric formulas)
295
+ // For cells with values, return the actual value (string, number, boolean, etc.)
296
+ if (!cell) return 0;
297
+
298
+ // Return the actual value - don't convert null/undefined to 0 here
299
+ // The formula parser will handle type conversion as needed
300
+ if (cell.value === null || cell.value === undefined) {
301
+ return 0; // Empty cells return 0 for numeric calculations
302
+ }
303
+
304
+ return cell.value;
305
+ }
306
+
307
+ /**
308
+ * Evaluate a formula and store the result
309
+ */
310
+ private evaluateFormula(sheetId: string | undefined, row: number, col: number): unknown {
311
+ const cellKey = getCellKey(row, col);
312
+ const cell = this.getCell(sheetId, row, col);
313
+
314
+ if (!cell?.formula) {
315
+ return cell?.value ?? null;
316
+ }
317
+
318
+ // Check for circular reference BEFORE adding to evaluatingCells
319
+ if (this.evaluatingCells.has(cellKey)) {
320
+ const error = '#CIRCULAR!';
321
+ this.formulaGraph.markClean(cellKey, error);
322
+ this.setCell(sheetId, row, col, { value: error });
323
+ return error;
324
+ }
325
+
326
+ // Add to evaluating cells to detect circular references
327
+ this.evaluatingCells.add(cellKey);
328
+
329
+ try {
330
+ // Parse formula (parser resolves relative references during parsing)
331
+ const parseResult = this.formulaParser.parse(cell.formula, row, col);
332
+
333
+ if (parseResult.error) {
334
+ this.evaluatingCells.delete(cellKey);
335
+ this.formulaGraph.markClean(cellKey, parseResult.error as CellValue);
336
+ this.setCell(sheetId, row, col, { value: parseResult.error as CellValue });
337
+ return parseResult.error;
338
+ }
339
+
340
+ // Create evaluation context with current formula cell position captured in closure
341
+ const evaluationContext = {
342
+ getCellValue: (r: number, c: number, sId?: string, sheetName?: string) => {
343
+ // Resolve sheet name to sheet ID if provided
344
+ let targetSheetId = sId ?? sheetId;
345
+ if (sheetName) {
346
+ const resolvedSheetId = this.getSheetIdByName(sheetName);
347
+ if (resolvedSheetId) {
348
+ targetSheetId = resolvedSheetId;
349
+ } else {
350
+ // Sheet not found - return error
351
+ return '#REF!';
352
+ }
353
+ }
354
+ return this.getCellCalculatedValue(targetSheetId, r, c);
355
+ },
356
+ getRangeValues: (range: RangeReference, sId?: string, sheetName?: string) => {
357
+ // Resolve sheet name to sheet ID if provided
358
+ let targetSheetId = sId ?? sheetId;
359
+ if (sheetName) {
360
+ const resolvedSheetId = this.getSheetIdByName(sheetName);
361
+ if (resolvedSheetId) {
362
+ targetSheetId = resolvedSheetId;
363
+ } else {
364
+ // Sheet not found - return empty array
365
+ return [];
366
+ }
367
+ }
368
+ // Use captured row/col from the formula cell being evaluated
369
+ return this.getRangeValues(targetSheetId, range, row, col);
370
+ },
371
+ getSheetIdByName: (sheetName: string) => {
372
+ return this.getSheetIdByName(sheetName);
373
+ },
374
+ };
375
+
376
+ // Evaluate formula
377
+ const result = this.formulaParser.evaluate(parseResult.ast, evaluationContext, row, col);
378
+
379
+ // Handle division by zero
380
+ if (typeof result === 'number' && !isFinite(result)) {
381
+ const error = result === Infinity || result === -Infinity ? '#DIV/0!' : '#NUM!';
382
+ this.evaluatingCells.delete(cellKey);
383
+ this.formulaGraph.markClean(cellKey, error);
384
+ this.setCell(sheetId, row, col, { value: error });
385
+ return error;
386
+ }
387
+
388
+ // Store result
389
+ const cellValue = result as CellValue;
390
+ this.evaluatingCells.delete(cellKey);
391
+ this.formulaGraph.markClean(cellKey, cellValue);
392
+
393
+ // Update cell value (but keep formula)
394
+ const currentCell = this.getCell(sheetId, row, col);
395
+ if (currentCell) {
396
+ this.setCell(sheetId, row, col, { ...currentCell, value: cellValue });
397
+ }
398
+
399
+ return result;
400
+ } catch (error) {
401
+ this.evaluatingCells.delete(cellKey);
402
+ const errorMsg = error instanceof Error ? error.message : '#ERROR!';
403
+ this.formulaGraph.markClean(cellKey, errorMsg as CellValue);
404
+ this.setCell(sheetId, row, col, { value: errorMsg as CellValue });
405
+ return errorMsg;
406
+ }
407
+ }
408
+
409
+ /**
410
+ * Get values from a range for formula evaluation
411
+ * Note: Range references are already resolved to absolute positions during parsing,
412
+ * but we keep the currentRow/currentCol parameters for consistency with the API
413
+ */
414
+ private getRangeValues(
415
+ sheetId: string | undefined,
416
+ range: RangeReference,
417
+ currentRow: number,
418
+ currentCol: number
419
+ ): unknown[][] {
420
+ const { start, end } = range;
421
+
422
+ // Resolve relative references to absolute positions
423
+ const startRow = start.rowAbsolute ? start.row : currentRow + start.row;
424
+ const endRow = end.rowAbsolute ? end.row : currentRow + end.row;
425
+ const startCol = start.colAbsolute ? start.col : currentCol + start.col;
426
+ const endCol = end.colAbsolute ? end.col : currentCol + end.col;
427
+
428
+ const minRow = Math.min(startRow, endRow);
429
+ const maxRow = Math.max(startRow, endRow);
430
+ const minCol = Math.min(startCol, endCol);
431
+ const maxCol = Math.max(startCol, endCol);
432
+
433
+ const values: unknown[][] = [];
434
+ for (let row = minRow; row <= maxRow; row++) {
435
+ const rowValues: unknown[] = [];
436
+ for (let col = minCol; col <= maxCol; col++) {
437
+ rowValues.push(this.getCellCalculatedValue(sheetId, row, col));
438
+ }
439
+ values.push(rowValues);
440
+ }
441
+
442
+ return values;
443
+ }
444
+
445
+ /**
446
+ * Recalculate all cells that depend on the given cell
447
+ */
448
+ private recalculateDependents(cellKey: string, sheetId: string | undefined): void {
449
+ const dependents = this.formulaGraph.getDependents(cellKey);
450
+
451
+ for (const dependentKey of dependents) {
452
+ // Invalidate dependent
453
+ this.formulaGraph.invalidate(dependentKey);
454
+
455
+ // Recalculate dependent
456
+ const { row, col } = parseCellKey(dependentKey);
457
+ this.evaluateFormula(sheetId, row, col);
458
+ }
459
+ }
460
+
461
+ /**
462
+ * Create a snapshot of the current workbook state
463
+ */
464
+ private createSnapshot(): WorkbookSnapshot {
465
+ const sheets = new Map<string, SheetSnapshot>();
466
+
467
+ for (const [sheetId, sheet] of this.sheets.entries()) {
468
+ // Deep clone cells
469
+ const cells = new Map<string, Cell>();
470
+ for (const [key, cell] of sheet.cells.entries()) {
471
+ cells.set(key, { ...cell });
472
+ }
473
+
474
+ // Deep clone config
475
+ const config = {
476
+ ...sheet.config,
477
+ rowHeights: sheet.config.rowHeights ? new Map(sheet.config.rowHeights) : undefined,
478
+ colWidths: sheet.config.colWidths ? new Map(sheet.config.colWidths) : undefined,
479
+ hiddenRows: sheet.config.hiddenRows ? new Set(sheet.config.hiddenRows) : undefined,
480
+ hiddenCols: sheet.config.hiddenCols ? new Set(sheet.config.hiddenCols) : undefined,
481
+ };
482
+
483
+ sheets.set(sheetId, {
484
+ id: sheet.id,
485
+ name: sheet.name,
486
+ cells,
487
+ config,
488
+ rowCount: sheet.rowCount,
489
+ colCount: sheet.colCount,
490
+ });
491
+ }
492
+
493
+ return {
494
+ sheets,
495
+ activeSheetId: this.activeSheetId,
496
+ selection: { ...this.selection },
497
+ };
498
+ }
499
+
500
+ /**
501
+ * Restore workbook state from a snapshot
502
+ */
503
+ private restoreSnapshot(snapshot: WorkbookSnapshot): void {
504
+ // Clear existing sheets
505
+ this.sheets.clear();
506
+
507
+ // Restore sheets
508
+ for (const [sheetId, sheetSnapshot] of snapshot.sheets.entries()) {
509
+ const sheet = new SheetImpl(sheetSnapshot.id, sheetSnapshot.name, sheetSnapshot.config);
510
+ sheet.rowCount = sheetSnapshot.rowCount;
511
+ sheet.colCount = sheetSnapshot.colCount;
512
+
513
+ // Restore cells
514
+ for (const [key, cell] of sheetSnapshot.cells.entries()) {
515
+ sheet.cells.set(key, { ...cell });
516
+ }
517
+
518
+ this.sheets.set(sheetId, sheet);
519
+ }
520
+
521
+ // Restore active sheet
522
+ this.activeSheetId = snapshot.activeSheetId;
523
+
524
+ // Restore selection
525
+ this.selection = { ...snapshot.selection };
526
+
527
+ // Rebuild formula graph
528
+ this.formulaGraph = new FormulaGraphImpl();
529
+ for (const [, sheet] of this.sheets.entries()) {
530
+ for (const [key, cell] of sheet.cells.entries()) {
531
+ if (cell.formula) {
532
+ const { row, col } = parseCellKey(key);
533
+ const parseResult = this.formulaParser.parse(cell.formula, row, col);
534
+ if (!parseResult.error) {
535
+ this.formulaGraph.addFormula(key, cell.formula, parseResult.dependencies);
536
+ }
537
+ }
538
+ }
539
+ }
540
+
541
+ // Recalculate all formulas
542
+ for (const [sheetId, sheet] of this.sheets.entries()) {
543
+ for (const [key, cell] of sheet.cells.entries()) {
544
+ if (cell.formula) {
545
+ const { row, col } = parseCellKey(key);
546
+ this.evaluateFormula(sheetId, row, col);
547
+ }
548
+ }
549
+ }
550
+
551
+ // Emit change events
552
+ this.events.emit('workbookChange', {});
553
+ this.events.emit('sheetChange', { sheetId: this.activeSheetId });
554
+ this.events.emit('cellSelection', { selection: this.selection });
555
+ }
556
+
557
+ /**
558
+ * Record current state for undo
559
+ */
560
+ recordHistory(): void {
561
+ if (this.isUndoing || this.isRedoing) {
562
+ return; // Don't record history during undo/redo operations
563
+ }
564
+
565
+ const snapshot = this.createSnapshot();
566
+ this.undoStack.push(snapshot);
567
+
568
+ // Limit history size
569
+ if (this.undoStack.length > this.maxHistorySize) {
570
+ this.undoStack.shift();
571
+ }
572
+
573
+ // Clear redo stack when new action is performed
574
+ this.redoStack = [];
575
+ }
576
+
577
+ /**
578
+ * Undo the last operation
579
+ */
580
+ undo(): boolean {
581
+ if (this.undoStack.length === 0) {
582
+ return false;
583
+ }
584
+
585
+ // Save current state to redo stack
586
+ const currentSnapshot = this.createSnapshot();
587
+ this.redoStack.push(currentSnapshot);
588
+
589
+ // Restore previous state
590
+ const previousSnapshot = this.undoStack.pop()!;
591
+ this.isUndoing = true;
592
+ try {
593
+ this.restoreSnapshot(previousSnapshot);
594
+ } finally {
595
+ this.isUndoing = false;
596
+ }
597
+
598
+ return true;
599
+ }
600
+
601
+ /**
602
+ * Redo the last undone operation
603
+ */
604
+ redo(): boolean {
605
+ if (this.redoStack.length === 0) {
606
+ return false;
607
+ }
608
+
609
+ // Save current state to undo stack
610
+ const currentSnapshot = this.createSnapshot();
611
+ this.undoStack.push(currentSnapshot);
612
+
613
+ // Restore next state
614
+ const nextSnapshot = this.redoStack.pop()!;
615
+ this.isRedoing = true;
616
+ try {
617
+ this.restoreSnapshot(nextSnapshot);
618
+ } finally {
619
+ this.isRedoing = false;
620
+ }
621
+
622
+ return true;
623
+ }
624
+
625
+ /**
626
+ * Check if undo is available
627
+ */
628
+ canUndo(): boolean {
629
+ return this.undoStack.length > 0;
630
+ }
631
+
632
+ /**
633
+ * Check if redo is available
634
+ */
635
+ canRedo(): boolean {
636
+ return this.redoStack.length > 0;
637
+ }
638
+
639
+ // ============================================
640
+ // Data Serialization Methods
641
+ // ============================================
642
+
643
+ /**
644
+ * Get complete workbook data for serialization
645
+ */
646
+ getData(): import('./types').WorkbookData {
647
+ // Serialize style pool
648
+ const stylePool: Record<string, import('./types').CellStyle> = {};
649
+ for (const [styleId, style] of this.stylePool.getAllStyles()) {
650
+ stylePool[styleId] = style;
651
+ }
652
+
653
+ // Serialize format pool
654
+ const formatPool: Record<string, import('./types').CellFormat> = {};
655
+ for (const [formatId, format] of this.formatPool.getAllFormats()) {
656
+ formatPool[formatId] = format;
657
+ }
658
+
659
+ // Serialize sheets
660
+ const sheets: import('./types').SheetData[] = [];
661
+ for (const sheet of this.sheets.values()) {
662
+ // Serialize cells
663
+ const cells: Array<{ key: string; cell: import('./types').Cell }> = [];
664
+ for (const [key, cell] of sheet.cells.entries()) {
665
+ cells.push({ key, cell });
666
+ }
667
+
668
+ // Serialize config (convert Maps/Sets to arrays)
669
+ const config = {
670
+ defaultRowHeight: sheet.config.defaultRowHeight,
671
+ defaultColWidth: sheet.config.defaultColWidth,
672
+ rowHeights: sheet.config.rowHeights ? Array.from(sheet.config.rowHeights.entries()) : undefined,
673
+ colWidths: sheet.config.colWidths ? Array.from(sheet.config.colWidths.entries()) : undefined,
674
+ hiddenRows: sheet.config.hiddenRows ? Array.from(sheet.config.hiddenRows) : undefined,
675
+ hiddenCols: sheet.config.hiddenCols ? Array.from(sheet.config.hiddenCols) : undefined,
676
+ frozenRows: sheet.config.frozenRows,
677
+ frozenCols: sheet.config.frozenCols,
678
+ showGridLines: sheet.config.showGridLines,
679
+ sortOrder: sheet.config.sortOrder,
680
+ filters: sheet.config.filters ? Array.from(sheet.config.filters.entries()) : undefined,
681
+ };
682
+
683
+ sheets.push({
684
+ id: sheet.id,
685
+ name: sheet.name,
686
+ cells,
687
+ config,
688
+ rowCount: sheet.rowCount,
689
+ colCount: sheet.colCount,
690
+ });
691
+ }
692
+
693
+ return {
694
+ id: this.id,
695
+ name: this.name,
696
+ activeSheetId: this.activeSheetId,
697
+ defaultRowHeight: this.defaultRowHeight,
698
+ defaultColWidth: this.defaultColWidth,
699
+ stylePool,
700
+ formatPool,
701
+ sheets,
702
+ selection: { ...this.selection },
703
+ };
704
+ }
705
+
706
+ /**
707
+ * Set complete workbook data from serialized data
708
+ */
709
+ setData(data: import('./types').WorkbookData): void {
710
+ // Clear existing state
711
+ this.sheets.clear();
712
+ this.formulaGraph = new FormulaGraphImpl();
713
+ this.stylePool.clear();
714
+ this.undoStack.length = 0;
715
+ this.redoStack.length = 0;
716
+
717
+ // Restore workbook metadata
718
+ this.id = data.id;
719
+ this.name = data.name;
720
+ this.activeSheetId = data.activeSheetId;
721
+ this.defaultRowHeight = data.defaultRowHeight;
722
+ this.defaultColWidth = data.defaultColWidth;
723
+
724
+ // Restore selection if provided
725
+ if (data.selection) {
726
+ this.selection = { ...data.selection };
727
+ }
728
+
729
+ // Restore style pool
730
+ for (const [styleId, style] of Object.entries(data.stylePool)) {
731
+ // We need to manually set the style since we cleared the pool
732
+ (this.stylePool as StylePool).setStyles(new Map([[styleId, style]]));
733
+ (this.stylePool as StylePool).setStyleToId(new Map([[this.stylePool.getStyleKey(style), styleId]]));
734
+ // Update nextId to avoid conflicts
735
+ const idNum = parseInt(styleId.split('_')[1] || '0');
736
+ if (idNum >= (this.stylePool as StylePool).getNextId()) {
737
+ (this.stylePool as StylePool).setNextId(idNum + 1);
738
+ }
739
+ }
740
+
741
+ // Restore format pool (if present - for backward compatibility)
742
+ if (data.formatPool) {
743
+ for (const [formatId, format] of Object.entries(data.formatPool)) {
744
+ // We need to manually set the format since we cleared the pool
745
+ (this.formatPool as FormatPool).setFormats(new Map([[formatId, format]]));
746
+ (this.formatPool as FormatPool).setFormatToId(new Map([[this.formatPool.getFormatKey(format), formatId]]));
747
+ // Update nextId to avoid conflicts
748
+ const idNum = parseInt(formatId.split('_')[1] || '0');
749
+ if (idNum >= (this.formatPool as FormatPool).getNextId()) {
750
+ (this.formatPool as FormatPool).setNextId(idNum + 1);
751
+ }
752
+ }
753
+ }
754
+
755
+ // Restore sheets
756
+ for (const sheetData of data.sheets) {
757
+ // Convert config arrays back to Maps/Sets
758
+ const config = {
759
+ defaultRowHeight: sheetData.config.defaultRowHeight,
760
+ defaultColWidth: sheetData.config.defaultColWidth,
761
+ rowHeights: sheetData.config.rowHeights ? new Map(sheetData.config.rowHeights) : undefined,
762
+ colWidths: sheetData.config.colWidths ? new Map(sheetData.config.colWidths) : undefined,
763
+ hiddenRows: sheetData.config.hiddenRows ? new Set(sheetData.config.hiddenRows) : undefined,
764
+ hiddenCols: sheetData.config.hiddenCols ? new Set(sheetData.config.hiddenCols) : undefined,
765
+ frozenRows: sheetData.config.frozenRows,
766
+ frozenCols: sheetData.config.frozenCols,
767
+ showGridLines: sheetData.config.showGridLines,
768
+ sortOrder: sheetData.config.sortOrder,
769
+ filters: sheetData.config.filters ? new Map(sheetData.config.filters) : undefined,
770
+ };
771
+
772
+ // Create sheet
773
+ const sheet = new SheetImpl(sheetData.id, sheetData.name, config);
774
+ sheet.rowCount = sheetData.rowCount;
775
+ sheet.colCount = sheetData.colCount;
776
+
777
+ // Restore cells (convert format to formatId if needed)
778
+ for (const { key, cell } of sheetData.cells) {
779
+ const cellToStore = { ...cell };
780
+
781
+ // Handle format conversion for backward compatibility
782
+ if ('format' in cellToStore && cellToStore.format && !cellToStore.formatId) {
783
+ // Clean and pool the format
784
+ const cleanedFormat = this.cleanFormat(cellToStore.format as CellFormat);
785
+ const formatId = this.formatPool.getOrCreate(cleanedFormat);
786
+ cellToStore.formatId = formatId;
787
+ delete (cellToStore as Partial<Cell> & { format?: CellFormat }).format;
788
+ }
789
+
790
+ sheet.cells.set(key, cellToStore);
791
+ }
792
+
793
+ this.sheets.set(sheetData.id, sheet);
794
+ }
795
+
796
+ // Rebuild formula graph and evaluate formulas
797
+ this.rebuildFormulaGraph();
798
+
799
+ // Emit change events
800
+ this.events.emit('workbookChange', {});
801
+ this.events.emit('sheetChange', { sheetId: this.activeSheetId });
802
+ this.events.emit('cellSelection', { selection: this.selection });
803
+ }
804
+
805
+ /**
806
+ * Clean up format object to only include properties relevant to the format type
807
+ */
808
+ private cleanFormat(format: CellFormat): CellFormat {
809
+ if (!format.type) {
810
+ return format;
811
+ }
812
+
813
+ const cleaned: CellFormat = { type: format.type };
814
+
815
+ switch (format.type) {
816
+ case 'number':
817
+ cleaned.decimalPlaces = format.decimalPlaces;
818
+ cleaned.useThousandsSeparator = format.useThousandsSeparator;
819
+ cleaned.negativeFormat = format.negativeFormat;
820
+ break;
821
+
822
+ case 'currency':
823
+ cleaned.decimalPlaces = format.decimalPlaces;
824
+ cleaned.currencyCode = format.currencyCode;
825
+ cleaned.currencySymbolPosition = format.currencySymbolPosition;
826
+ cleaned.negativeFormat = format.negativeFormat;
827
+ break;
828
+
829
+ case 'accounting':
830
+ cleaned.decimalPlaces = format.decimalPlaces;
831
+ cleaned.currencyCode = format.currencyCode;
832
+ cleaned.negativeFormat = format.negativeFormat;
833
+ break;
834
+
835
+ case 'percentage':
836
+ cleaned.decimalPlaces = format.decimalPlaces;
837
+ break;
838
+
839
+ case 'scientific':
840
+ cleaned.decimalPlaces = format.decimalPlaces;
841
+ break;
842
+
843
+ case 'fraction':
844
+ cleaned.fractionType = format.fractionType;
845
+ break;
846
+
847
+ case 'date':
848
+ cleaned.dateFormat = format.dateFormat;
849
+ break;
850
+
851
+ case 'time':
852
+ cleaned.timeFormat = format.timeFormat;
853
+ break;
854
+
855
+ case 'datetime':
856
+ cleaned.dateFormat = format.dateFormat;
857
+ cleaned.timeFormat = format.timeFormat;
858
+ break;
859
+
860
+ case 'duration':
861
+ cleaned.durationFormat = format.durationFormat;
862
+ break;
863
+
864
+ case 'custom':
865
+ cleaned.pattern = format.pattern;
866
+ break;
867
+
868
+ case 'text':
869
+ default:
870
+ // Text format doesn't need additional properties
871
+ break;
872
+ }
873
+
874
+ return cleaned;
875
+ }
876
+
877
+ /**
878
+ * Rebuild formula graph from current cells and evaluate all formulas
879
+ */
880
+ private rebuildFormulaGraph(): void {
881
+ for (const [sheetId, sheet] of this.sheets.entries()) {
882
+ for (const [key, cell] of sheet.cells.entries()) {
883
+ if (cell.formula) {
884
+ const { row, col } = parseCellKey(key);
885
+ const parseResult = this.formulaParser.parse(cell.formula, row, col);
886
+ if (!parseResult.error) {
887
+ this.formulaGraph.addFormula(key, cell.formula, parseResult.dependencies);
888
+ // Evaluate the formula
889
+ this.evaluateFormula(sheetId, row, col);
890
+ }
891
+ }
892
+ }
893
+ }
894
+ }
895
+
896
+ // ============================================
897
+ // Sorting Methods
898
+ // ============================================
899
+
900
+ /**
901
+ * Set the sort order for a sheet
902
+ * @param sortOrder Sort order to apply
903
+ * @param sheetId Target sheet ID (defaults to active sheet)
904
+ */
905
+ setSortOrder(sortOrder: SortOrder[], sheetId?: string): void {
906
+ const targetSheetId = sheetId ?? this.activeSheetId;
907
+ const sheet = this.sheets.get(targetSheetId);
908
+ if (sheet) {
909
+ // Record history before making changes
910
+ if (!this.isUndoing && !this.isRedoing && !this.isBatching) {
911
+ this.recordHistory();
912
+ }
913
+ sheet.setSortOrder(sortOrder);
914
+ }
915
+ }
916
+
917
+ /**
918
+ * Get the sort order for a sheet
919
+ * @param sheetId Target sheet ID (defaults to active sheet)
920
+ * @returns Current sort order
921
+ */
922
+ getSortOrder(sheetId?: string): SortOrder[] {
923
+ const targetSheetId = sheetId ?? this.activeSheetId;
924
+ const sheet = this.sheets.get(targetSheetId);
925
+ return sheet ? sheet.getSortOrder() : [];
926
+ }
927
+
928
+ /**
929
+ * Clear sorting for a sheet
930
+ * @param sheetId Target sheet ID (defaults to active sheet)
931
+ */
932
+ clearSort(sheetId?: string): void {
933
+ const targetSheetId = sheetId ?? this.activeSheetId;
934
+ const sheet = this.sheets.get(targetSheetId);
935
+ if (sheet) {
936
+ // Record history before making changes
937
+ if (!this.isUndoing && !this.isRedoing && !this.isBatching) {
938
+ this.recordHistory();
939
+ }
940
+ sheet.clearSort();
941
+ }
942
+ }
943
+
944
+ /**
945
+ * Sort the sheet data according to current sort order
946
+ * @param sheetId Target sheet ID (defaults to active sheet)
947
+ */
948
+ sortSheet(sheetId?: string): void {
949
+ const targetSheetId = sheetId ?? this.activeSheetId;
950
+ const sheet = this.sheets.get(targetSheetId);
951
+ if (sheet) {
952
+ const sortOrder = sheet.getSortOrder();
953
+ if (sortOrder.length > 0) {
954
+ // Record history before sorting
955
+ if (!this.isUndoing && !this.isRedoing && !this.isBatching) {
956
+ this.recordHistory();
957
+ }
958
+
959
+ SortManager.sortRows(sheet, sortOrder);
960
+ }
961
+ }
962
+ }
963
+
964
+ // ============================================
965
+ // Filtering Methods
966
+ // ============================================
967
+
968
+ /**
969
+ * Set a filter for a column
970
+ * @param column Column index
971
+ * @param filter Filter configuration
972
+ * @param sheetId Target sheet ID (defaults to active sheet)
973
+ */
974
+ setFilter(column: number, filter: import('./types').ColumnFilter, sheetId?: string): void {
975
+ const targetSheetId = sheetId ?? this.activeSheetId;
976
+ const sheet = this.sheets.get(targetSheetId);
977
+ if (sheet) {
978
+ // Record history before making changes
979
+ if (!this.isUndoing && !this.isRedoing && !this.isBatching) {
980
+ this.recordHistory();
981
+ }
982
+ sheet.setFilter(column, filter);
983
+ }
984
+ }
985
+
986
+ /**
987
+ * Clear filter for a specific column
988
+ * @param column Column index
989
+ * @param sheetId Target sheet ID (defaults to active sheet)
990
+ */
991
+ clearFilter(column: number, sheetId?: string): void {
992
+ const targetSheetId = sheetId ?? this.activeSheetId;
993
+ const sheet = this.sheets.get(targetSheetId);
994
+ if (sheet) {
995
+ // Record history before making changes
996
+ if (!this.isUndoing && !this.isRedoing && !this.isBatching) {
997
+ this.recordHistory();
998
+ }
999
+ sheet.clearFilter(column);
1000
+ }
1001
+ }
1002
+
1003
+ /**
1004
+ * Get all active filters for a sheet
1005
+ * @param sheetId Target sheet ID (defaults to active sheet)
1006
+ * @returns Map of column -> filter
1007
+ */
1008
+ getFilters(sheetId?: string): Map<number, import('./types').ColumnFilter> {
1009
+ const targetSheetId = sheetId ?? this.activeSheetId;
1010
+ const sheet = this.sheets.get(targetSheetId);
1011
+ return sheet ? sheet.getFilters() : new Map();
1012
+ }
1013
+
1014
+ /**
1015
+ * Clear all filters for a sheet
1016
+ * @param sheetId Target sheet ID (defaults to active sheet)
1017
+ */
1018
+ clearAllFilters(sheetId?: string): void {
1019
+ const targetSheetId = sheetId ?? this.activeSheetId;
1020
+ const sheet = this.sheets.get(targetSheetId);
1021
+ if (sheet) {
1022
+ // Record history before making changes
1023
+ if (!this.isUndoing && !this.isRedoing && !this.isBatching) {
1024
+ this.recordHistory();
1025
+ }
1026
+ sheet.clearAllFilters();
1027
+ }
1028
+ }
1029
+
1030
+ }
1031
+