@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,1056 @@
1
+ // Main Canvas Renderer - Coordinates all sub-renderers
2
+
3
+ import type {
4
+ CanvasRendererConfig,
5
+ RenderState,
6
+ Viewport,
7
+ CellPosition,
8
+ DirtyRegion,
9
+ CanvasTheme,
10
+ Rect,
11
+ } from './types';
12
+ import { DEFAULT_THEME } from './types';
13
+ import { TextRenderer } from './text-renderer';
14
+ import { GridRenderer } from './grid-renderer';
15
+ import { CellRenderer } from './cell-renderer';
16
+ import { HeaderRenderer } from './header-renderer';
17
+ import { SelectionRenderer } from './selection-renderer';
18
+ import { HitTester } from './hit-testing';
19
+ import {
20
+ calculateFreezeDimensions,
21
+ type FreezeRegion,
22
+ type FreezeDimensions
23
+ } from '../features/freeze';
24
+
25
+ /**
26
+ * Main canvas renderer that coordinates all rendering operations
27
+ */
28
+ export class CanvasRenderer {
29
+ private canvas: HTMLCanvasElement;
30
+ private ctx: CanvasRenderingContext2D;
31
+ private devicePixelRatio: number;
32
+
33
+ // Configuration
34
+ private defaultRowHeight: number;
35
+ private defaultColWidth: number;
36
+ private headerHeight: number;
37
+ private headerWidth: number;
38
+ private theme: CanvasTheme;
39
+
40
+ // Viewport state
41
+ private viewport: Viewport;
42
+
43
+ // Current render state
44
+ private renderState: RenderState | null = null;
45
+
46
+ // Sub-renderers
47
+ private textRenderer: TextRenderer;
48
+ private gridRenderer: GridRenderer;
49
+ private cellRenderer: CellRenderer;
50
+ private headerRenderer: HeaderRenderer;
51
+ private selectionRenderer: SelectionRenderer;
52
+
53
+ // Hit tester
54
+ private hitTester: HitTester;
55
+
56
+ // Dirty tracking
57
+ private isDirty: boolean = true;
58
+ private dirtyRegions: DirtyRegion[] = [];
59
+
60
+ // Animation frame handle
61
+ private animationFrameId: number | null = null;
62
+
63
+ // Freeze panes state
64
+ private freezeDimensions: FreezeDimensions = { frozenWidth: 0, frozenHeight: 0 };
65
+ private frozenRows: number = 0;
66
+ private frozenCols: number = 0;
67
+
68
+ constructor(config: CanvasRendererConfig) {
69
+ this.canvas = config.canvas;
70
+ const ctx = this.canvas.getContext('2d');
71
+ if (!ctx) {
72
+ throw new Error('Failed to get 2D context from canvas');
73
+ }
74
+ this.ctx = ctx;
75
+
76
+ this.devicePixelRatio = config.devicePixelRatio ?? window.devicePixelRatio ?? 1;
77
+ this.defaultRowHeight = config.defaultRowHeight;
78
+ this.defaultColWidth = config.defaultColWidth;
79
+ this.headerHeight = config.headerHeight;
80
+ this.headerWidth = config.headerWidth;
81
+ this.theme = { ...DEFAULT_THEME, ...config.theme };
82
+
83
+ // Initialize viewport
84
+ this.viewport = {
85
+ scrollTop: 0,
86
+ scrollLeft: 0,
87
+ width: this.canvas.width / this.devicePixelRatio,
88
+ height: this.canvas.height / this.devicePixelRatio,
89
+ startRow: 0,
90
+ endRow: 0,
91
+ startCol: 0,
92
+ endCol: 0,
93
+ };
94
+
95
+ // Initialize sub-renderers
96
+ this.textRenderer = new TextRenderer(this.theme);
97
+ this.gridRenderer = new GridRenderer(this.theme);
98
+ this.cellRenderer = new CellRenderer(this.theme, this.textRenderer);
99
+ this.headerRenderer = new HeaderRenderer(this.theme);
100
+ this.selectionRenderer = new SelectionRenderer(this.theme);
101
+
102
+ // Initialize hit tester
103
+ this.hitTester = new HitTester(
104
+ this.headerWidth,
105
+ this.headerHeight,
106
+ this.defaultRowHeight,
107
+ this.defaultColWidth
108
+ );
109
+
110
+ // Set up canvas for high DPI
111
+ this.setupCanvas();
112
+ }
113
+
114
+ /**
115
+ * Set up canvas for high DPI displays
116
+ */
117
+ private setupCanvas(): void {
118
+ const { width, height } = this.canvas.getBoundingClientRect();
119
+
120
+ // Set actual size in memory (scaled for device pixel ratio)
121
+ this.canvas.width = width * this.devicePixelRatio;
122
+ this.canvas.height = height * this.devicePixelRatio;
123
+
124
+ // Scale the context to match device pixel ratio
125
+ this.ctx.scale(this.devicePixelRatio, this.devicePixelRatio);
126
+
127
+ // Update viewport dimensions
128
+ this.viewport.width = width;
129
+ this.viewport.height = height;
130
+ }
131
+
132
+ /**
133
+ * Resize the canvas
134
+ */
135
+ resize(width: number, height: number): void {
136
+ this.canvas.style.width = `${width}px`;
137
+ this.canvas.style.height = `${height}px`;
138
+ this.canvas.width = width * this.devicePixelRatio;
139
+ this.canvas.height = height * this.devicePixelRatio;
140
+
141
+ this.ctx.scale(this.devicePixelRatio, this.devicePixelRatio);
142
+
143
+ this.viewport.width = width;
144
+ this.viewport.height = height;
145
+
146
+ this.invalidate();
147
+ }
148
+
149
+ /**
150
+ * Set the viewport scroll position
151
+ */
152
+ setViewport(scrollTop: number, scrollLeft: number): void {
153
+ if (this.viewport.scrollTop === scrollTop && this.viewport.scrollLeft === scrollLeft) {
154
+ return;
155
+ }
156
+
157
+ this.viewport.scrollTop = scrollTop;
158
+ this.viewport.scrollLeft = scrollLeft;
159
+
160
+ // Update hit tester
161
+ this.hitTester.setScroll(scrollTop, scrollLeft);
162
+
163
+ // Recalculate visible range if we have render state
164
+ if (this.renderState) {
165
+ this.calculateVisibleRange(this.renderState);
166
+ }
167
+
168
+ this.invalidate();
169
+ }
170
+
171
+ /**
172
+ * Calculate the visible range based on current viewport and optionally provided dimensions.
173
+ * This can be called before setState to get an accurate viewport for cell loading.
174
+ */
175
+ calculateVisibleRangeForDimensions(
176
+ rowCount: number,
177
+ colCount: number,
178
+ rowHeights?: Map<number, number>,
179
+ colWidths?: Map<number, number>
180
+ ): void {
181
+ const { scrollTop, scrollLeft, width, height } = this.viewport;
182
+ const defaultRowHeight = this.defaultRowHeight;
183
+ const defaultColWidth = this.defaultColWidth;
184
+
185
+ // Calculate visible rows
186
+ let y = 0;
187
+ let startRow = 0;
188
+ while (y < scrollTop && startRow < rowCount) {
189
+ y += rowHeights?.get(startRow) ?? defaultRowHeight;
190
+ startRow++;
191
+ }
192
+ if (startRow > 0) startRow--;
193
+
194
+ let endRow = startRow;
195
+ const visibleHeight = height - this.headerHeight;
196
+ while (y < scrollTop + visibleHeight && endRow < rowCount) {
197
+ y += rowHeights?.get(endRow) ?? defaultRowHeight;
198
+ endRow++;
199
+ }
200
+ endRow = Math.min(endRow + 1, rowCount);
201
+
202
+ // Calculate visible columns
203
+ let x = 0;
204
+ let startCol = 0;
205
+ while (x < scrollLeft && startCol < colCount) {
206
+ x += colWidths?.get(startCol) ?? defaultColWidth;
207
+ startCol++;
208
+ }
209
+ if (startCol > 0) startCol--;
210
+
211
+ let endCol = startCol;
212
+ const visibleWidth = width - this.headerWidth;
213
+ while (x < scrollLeft + visibleWidth && endCol < colCount) {
214
+ x += colWidths?.get(endCol) ?? defaultColWidth;
215
+ endCol++;
216
+ }
217
+ endCol = Math.min(endCol + 1, colCount);
218
+
219
+ this.viewport.startRow = startRow;
220
+ this.viewport.endRow = endRow;
221
+ this.viewport.startCol = startCol;
222
+ this.viewport.endCol = endCol;
223
+ }
224
+
225
+ /**
226
+ * Update the render state
227
+ */
228
+ setState(state: RenderState): void {
229
+ this.renderState = state;
230
+
231
+ // Calculate freeze dimensions
232
+ this.frozenRows = state.frozenRows ?? 0;
233
+ this.frozenCols = state.frozenCols ?? 0;
234
+ this.freezeDimensions = calculateFreezeDimensions(
235
+ this.frozenRows,
236
+ this.frozenCols,
237
+ state.rowHeights,
238
+ state.colWidths,
239
+ this.defaultRowHeight,
240
+ this.defaultColWidth,
241
+ state.hiddenRows,
242
+ state.hiddenCols
243
+ );
244
+
245
+ // Update hit tester with row/col dimensions, hidden rows/cols, and freeze config
246
+ this.hitTester.setDimensions(
247
+ state.rowHeights,
248
+ state.colWidths,
249
+ this.defaultRowHeight,
250
+ this.defaultColWidth,
251
+ state.rowCount,
252
+ state.colCount,
253
+ state.hiddenRows,
254
+ state.hiddenCols
255
+ );
256
+
257
+ // Update hit tester with freeze config
258
+ this.hitTester.setFreezeConfig(
259
+ this.frozenRows,
260
+ this.frozenCols,
261
+ this.freezeDimensions.frozenWidth,
262
+ this.freezeDimensions.frozenHeight
263
+ );
264
+
265
+ // Calculate visible range immediately so getViewport() returns correct values
266
+ this.calculateVisibleRange(state);
267
+
268
+ this.invalidate();
269
+ }
270
+
271
+ /**
272
+ * Mark the canvas as needing redraw
273
+ */
274
+ invalidate(region?: DirtyRegion): void {
275
+ this.isDirty = true;
276
+ if (region) {
277
+ this.dirtyRegions.push(region);
278
+ } else {
279
+ this.dirtyRegions = [{ type: 'all' }];
280
+ }
281
+
282
+ // Schedule a render on the next animation frame
283
+ if (this.animationFrameId === null) {
284
+ this.animationFrameId = requestAnimationFrame(() => {
285
+ this.animationFrameId = null;
286
+ this.render();
287
+ });
288
+ }
289
+ }
290
+
291
+ /**
292
+ * Force an immediate render
293
+ */
294
+ renderNow(): void {
295
+ if (this.animationFrameId !== null) {
296
+ cancelAnimationFrame(this.animationFrameId);
297
+ this.animationFrameId = null;
298
+ }
299
+ this.render();
300
+ }
301
+
302
+ /**
303
+ * Main render method
304
+ */
305
+ private render(): void {
306
+ if (!this.isDirty) {
307
+ return;
308
+ }
309
+
310
+ if (!this.renderState) {
311
+ return;
312
+ }
313
+
314
+ const state = this.renderState;
315
+ const { frozenWidth, frozenHeight } = this.freezeDimensions;
316
+ const hasFrozenRows = this.frozenRows > 0;
317
+ const hasFrozenCols = this.frozenCols > 0;
318
+
319
+ // Calculate visible range
320
+ this.calculateVisibleRange(state);
321
+
322
+ // Clear the canvas
323
+ this.ctx.clearRect(0, 0, this.viewport.width, this.viewport.height);
324
+
325
+ // Save context state
326
+ this.ctx.save();
327
+
328
+ // Render in order (back to front):
329
+ // For freeze panes, we render 4 regions in order:
330
+ // 1. Main scrollable area (scrolls both ways)
331
+ // 2. Left frozen column area (scrolls vertically only)
332
+ // 3. Top frozen row area (scrolls horizontally only)
333
+ // 4. Top-left corner (never scrolls)
334
+
335
+ // --- MAIN SCROLLABLE AREA ---
336
+ this.renderRegion(state, 'main');
337
+
338
+ // --- LEFT FROZEN AREA (if frozen cols exist) ---
339
+ if (hasFrozenCols) {
340
+ this.renderRegion(state, 'left');
341
+ }
342
+
343
+ // --- TOP FROZEN AREA (if frozen rows exist) ---
344
+ if (hasFrozenRows) {
345
+ this.renderRegion(state, 'top');
346
+ }
347
+
348
+ // --- TOP-LEFT FROZEN CORNER (if both frozen rows and cols exist) ---
349
+ if (hasFrozenRows && hasFrozenCols) {
350
+ this.renderRegion(state, 'top-left');
351
+ }
352
+
353
+ // --- FREEZE DIVIDER LINES ---
354
+ if (hasFrozenRows || hasFrozenCols) {
355
+ this.renderFreezeDividers(frozenWidth, frozenHeight);
356
+ }
357
+
358
+ // --- HEADERS ---
359
+ this.renderHeaders(state);
360
+
361
+ // --- CORNER CELL ---
362
+ this.renderCornerCell();
363
+
364
+ // Restore context state
365
+ this.ctx.restore();
366
+
367
+ // Reset dirty state
368
+ this.isDirty = false;
369
+ this.dirtyRegions = [];
370
+ }
371
+
372
+ /**
373
+ * Render a specific freeze region (cells, grid lines, selection)
374
+ */
375
+ private renderRegion(state: RenderState, region: FreezeRegion): void {
376
+ const { frozenWidth, frozenHeight } = this.freezeDimensions;
377
+ const { scrollTop, scrollLeft, width, height } = this.viewport;
378
+
379
+ // Calculate clip rect based on region
380
+ let clipX: number, clipY: number, clipW: number, clipH: number;
381
+ let effectiveScrollTop: number, effectiveScrollLeft: number;
382
+ let startRow: number, endRow: number, startCol: number, endCol: number;
383
+
384
+ switch (region) {
385
+ case 'top-left':
386
+ // Never scrolls, shows frozen rows and cols
387
+ clipX = this.headerWidth;
388
+ clipY = this.headerHeight;
389
+ clipW = frozenWidth;
390
+ clipH = frozenHeight;
391
+ effectiveScrollTop = 0;
392
+ effectiveScrollLeft = 0;
393
+ startRow = 0;
394
+ endRow = this.frozenRows;
395
+ startCol = 0;
396
+ endCol = this.frozenCols;
397
+ break;
398
+
399
+ case 'top':
400
+ // Scrolls horizontally only, shows frozen rows
401
+ clipX = this.headerWidth + frozenWidth;
402
+ clipY = this.headerHeight;
403
+ clipW = width - this.headerWidth - frozenWidth;
404
+ clipH = frozenHeight;
405
+ effectiveScrollTop = 0;
406
+ effectiveScrollLeft = scrollLeft;
407
+ startRow = 0;
408
+ endRow = this.frozenRows;
409
+ startCol = this.viewport.startCol;
410
+ endCol = this.viewport.endCol;
411
+ // Ensure we don't render frozen cols in this region
412
+ if (startCol < this.frozenCols) startCol = this.frozenCols;
413
+ break;
414
+
415
+ case 'left':
416
+ // Scrolls vertically only, shows frozen cols
417
+ clipX = this.headerWidth;
418
+ clipY = this.headerHeight + frozenHeight;
419
+ clipW = frozenWidth;
420
+ clipH = height - this.headerHeight - frozenHeight;
421
+ effectiveScrollTop = scrollTop;
422
+ effectiveScrollLeft = 0;
423
+ startRow = this.viewport.startRow;
424
+ endRow = this.viewport.endRow;
425
+ startCol = 0;
426
+ endCol = this.frozenCols;
427
+ // Ensure we don't render frozen rows in this region
428
+ if (startRow < this.frozenRows) startRow = this.frozenRows;
429
+ break;
430
+
431
+ case 'main':
432
+ default:
433
+ // Scrolls both ways
434
+ clipX = this.headerWidth + frozenWidth;
435
+ clipY = this.headerHeight + frozenHeight;
436
+ clipW = width - this.headerWidth - frozenWidth;
437
+ clipH = height - this.headerHeight - frozenHeight;
438
+ effectiveScrollTop = scrollTop;
439
+ effectiveScrollLeft = scrollLeft;
440
+ startRow = this.viewport.startRow;
441
+ endRow = this.viewport.endRow;
442
+ startCol = this.viewport.startCol;
443
+ endCol = this.viewport.endCol;
444
+ // Ensure we don't render frozen rows/cols in main region
445
+ if (startRow < this.frozenRows) startRow = this.frozenRows;
446
+ if (startCol < this.frozenCols) startCol = this.frozenCols;
447
+ break;
448
+ }
449
+
450
+ // Skip if clip region is invalid
451
+ if (clipW <= 0 || clipH <= 0) return;
452
+
453
+ // Render cells, grid lines, and selection for this region
454
+ this.ctx.save();
455
+ this.ctx.beginPath();
456
+ this.ctx.rect(clipX, clipY, clipW, clipH);
457
+ this.ctx.clip();
458
+
459
+ // Render cells
460
+ this.renderCellsInRegion(
461
+ state,
462
+ startRow, endRow, startCol, endCol,
463
+ effectiveScrollTop, effectiveScrollLeft,
464
+ region
465
+ );
466
+
467
+ // Render grid lines
468
+ this.renderGridLinesInRegion(
469
+ state,
470
+ startRow, endRow, startCol, endCol,
471
+ effectiveScrollTop, effectiveScrollLeft,
472
+ region
473
+ );
474
+
475
+ // Render selection
476
+ this.renderSelectionInRegion(
477
+ state,
478
+ effectiveScrollTop, effectiveScrollLeft,
479
+ region
480
+ );
481
+
482
+ this.ctx.restore();
483
+ }
484
+
485
+ /**
486
+ * Render freeze divider lines
487
+ */
488
+ private renderFreezeDividers(frozenWidth: number, frozenHeight: number): void {
489
+ const { width, height } = this.viewport;
490
+
491
+ this.ctx.save();
492
+ this.ctx.strokeStyle = this.theme.freezeDividerColor;
493
+ this.ctx.lineWidth = this.theme.freezeDividerWidth;
494
+
495
+ // Vertical divider (after frozen columns)
496
+ if (this.frozenCols > 0 && frozenWidth > 0) {
497
+ const dividerX = this.headerWidth + frozenWidth;
498
+ this.ctx.beginPath();
499
+ this.ctx.moveTo(dividerX, this.headerHeight);
500
+ this.ctx.lineTo(dividerX, height);
501
+ this.ctx.stroke();
502
+
503
+ // Add subtle shadow
504
+ this.ctx.fillStyle = this.theme.freezeShadowColor;
505
+ this.ctx.fillRect(dividerX, this.headerHeight, 4, height - this.headerHeight);
506
+ }
507
+
508
+ // Horizontal divider (after frozen rows)
509
+ if (this.frozenRows > 0 && frozenHeight > 0) {
510
+ const dividerY = this.headerHeight + frozenHeight;
511
+ this.ctx.beginPath();
512
+ this.ctx.moveTo(this.headerWidth, dividerY);
513
+ this.ctx.lineTo(width, dividerY);
514
+ this.ctx.stroke();
515
+
516
+ // Add subtle shadow
517
+ this.ctx.fillStyle = this.theme.freezeShadowColor;
518
+ this.ctx.fillRect(this.headerWidth, dividerY, width - this.headerWidth, 4);
519
+ }
520
+
521
+ this.ctx.restore();
522
+ }
523
+
524
+ /**
525
+ * Calculate which rows and columns are visible
526
+ */
527
+ private calculateVisibleRange(state: RenderState): void {
528
+ const { scrollTop, scrollLeft, width, height } = this.viewport;
529
+ const hiddenRows = state.hiddenRows ?? new Set<number>();
530
+ const hiddenCols = state.hiddenCols ?? new Set<number>();
531
+
532
+ // Calculate visible rows (skip hidden rows)
533
+ // Start from frozenRows since frozen rows don't scroll
534
+ let y = 0;
535
+ let startRow = this.frozenRows;
536
+ while (y < scrollTop && startRow < state.rowCount) {
537
+ if (!hiddenRows.has(startRow)) {
538
+ y += state.rowHeights.get(startRow) ?? this.defaultRowHeight;
539
+ }
540
+ startRow++;
541
+ }
542
+ if (startRow > this.frozenRows) startRow--; // Include partially visible row
543
+
544
+ let endRow = startRow;
545
+ const visibleHeight = height - this.headerHeight - this.freezeDimensions.frozenHeight;
546
+ while (y < scrollTop + visibleHeight && endRow < state.rowCount) {
547
+ if (!hiddenRows.has(endRow)) {
548
+ y += state.rowHeights.get(endRow) ?? this.defaultRowHeight;
549
+ }
550
+ endRow++;
551
+ }
552
+ endRow = Math.min(endRow + 1, state.rowCount); // Include partially visible row
553
+
554
+ // Calculate visible columns (skip hidden columns)
555
+ // Start from frozenCols since frozen columns don't scroll
556
+ let x = 0;
557
+ let startCol = this.frozenCols;
558
+ while (x < scrollLeft && startCol < state.colCount) {
559
+ if (!hiddenCols.has(startCol)) {
560
+ x += state.colWidths.get(startCol) ?? this.defaultColWidth;
561
+ }
562
+ startCol++;
563
+ }
564
+ if (startCol > this.frozenCols) startCol--; // Include partially visible column
565
+
566
+ let endCol = startCol;
567
+ const visibleWidth = width - this.headerWidth - this.freezeDimensions.frozenWidth;
568
+ while (x < scrollLeft + visibleWidth && endCol < state.colCount) {
569
+ if (!hiddenCols.has(endCol)) {
570
+ x += state.colWidths.get(endCol) ?? this.defaultColWidth;
571
+ }
572
+ endCol++;
573
+ }
574
+ endCol = Math.min(endCol + 1, state.colCount); // Include partially visible column
575
+
576
+ this.viewport.startRow = startRow;
577
+ this.viewport.endRow = endRow;
578
+ this.viewport.startCol = startCol;
579
+ this.viewport.endCol = endCol;
580
+ }
581
+
582
+ /**
583
+ * Render cells in a specific region
584
+ */
585
+ private renderCellsInRegion(
586
+ state: RenderState,
587
+ startRow: number,
588
+ endRow: number,
589
+ startCol: number,
590
+ endCol: number,
591
+ effectiveScrollTop: number,
592
+ effectiveScrollLeft: number,
593
+ region: FreezeRegion
594
+ ): void {
595
+ const hiddenRows = state.hiddenRows ?? new Set<number>();
596
+ const hiddenCols = state.hiddenCols ?? new Set<number>();
597
+ const { frozenWidth, frozenHeight } = this.freezeDimensions;
598
+
599
+ // Calculate starting Y position based on region
600
+ let startY: number;
601
+ if (region === 'top-left' || region === 'top') {
602
+ // Frozen rows - start at header
603
+ startY = this.headerHeight;
604
+ for (let r = 0; r < startRow; r++) {
605
+ if (!hiddenRows.has(r)) {
606
+ startY += state.rowHeights.get(r) ?? this.defaultRowHeight;
607
+ }
608
+ }
609
+ } else {
610
+ // Non-frozen rows - account for frozen area and scroll
611
+ startY = this.headerHeight + frozenHeight;
612
+ for (let r = this.frozenRows; r < startRow; r++) {
613
+ if (!hiddenRows.has(r)) {
614
+ startY += state.rowHeights.get(r) ?? this.defaultRowHeight;
615
+ }
616
+ }
617
+ startY -= effectiveScrollTop;
618
+ }
619
+
620
+ // Calculate starting X position based on region
621
+ let startX: number;
622
+ if (region === 'top-left' || region === 'left') {
623
+ // Frozen cols - start at header
624
+ startX = this.headerWidth;
625
+ for (let c = 0; c < startCol; c++) {
626
+ if (!hiddenCols.has(c)) {
627
+ startX += state.colWidths.get(c) ?? this.defaultColWidth;
628
+ }
629
+ }
630
+ } else {
631
+ // Non-frozen cols - account for frozen area and scroll
632
+ startX = this.headerWidth + frozenWidth;
633
+ for (let c = this.frozenCols; c < startCol; c++) {
634
+ if (!hiddenCols.has(c)) {
635
+ startX += state.colWidths.get(c) ?? this.defaultColWidth;
636
+ }
637
+ }
638
+ startX -= effectiveScrollLeft;
639
+ }
640
+
641
+ // Render visible cells (skip hidden and filtered rows/cols)
642
+ let y = startY;
643
+ for (let row = startRow; row < endRow; row++) {
644
+ // Skip hidden rows
645
+ if (hiddenRows.has(row)) continue;
646
+ // Skip filtered rows (only show rows that pass all filters)
647
+ if (state.filteredRows && !state.filteredRows.has(row)) continue;
648
+
649
+ const rowHeight = state.rowHeights.get(row) ?? this.defaultRowHeight;
650
+ let x = startX;
651
+
652
+ for (let col = startCol; col < endCol; col++) {
653
+ // Skip hidden columns
654
+ if (hiddenCols.has(col)) continue;
655
+
656
+ const colWidth = state.colWidths.get(col) ?? this.defaultColWidth;
657
+ const cellKey = `${row}:${col}`;
658
+ const cell = state.cells.get(cellKey);
659
+ const style = cell?.styleId ? state.styles.get(cell.styleId) : undefined;
660
+ const format = cell?.formatId ? state.formats.get(cell.formatId) : undefined;
661
+
662
+ const bounds: Rect = { x, y, width: colWidth, height: rowHeight };
663
+
664
+ // Skip rendering if this is the editing cell
665
+ const isEditing = state.editingCell?.row === row && state.editingCell?.col === col;
666
+ if (!isEditing) {
667
+ this.cellRenderer.renderCell(this.ctx, cell, bounds, style, format);
668
+ } else {
669
+ // Render empty cell background for editing cell
670
+ this.cellRenderer.renderEmptyCell(this.ctx, bounds);
671
+ }
672
+
673
+ x += colWidth;
674
+ }
675
+
676
+ y += rowHeight;
677
+ }
678
+ }
679
+
680
+ /**
681
+ * Render grid lines in a specific region
682
+ */
683
+ private renderGridLinesInRegion(
684
+ state: RenderState,
685
+ startRow: number,
686
+ endRow: number,
687
+ startCol: number,
688
+ endCol: number,
689
+ effectiveScrollTop: number,
690
+ effectiveScrollLeft: number,
691
+ region: FreezeRegion
692
+ ): void {
693
+ const { frozenWidth, frozenHeight } = this.freezeDimensions;
694
+
695
+ // Create a modified viewport for this region
696
+ const regionViewport: Viewport = {
697
+ ...this.viewport,
698
+ scrollTop: effectiveScrollTop,
699
+ scrollLeft: effectiveScrollLeft,
700
+ startRow,
701
+ endRow,
702
+ startCol,
703
+ endCol,
704
+ };
705
+
706
+ // Determine header offset based on region
707
+ let headerWidthOffset = this.headerWidth;
708
+ let headerHeightOffset = this.headerHeight;
709
+
710
+ if (region === 'main' || region === 'top') {
711
+ headerWidthOffset = this.headerWidth + frozenWidth;
712
+ }
713
+ if (region === 'main' || region === 'left') {
714
+ headerHeightOffset = this.headerHeight + frozenHeight;
715
+ }
716
+
717
+ this.gridRenderer.renderGridLines(
718
+ this.ctx,
719
+ regionViewport,
720
+ state.rowHeights,
721
+ state.colWidths,
722
+ this.defaultRowHeight,
723
+ this.defaultColWidth,
724
+ headerWidthOffset,
725
+ headerHeightOffset,
726
+ startRow,
727
+ endRow,
728
+ startCol,
729
+ endCol,
730
+ state.hiddenRows,
731
+ state.hiddenCols
732
+ );
733
+ }
734
+
735
+ /**
736
+ * Render selection in a specific region
737
+ */
738
+ private renderSelectionInRegion(
739
+ state: RenderState,
740
+ effectiveScrollTop: number,
741
+ effectiveScrollLeft: number,
742
+ region: FreezeRegion
743
+ ): void {
744
+ if (!state.selection && !state.activeCell && (!state.formulaRanges || state.formulaRanges.length === 0)) {
745
+ return;
746
+ }
747
+
748
+ const { frozenWidth, frozenHeight } = this.freezeDimensions;
749
+
750
+ // Create a modified viewport for this region
751
+ const regionViewport: Viewport = {
752
+ ...this.viewport,
753
+ scrollTop: effectiveScrollTop,
754
+ scrollLeft: effectiveScrollLeft,
755
+ };
756
+
757
+ // Determine header offset based on region
758
+ let headerWidthOffset = this.headerWidth;
759
+ let headerHeightOffset = this.headerHeight;
760
+
761
+ if (region === 'main' || region === 'top') {
762
+ headerWidthOffset = this.headerWidth + frozenWidth;
763
+ }
764
+ if (region === 'main' || region === 'left') {
765
+ headerHeightOffset = this.headerHeight + frozenHeight;
766
+ }
767
+
768
+ this.selectionRenderer.render(
769
+ this.ctx,
770
+ state.selection,
771
+ state.activeCell,
772
+ regionViewport,
773
+ state.rowHeights,
774
+ state.colWidths,
775
+ this.defaultRowHeight,
776
+ this.defaultColWidth,
777
+ headerWidthOffset,
778
+ headerHeightOffset,
779
+ state.formulaRanges,
780
+ state.hiddenRows,
781
+ state.hiddenCols,
782
+ this.frozenRows,
783
+ this.frozenCols,
784
+ region
785
+ );
786
+ }
787
+
788
+
789
+ /**
790
+ * Render row and column headers
791
+ */
792
+ private renderHeaders(state: RenderState): void {
793
+ const { startRow, endRow, startCol, endCol, scrollTop, scrollLeft } = this.viewport;
794
+ const { frozenWidth, frozenHeight } = this.freezeDimensions;
795
+ const hasFrozenRows = this.frozenRows > 0;
796
+ const hasFrozenCols = this.frozenCols > 0;
797
+
798
+ // --- COLUMN HEADERS ---
799
+ if (hasFrozenCols) {
800
+ // Render frozen column headers (don't scroll horizontally)
801
+ this.ctx.save();
802
+ this.ctx.beginPath();
803
+ this.ctx.rect(this.headerWidth, 0, frozenWidth, this.headerHeight);
804
+ this.ctx.clip();
805
+
806
+ this.headerRenderer.renderColumnHeaders(
807
+ this.ctx,
808
+ 0,
809
+ this.frozenCols,
810
+ 0, // No horizontal scroll for frozen columns
811
+ state.colWidths,
812
+ this.defaultColWidth,
813
+ this.headerWidth,
814
+ this.headerHeight,
815
+ state.selection,
816
+ state.activeCell,
817
+ state.hiddenCols,
818
+ undefined, // No skip - render from column 0
819
+ state.filters
820
+ );
821
+
822
+ this.ctx.restore();
823
+ }
824
+
825
+ // Render scrollable column headers
826
+ this.ctx.save();
827
+ this.ctx.beginPath();
828
+ const colHeaderX = this.headerWidth + frozenWidth;
829
+ const colHeaderWidth = this.viewport.width - this.headerWidth - frozenWidth;
830
+ this.ctx.rect(colHeaderX, 0, colHeaderWidth, this.headerHeight);
831
+ this.ctx.clip();
832
+
833
+ this.headerRenderer.renderColumnHeaders(
834
+ this.ctx,
835
+ hasFrozenCols ? this.frozenCols : startCol,
836
+ endCol,
837
+ scrollLeft,
838
+ state.colWidths,
839
+ this.defaultColWidth,
840
+ this.headerWidth + frozenWidth, // Start after frozen area
841
+ this.headerHeight,
842
+ state.selection,
843
+ state.activeCell,
844
+ state.hiddenCols,
845
+ hasFrozenCols ? this.frozenCols : undefined, // Skip accumulating frozen column widths
846
+ state.filters
847
+ );
848
+
849
+ this.ctx.restore();
850
+
851
+ // --- ROW HEADERS ---
852
+ if (hasFrozenRows) {
853
+ // Render frozen row headers (don't scroll vertically)
854
+ this.ctx.save();
855
+ this.ctx.beginPath();
856
+ this.ctx.rect(0, this.headerHeight, this.headerWidth, frozenHeight);
857
+ this.ctx.clip();
858
+
859
+ this.headerRenderer.renderRowHeaders(
860
+ this.ctx,
861
+ 0,
862
+ this.frozenRows,
863
+ 0, // No vertical scroll for frozen rows
864
+ state.rowHeights,
865
+ this.defaultRowHeight,
866
+ this.headerWidth,
867
+ this.headerHeight,
868
+ state.selection,
869
+ state.activeCell,
870
+ state.hiddenRows,
871
+ undefined // No skip - render from row 0
872
+ );
873
+
874
+ this.ctx.restore();
875
+ }
876
+
877
+ // Render scrollable row headers
878
+ this.ctx.save();
879
+ this.ctx.beginPath();
880
+ const rowHeaderY = this.headerHeight + frozenHeight;
881
+ const rowHeaderHeight = this.viewport.height - this.headerHeight - frozenHeight;
882
+ this.ctx.rect(0, rowHeaderY, this.headerWidth, rowHeaderHeight);
883
+ this.ctx.clip();
884
+
885
+ this.headerRenderer.renderRowHeaders(
886
+ this.ctx,
887
+ hasFrozenRows ? this.frozenRows : startRow,
888
+ endRow,
889
+ scrollTop,
890
+ state.rowHeights,
891
+ this.defaultRowHeight,
892
+ this.headerWidth,
893
+ this.headerHeight + frozenHeight, // Start after frozen area
894
+ state.selection,
895
+ state.activeCell,
896
+ state.hiddenRows,
897
+ hasFrozenRows ? this.frozenRows : undefined // Skip accumulating frozen row heights
898
+ );
899
+
900
+ this.ctx.restore();
901
+ }
902
+
903
+ /**
904
+ * Render the corner cell
905
+ */
906
+ private renderCornerCell(): void {
907
+ this.headerRenderer.renderCornerCell(
908
+ this.ctx,
909
+ this.headerWidth,
910
+ this.headerHeight
911
+ );
912
+ }
913
+
914
+ /**
915
+ * Get the cell at a canvas point
916
+ */
917
+ getCellAtPoint(x: number, y: number): CellPosition | null {
918
+ return this.hitTester.getCellAt(x, y);
919
+ }
920
+
921
+ /**
922
+ * Get header at a canvas point
923
+ */
924
+ getHeaderAtPoint(x: number, y: number) {
925
+ return this.hitTester.getHeaderAt(x, y);
926
+ }
927
+
928
+ /**
929
+ * Get resize handle at a canvas point
930
+ */
931
+ getResizeHandleAtPoint(x: number, y: number) {
932
+ return this.hitTester.getResizeHandleAt(x, y);
933
+ }
934
+
935
+ /**
936
+ * Check if point is on fill handle
937
+ */
938
+ isFillHandleAtPoint(x: number, y: number): boolean {
939
+ if (!this.renderState?.selection?.activeCell) {
940
+ return false;
941
+ }
942
+ return this.hitTester.getFillHandleAt(
943
+ x,
944
+ y,
945
+ this.renderState.selection.ranges[0]
946
+ );
947
+ }
948
+
949
+ /**
950
+ * Get cell bounds in canvas coordinates (accounting for freeze panes)
951
+ */
952
+ getCellBounds(row: number, col: number): Rect | null {
953
+ if (!this.renderState) return null;
954
+
955
+ const { scrollTop, scrollLeft } = this.viewport;
956
+ const { frozenWidth, frozenHeight } = this.freezeDimensions;
957
+ const state = this.renderState;
958
+ const hiddenRows = state.hiddenRows ?? new Set<number>();
959
+ const hiddenCols = state.hiddenCols ?? new Set<number>();
960
+
961
+ // If the cell itself is hidden, return null
962
+ if (hiddenRows.has(row) || hiddenCols.has(col)) {
963
+ return null;
964
+ }
965
+
966
+ // Determine if cell is in frozen area
967
+ const isRowFrozen = row < this.frozenRows;
968
+ const isColFrozen = col < this.frozenCols;
969
+
970
+ // Calculate x position (skip hidden columns)
971
+ let x: number;
972
+ if (isColFrozen) {
973
+ // Cell is in frozen columns - no scroll offset
974
+ x = this.headerWidth;
975
+ for (let c = 0; c < col; c++) {
976
+ if (!hiddenCols.has(c)) {
977
+ x += state.colWidths.get(c) ?? this.defaultColWidth;
978
+ }
979
+ }
980
+ } else {
981
+ // Cell is in scrollable columns - apply scroll offset
982
+ x = this.headerWidth + frozenWidth;
983
+ for (let c = this.frozenCols; c < col; c++) {
984
+ if (!hiddenCols.has(c)) {
985
+ x += state.colWidths.get(c) ?? this.defaultColWidth;
986
+ }
987
+ }
988
+ x -= scrollLeft;
989
+ }
990
+
991
+ // Calculate y position (skip hidden rows)
992
+ let y: number;
993
+ if (isRowFrozen) {
994
+ // Cell is in frozen rows - no scroll offset
995
+ y = this.headerHeight;
996
+ for (let r = 0; r < row; r++) {
997
+ if (!hiddenRows.has(r)) {
998
+ y += state.rowHeights.get(r) ?? this.defaultRowHeight;
999
+ }
1000
+ }
1001
+ } else {
1002
+ // Cell is in scrollable rows - apply scroll offset
1003
+ y = this.headerHeight + frozenHeight;
1004
+ for (let r = this.frozenRows; r < row; r++) {
1005
+ if (!hiddenRows.has(r)) {
1006
+ y += state.rowHeights.get(r) ?? this.defaultRowHeight;
1007
+ }
1008
+ }
1009
+ y -= scrollTop;
1010
+ }
1011
+
1012
+ const width = state.colWidths.get(col) ?? this.defaultColWidth;
1013
+ const height = state.rowHeights.get(row) ?? this.defaultRowHeight;
1014
+
1015
+ return { x, y, width, height };
1016
+ }
1017
+
1018
+ /**
1019
+ * Get the current viewport
1020
+ */
1021
+ getViewport(): Viewport {
1022
+ return { ...this.viewport };
1023
+ }
1024
+
1025
+ /**
1026
+ * Get the hit tester
1027
+ */
1028
+ getHitTester(): HitTester {
1029
+ return this.hitTester;
1030
+ }
1031
+
1032
+ /**
1033
+ * Get the current freeze dimensions
1034
+ */
1035
+ getFreezeDimensions(): FreezeDimensions {
1036
+ return { ...this.freezeDimensions };
1037
+ }
1038
+
1039
+ /**
1040
+ * Get frozen row/col counts
1041
+ */
1042
+ getFreezeConfig(): { frozenRows: number; frozenCols: number } {
1043
+ return { frozenRows: this.frozenRows, frozenCols: this.frozenCols };
1044
+ }
1045
+
1046
+ /**
1047
+ * Cleanup resources
1048
+ */
1049
+ destroy(): void {
1050
+ if (this.animationFrameId !== null) {
1051
+ cancelAnimationFrame(this.animationFrameId);
1052
+ this.animationFrameId = null;
1053
+ }
1054
+ }
1055
+ }
1056
+