@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,1010 @@
1
+ // =============================================================================
2
+ // Canvas Spreadsheet Renderer
3
+ // =============================================================================
4
+ //
5
+ // Draws a spreadsheet onto a CanvasRenderingContext2D:
6
+ // - Grid lines and cell backgrounds
7
+ // - Text content with alignment and overflow
8
+ // - Borders
9
+ // - Column/row headers
10
+ // - Frozen panes
11
+ // - Selection highlight (Phase 6)
12
+ //
13
+ // This module is pure TypeScript with no framework dependency.
14
+ // =============================================================================
15
+
16
+ import type { LayoutEngine } from "./canvas_layout";
17
+ import { ROW_HEADER_WIDTH, COL_HEADER_HEIGHT } from "./canvas_layout";
18
+ import type { CellStyle, MergedCellRange, ParsedImage, RichTextPart } from "./types";
19
+ import type { CfOverlay } from "./excel_cf_evaluate";
20
+ import type { ParsedChart, ParsedDrawing } from "./ooxml_chart_types";
21
+ import { colNumToLetters, colLettersToNum } from "./excel_utils";
22
+ import { drawCellTextAdvanced } from "./canvas_text";
23
+ import { drawDataBar, drawIcon, getIconOffset } from "./canvas_cf";
24
+ import { drawImages } from "./canvas_images";
25
+ import { drawChart } from "./canvas_chart";
26
+ import { drawDrawing } from "./canvas_shape";
27
+ import { createPatternFill, createGradientFill } from "./canvas_fill";
28
+ import type { SparklineRenderInput } from "./canvas_sparkline";
29
+ import { drawSparkline } from "./canvas_sparkline";
30
+ import type { AutoFilterRange } from "./ooxml_chart_types";
31
+
32
+ // =============================================================================
33
+ // Types
34
+ // =============================================================================
35
+
36
+ export interface RenderConfig {
37
+ layout: LayoutEngine;
38
+ scrollX: number;
39
+ scrollY: number;
40
+ viewWidth: number;
41
+ viewHeight: number;
42
+ devicePixelRatio: number;
43
+ /** Get cell data for rendering. Returns undefined for empty cells. */
44
+ getCell: (row: number, col: number) => CellRenderData | undefined;
45
+ /** Merged cell ranges. */
46
+ mergedCells: MergedCellRange[];
47
+ /** Frozen pane: { row, col }. */
48
+ freeze?: { row: number; col: number };
49
+ /** Whether to show grid lines. */
50
+ showGridLines: boolean;
51
+ /** Active selection (row, col) for highlight. */
52
+ activeCell?: { row: number; col: number };
53
+ /** Selected range for highlight. */
54
+ selectionRange?: { startRow: number; startCol: number; endRow: number; endCol: number };
55
+ /** Conditional formatting overlays keyed by "row:col". */
56
+ cfOverlays?: Map<string, CfOverlay>;
57
+ /** Embedded images. */
58
+ images?: ParsedImage[];
59
+ /** Parsed charts. */
60
+ charts?: ParsedChart[];
61
+ /** Parsed drawings (shapes, text boxes, connectors). */
62
+ drawings?: ParsedDrawing[];
63
+ /** Resolve rich text parts for a cell. */
64
+ richText?: (row: number, col: number) => RichTextPart[] | undefined;
65
+ /** Fill drag target range (dashed border). */
66
+ fillTarget?: { startRow: number; startCol: number; endRow: number; endCol: number };
67
+ /** Sparklines keyed by "row:col". */
68
+ sparklines?: Map<string, SparklineRenderInput>;
69
+ /** Auto-filter range (for dropdown arrows on header cells). */
70
+ autoFilter?: AutoFilterRange;
71
+ /** Row outline levels (grouping). Key = row number, value = level 1-8. */
72
+ rowOutlineLevels?: Map<number, number>;
73
+ /** Column outline levels (grouping). Key = col number, value = level 1-8. */
74
+ colOutlineLevels?: Map<number, number>;
75
+ /** Collapsed outline groups. Set of "row:endRow:level" or "col:endCol:level" strings. */
76
+ collapsedGroups?: Set<string>;
77
+ /** Row page break positions (row numbers where breaks occur). */
78
+ rowPageBreaks?: number[];
79
+ /** Column page break positions (col numbers where breaks occur). */
80
+ colPageBreaks?: number[];
81
+ /** Cut source range — drawn with marching ants until paste or Escape. */
82
+ cutRange?: { startRow: number; startCol: number; endRow: number; endCol: number };
83
+ /** Phase offset for marching-ants animation (0..7 cycles the dash). */
84
+ cutDashOffset?: number;
85
+ }
86
+
87
+ export interface CellRenderData {
88
+ displayValue: string;
89
+ style: CellStyle;
90
+ richText?: RichTextPart[];
91
+ isHyperlink?: boolean;
92
+ }
93
+
94
+ // =============================================================================
95
+ // Colors
96
+ // =============================================================================
97
+
98
+ const GRID_COLOR = "#E2E8F0";
99
+ const HEADER_BG = "#F8FAFC";
100
+ const HEADER_BORDER = "#CBD5E1";
101
+ const HEADER_TEXT = "#475569";
102
+ const FROZEN_SEPARATOR = "#94A3B8";
103
+ // Excel/Sheets-style green for the active cell border. Stronger than the prior
104
+ // blue and visually distinct from the lighter blue selection range fill.
105
+ const SELECTION_BORDER = "#137333";
106
+ const SELECTION_FILL = "rgba(37, 99, 235, 0.08)";
107
+ const HEADER_FONT = "12px -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif";
108
+ const OUTLINE_BTN_SIZE = 12;
109
+ const OUTLINE_LEVEL_STEP = 14;
110
+ const OUTLINE_BTN_BG = "#F1F5F9";
111
+ const OUTLINE_BTN_BORDER = "#94A3B8";
112
+ const OUTLINE_BTN_TEXT = "#475569";
113
+ const OUTLINE_BTN_FONT = "bold 10px -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif";
114
+
115
+ // =============================================================================
116
+ // Main draw function
117
+ // =============================================================================
118
+
119
+ export function drawSpreadsheet(
120
+ ctx: CanvasRenderingContext2D,
121
+ config: RenderConfig,
122
+ ): void {
123
+ const { layout, scrollX, scrollY, viewWidth, viewHeight, devicePixelRatio: dpr } = config;
124
+ const freezeRow = config.freeze?.row ?? 0;
125
+ const freezeCol = config.freeze?.col ?? 0;
126
+
127
+ // Set up canvas scaling for HiDPI
128
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
129
+
130
+ // Clear
131
+ ctx.fillStyle = "#FFFFFF";
132
+ ctx.fillRect(0, 0, viewWidth, viewHeight);
133
+
134
+ // Compute visible range
135
+ const visible = layout.getVisibleRange(scrollX, scrollY, viewWidth, viewHeight);
136
+
137
+ // Build merge map for fast lookup
138
+ const mergeMap = buildMergeMap(config.mergedCells);
139
+
140
+ // Draw cells in 4 quadrants (for frozen panes):
141
+ // Q1: frozen rows + frozen cols (top-left)
142
+ // Q2: frozen rows + scrollable cols (top)
143
+ // Q3: scrollable rows + frozen cols (left)
144
+ // Q4: scrollable rows + scrollable cols (main)
145
+
146
+ // Q4: Main scrollable area
147
+ ctx.save();
148
+ ctx.beginPath();
149
+ ctx.rect(
150
+ ROW_HEADER_WIDTH + layout.frozenWidth,
151
+ COL_HEADER_HEIGHT + layout.frozenHeight,
152
+ viewWidth - ROW_HEADER_WIDTH - layout.frozenWidth,
153
+ viewHeight - COL_HEADER_HEIGHT - layout.frozenHeight,
154
+ );
155
+ ctx.clip();
156
+ drawCells(ctx, config, visible.startRow, visible.endRow, visible.startCol, visible.endCol, scrollX, scrollY, mergeMap);
157
+ ctx.restore();
158
+
159
+ // Q2: Frozen rows, scrollable cols
160
+ if (freezeRow > 0) {
161
+ ctx.save();
162
+ ctx.beginPath();
163
+ ctx.rect(
164
+ ROW_HEADER_WIDTH + layout.frozenWidth,
165
+ COL_HEADER_HEIGHT,
166
+ viewWidth - ROW_HEADER_WIDTH - layout.frozenWidth,
167
+ layout.frozenHeight,
168
+ );
169
+ ctx.clip();
170
+ drawCells(ctx, config, 1, freezeRow, visible.startCol, visible.endCol, scrollX, 0, mergeMap);
171
+ ctx.restore();
172
+ }
173
+
174
+ // Q3: Scrollable rows, frozen cols
175
+ if (freezeCol > 0) {
176
+ ctx.save();
177
+ ctx.beginPath();
178
+ ctx.rect(
179
+ ROW_HEADER_WIDTH,
180
+ COL_HEADER_HEIGHT + layout.frozenHeight,
181
+ layout.frozenWidth,
182
+ viewHeight - COL_HEADER_HEIGHT - layout.frozenHeight,
183
+ );
184
+ ctx.clip();
185
+ drawCells(ctx, config, visible.startRow, visible.endRow, 1, freezeCol, 0, scrollY, mergeMap);
186
+ ctx.restore();
187
+ }
188
+
189
+ // Q1: Frozen rows + frozen cols
190
+ if (freezeRow > 0 && freezeCol > 0) {
191
+ ctx.save();
192
+ ctx.beginPath();
193
+ ctx.rect(ROW_HEADER_WIDTH, COL_HEADER_HEIGHT, layout.frozenWidth, layout.frozenHeight);
194
+ ctx.clip();
195
+ drawCells(ctx, config, 1, freezeRow, 1, freezeCol, 0, 0, mergeMap);
196
+ ctx.restore();
197
+ }
198
+
199
+ // Draw images, charts, and drawings
200
+ if (config.images) drawImages(ctx, config.images, layout, scrollX, scrollY, viewWidth, viewHeight);
201
+ if (config.charts) {
202
+ for (const chart of config.charts) drawChart(ctx, chart, layout, scrollX, scrollY);
203
+ }
204
+ if (config.drawings) {
205
+ for (const d of config.drawings) drawDrawing(ctx, d, layout, scrollX, scrollY);
206
+ }
207
+
208
+ // Draw frozen pane separators
209
+ if (freezeRow > 0) {
210
+ ctx.strokeStyle = FROZEN_SEPARATOR;
211
+ ctx.lineWidth = 2;
212
+ ctx.beginPath();
213
+ ctx.moveTo(0, COL_HEADER_HEIGHT + layout.frozenHeight);
214
+ ctx.lineTo(viewWidth, COL_HEADER_HEIGHT + layout.frozenHeight);
215
+ ctx.stroke();
216
+ }
217
+ if (freezeCol > 0) {
218
+ ctx.strokeStyle = FROZEN_SEPARATOR;
219
+ ctx.lineWidth = 2;
220
+ ctx.beginPath();
221
+ ctx.moveTo(ROW_HEADER_WIDTH + layout.frozenWidth, 0);
222
+ ctx.lineTo(ROW_HEADER_WIDTH + layout.frozenWidth, viewHeight);
223
+ ctx.stroke();
224
+ }
225
+
226
+ // Draw selection highlight
227
+ if (config.selectionRange) {
228
+ drawSelection(ctx, config, config.selectionRange, scrollX, scrollY);
229
+ }
230
+ if (config.activeCell) {
231
+ drawActiveCell(ctx, config, config.activeCell, scrollX, scrollY);
232
+ }
233
+ // Marching-ants cut border — drawn last so it sits on top of cell content
234
+ // and selection fill but under modal overlays.
235
+ if (config.cutRange) {
236
+ drawCutBorder(ctx, config, config.cutRange, scrollX, scrollY, config.cutDashOffset ?? 0);
237
+ }
238
+ if (config.fillTarget) {
239
+ drawFillTarget(ctx, config, config.fillTarget, scrollX, scrollY);
240
+ }
241
+
242
+ // Draw page break indicators (dashed lines)
243
+ if (config.rowPageBreaks || config.colPageBreaks) {
244
+ drawPageBreaks(ctx, config, scrollX, scrollY);
245
+ }
246
+
247
+ // Draw auto-filter dropdown arrows on cells
248
+ if (config.autoFilter) {
249
+ drawAutoFilterArrows(ctx, config, config.autoFilter, scrollX, scrollY);
250
+ }
251
+
252
+ // Draw headers on top
253
+ drawColumnHeaders(ctx, config, visible, scrollX);
254
+ drawRowHeaders(ctx, config, visible, scrollY);
255
+ drawCornerCell(ctx);
256
+
257
+ // Draw outline grouping buttons over headers
258
+ if (config.rowOutlineLevels && config.rowOutlineLevels.size > 0) {
259
+ drawRowOutlineButtons(ctx, config, visible, scrollY);
260
+ }
261
+ if (config.colOutlineLevels && config.colOutlineLevels.size > 0) {
262
+ drawColOutlineButtons(ctx, config, visible, scrollX);
263
+ }
264
+ }
265
+
266
+ // =============================================================================
267
+ // Cell drawing
268
+ // =============================================================================
269
+
270
+ function drawCells(
271
+ ctx: CanvasRenderingContext2D,
272
+ config: RenderConfig,
273
+ startRow: number,
274
+ endRow: number,
275
+ startCol: number,
276
+ endCol: number,
277
+ offsetX: number,
278
+ offsetY: number,
279
+ mergeMap: Map<number, MergedCellRange>,
280
+ ): void {
281
+ const { layout, showGridLines } = config;
282
+
283
+ for (let r = startRow; r <= endRow; r++) {
284
+ for (let c = startCol; c <= endCol; c++) {
285
+ // Check if this cell is part of a merge (and not the top-left)
286
+ const mergeKey = r * 100000 + c;
287
+ const merge = mergeMap.get(mergeKey);
288
+ if (merge && (merge.startRow !== r || merge.startCol !== c)) continue;
289
+
290
+ const cellX = layout.colX[c] - offsetX;
291
+ const cellY = layout.rowY[r] - offsetY;
292
+ let cellW = layout.colW[c];
293
+ let cellH = layout.rowH[r];
294
+
295
+ // Expand for merged cells
296
+ if (merge) {
297
+ cellW = 0;
298
+ for (let mc = merge.startCol; mc <= merge.endCol; mc++) cellW += layout.colW[mc];
299
+ cellH = 0;
300
+ for (let mr = merge.startRow; mr <= merge.endRow; mr++) cellH += layout.rowH[mr];
301
+ }
302
+
303
+ if (cellW === 0 || cellH === 0) continue;
304
+
305
+ const cellData = config.getCell(r, c);
306
+
307
+ // Background — gradient, pattern, or solid color
308
+ const gradData = cellData?.style.gradientData;
309
+ if (gradData) {
310
+ ctx.fillStyle = createGradientFill(ctx, gradData, cellX, cellY, cellW, cellH);
311
+ ctx.fillRect(cellX, cellY, cellW, cellH);
312
+ } else if (cellData?.style.backgroundPattern) {
313
+ const pattern = createPatternFill(ctx, cellData.style.backgroundPattern, cellData.style.patternType);
314
+ if (pattern) {
315
+ ctx.fillStyle = pattern;
316
+ ctx.fillRect(cellX, cellY, cellW, cellH);
317
+ }
318
+ } else if (cellData?.style.backgroundGradient) {
319
+ // CSS gradient string — parse as simple linear for canvas
320
+ ctx.fillStyle = "#F0F0F0";
321
+ ctx.fillRect(cellX, cellY, cellW, cellH);
322
+ }
323
+ const bg = cellData?.style.backgroundColor;
324
+ if (bg && !gradData && !cellData?.style.backgroundPattern) {
325
+ ctx.fillStyle = bg;
326
+ ctx.fillRect(cellX, cellY, cellW, cellH);
327
+ }
328
+
329
+ // Grid lines
330
+ if (showGridLines) {
331
+ ctx.strokeStyle = GRID_COLOR;
332
+ ctx.lineWidth = 0.5;
333
+ ctx.strokeRect(cellX, cellY, cellW, cellH);
334
+ }
335
+
336
+ // Borders (override grid lines)
337
+ if (cellData?.style) {
338
+ drawBorders(ctx, cellData.style, cellX, cellY, cellW, cellH);
339
+ }
340
+
341
+ // Conditional formatting overlays
342
+ const overlay = config.cfOverlays?.get(r + ":" + c);
343
+ if (overlay?.backgroundColor) {
344
+ ctx.fillStyle = overlay.backgroundColor;
345
+ ctx.fillRect(cellX, cellY, cellW, cellH);
346
+ }
347
+ if (overlay?.dataBarWidth !== undefined) {
348
+ drawDataBar(ctx, overlay, cellX, cellY, cellW, cellH);
349
+ }
350
+ if (overlay?.iconSvg) {
351
+ drawIcon(ctx, overlay, cellX, cellY, cellH);
352
+ }
353
+
354
+ // Text
355
+ if (cellData && cellData.displayValue) {
356
+ const iconOff = getIconOffset(overlay, cellH);
357
+ const rt = cellData.richText ?? config.richText?.(r, c);
358
+
359
+ // Hyperlink styling: blue text + underline unless explicit font color is set
360
+ let textStyle = cellData.style;
361
+ if (cellData.isHyperlink && !cellData.style.fontColor) {
362
+ textStyle = { ...cellData.style, fontColor: "#0563C1", fontUnderline: cellData.style.fontUnderline ?? "single" };
363
+ }
364
+
365
+ drawCellTextAdvanced(ctx, {
366
+ text: cellData.displayValue,
367
+ richText: rt,
368
+ style: textStyle,
369
+ cellX: cellX + iconOff,
370
+ cellY,
371
+ cellW: cellW - iconOff,
372
+ cellH,
373
+ });
374
+ }
375
+
376
+ // Sparkline
377
+ const sparkline = config.sparklines?.get(`${r}:${c}`);
378
+ if (sparkline) {
379
+ drawSparkline(ctx, { ...sparkline, x: cellX, y: cellY, w: cellW, h: cellH });
380
+ }
381
+
382
+ }
383
+ }
384
+ }
385
+
386
+ function drawBorders(
387
+ ctx: CanvasRenderingContext2D,
388
+ style: CellStyle,
389
+ x: number,
390
+ y: number,
391
+ w: number,
392
+ h: number,
393
+ ): void {
394
+ const drawBorder = (
395
+ x1: number, y1: number,
396
+ x2: number, y2: number,
397
+ border: { width: number; style: string; color?: string },
398
+ ) => {
399
+ ctx.strokeStyle = border.color ?? "#000000";
400
+ ctx.lineWidth = border.width;
401
+ if (border.style === "dashed") ctx.setLineDash([4, 2]);
402
+ else if (border.style === "dotted") ctx.setLineDash([1, 1]);
403
+ else ctx.setLineDash([]);
404
+ ctx.beginPath();
405
+ ctx.moveTo(x1, y1);
406
+ ctx.lineTo(x2, y2);
407
+ ctx.stroke();
408
+ ctx.setLineDash([]);
409
+ };
410
+
411
+ if (style.borderTop) drawBorder(x, y, x + w, y, style.borderTop);
412
+ if (style.borderBottom) drawBorder(x, y + h, x + w, y + h, style.borderBottom);
413
+ if (style.borderLeft) drawBorder(x, y, x, y + h, style.borderLeft);
414
+ if (style.borderRight) drawBorder(x + w, y, x + w, y + h, style.borderRight);
415
+ if (style.borderDiagonal) {
416
+ if (style.diagonalDown) drawBorder(x, y, x + w, y + h, style.borderDiagonal);
417
+ if (style.diagonalUp) drawBorder(x, y + h, x + w, y, style.borderDiagonal);
418
+ }
419
+ }
420
+
421
+ // =============================================================================
422
+ // Headers
423
+ // =============================================================================
424
+
425
+ function drawColumnHeaders(
426
+ ctx: CanvasRenderingContext2D,
427
+ config: RenderConfig,
428
+ visible: { startCol: number; endCol: number },
429
+ scrollX: number,
430
+ ): void {
431
+ const { layout } = config;
432
+ const freezeCol = config.freeze?.col ?? 0;
433
+
434
+ // Header background
435
+ ctx.fillStyle = HEADER_BG;
436
+ ctx.fillRect(0, 0, config.viewWidth, COL_HEADER_HEIGHT);
437
+ ctx.strokeStyle = HEADER_BORDER;
438
+ ctx.lineWidth = 1;
439
+ ctx.beginPath();
440
+ ctx.moveTo(0, COL_HEADER_HEIGHT - 0.5);
441
+ ctx.lineTo(config.viewWidth, COL_HEADER_HEIGHT - 0.5);
442
+ ctx.stroke();
443
+
444
+ ctx.font = HEADER_FONT;
445
+ ctx.fillStyle = HEADER_TEXT;
446
+ ctx.textAlign = "center";
447
+ ctx.textBaseline = "middle";
448
+
449
+ // Frozen columns
450
+ for (let c = 1; c <= freezeCol; c++) {
451
+ const x = layout.colX[c];
452
+ const w = layout.colW[c];
453
+ if (w === 0) continue;
454
+ ctx.fillStyle = HEADER_BG;
455
+ ctx.fillRect(x, 0, w, COL_HEADER_HEIGHT);
456
+ ctx.strokeStyle = HEADER_BORDER;
457
+ ctx.strokeRect(x, 0, w, COL_HEADER_HEIGHT);
458
+ ctx.fillStyle = HEADER_TEXT;
459
+ ctx.fillText(colNumToLetters(c), x + w / 2, COL_HEADER_HEIGHT / 2);
460
+ }
461
+
462
+ // Scrollable columns
463
+ for (let c = visible.startCol; c <= visible.endCol; c++) {
464
+ const x = layout.colX[c] - scrollX;
465
+ const w = layout.colW[c];
466
+ if (w === 0) continue;
467
+ ctx.fillStyle = HEADER_BG;
468
+ ctx.fillRect(x, 0, w, COL_HEADER_HEIGHT);
469
+ ctx.strokeStyle = HEADER_BORDER;
470
+ ctx.strokeRect(x, 0, w, COL_HEADER_HEIGHT);
471
+ ctx.fillStyle = HEADER_TEXT;
472
+ ctx.fillText(colNumToLetters(c), x + w / 2, COL_HEADER_HEIGHT / 2);
473
+ }
474
+ }
475
+
476
+ function drawRowHeaders(
477
+ ctx: CanvasRenderingContext2D,
478
+ config: RenderConfig,
479
+ visible: { startRow: number; endRow: number },
480
+ scrollY: number,
481
+ ): void {
482
+ const { layout } = config;
483
+ const freezeRow = config.freeze?.row ?? 0;
484
+
485
+ // Header background
486
+ ctx.fillStyle = HEADER_BG;
487
+ ctx.fillRect(0, 0, ROW_HEADER_WIDTH, config.viewHeight);
488
+ ctx.strokeStyle = HEADER_BORDER;
489
+ ctx.lineWidth = 1;
490
+ ctx.beginPath();
491
+ ctx.moveTo(ROW_HEADER_WIDTH - 0.5, 0);
492
+ ctx.lineTo(ROW_HEADER_WIDTH - 0.5, config.viewHeight);
493
+ ctx.stroke();
494
+
495
+ ctx.font = HEADER_FONT;
496
+ ctx.fillStyle = HEADER_TEXT;
497
+ ctx.textAlign = "center";
498
+ ctx.textBaseline = "middle";
499
+
500
+ // Frozen rows
501
+ for (let r = 1; r <= freezeRow; r++) {
502
+ const y = layout.rowY[r];
503
+ const h = layout.rowH[r];
504
+ if (h === 0) continue;
505
+ ctx.fillStyle = HEADER_BG;
506
+ ctx.fillRect(0, y, ROW_HEADER_WIDTH, h);
507
+ ctx.strokeStyle = HEADER_BORDER;
508
+ ctx.strokeRect(0, y, ROW_HEADER_WIDTH, h);
509
+ ctx.fillStyle = HEADER_TEXT;
510
+ ctx.fillText(String(r), ROW_HEADER_WIDTH / 2, y + h / 2);
511
+ }
512
+
513
+ // Scrollable rows
514
+ for (let r = visible.startRow; r <= visible.endRow; r++) {
515
+ const y = layout.rowY[r] - scrollY;
516
+ const h = layout.rowH[r];
517
+ if (h === 0) continue;
518
+ ctx.fillStyle = HEADER_BG;
519
+ ctx.fillRect(0, y, ROW_HEADER_WIDTH, h);
520
+ ctx.strokeStyle = HEADER_BORDER;
521
+ ctx.strokeRect(0, y, ROW_HEADER_WIDTH, h);
522
+ ctx.fillStyle = HEADER_TEXT;
523
+ ctx.fillText(String(r), ROW_HEADER_WIDTH / 2, y + h / 2);
524
+ }
525
+ }
526
+
527
+ function drawCornerCell(ctx: CanvasRenderingContext2D): void {
528
+ ctx.fillStyle = HEADER_BG;
529
+ ctx.fillRect(0, 0, ROW_HEADER_WIDTH, COL_HEADER_HEIGHT);
530
+ ctx.strokeStyle = HEADER_BORDER;
531
+ ctx.strokeRect(0, 0, ROW_HEADER_WIDTH, COL_HEADER_HEIGHT);
532
+ }
533
+
534
+ // =============================================================================
535
+ // Selection
536
+ // =============================================================================
537
+
538
+ function drawSelection(
539
+ ctx: CanvasRenderingContext2D,
540
+ config: RenderConfig,
541
+ range: { startRow: number; startCol: number; endRow: number; endCol: number },
542
+ scrollX: number,
543
+ scrollY: number,
544
+ ): void {
545
+ const { layout } = config;
546
+ const x = layout.colX[range.startCol] - scrollX;
547
+ const y = layout.rowY[range.startRow] - scrollY;
548
+ let w = 0;
549
+ for (let c = range.startCol; c <= range.endCol; c++) w += layout.colW[c];
550
+ let h = 0;
551
+ for (let r = range.startRow; r <= range.endRow; r++) h += layout.rowH[r];
552
+
553
+ ctx.fillStyle = SELECTION_FILL;
554
+ ctx.fillRect(x, y, w, h);
555
+ }
556
+
557
+ // Larger fill handle (8x8) with a white border so it reads on busy
558
+ // backgrounds — matches Excel's bottom-right corner affordance.
559
+ const FILL_HANDLE_SIZE = 8;
560
+
561
+ function drawActiveCell(
562
+ ctx: CanvasRenderingContext2D,
563
+ config: RenderConfig,
564
+ cell: { row: number; col: number },
565
+ scrollX: number,
566
+ scrollY: number,
567
+ ): void {
568
+ const { layout } = config;
569
+ const x = layout.colX[cell.col] - scrollX;
570
+ const y = layout.rowY[cell.row] - scrollY;
571
+ const w = layout.colW[cell.col];
572
+ const h = layout.rowH[cell.row];
573
+
574
+ // Active cell: 2.5px solid green border, drawn inset by half-width so it
575
+ // doesn't bleed into adjacent cell text.
576
+ ctx.strokeStyle = SELECTION_BORDER;
577
+ ctx.lineWidth = 2.5;
578
+ ctx.strokeRect(x + 0.5, y + 0.5, w - 1, h - 1);
579
+
580
+ // Fill handle: white-bordered green square at bottom-right corner of the
581
+ // selection range (or the active cell itself if no range).
582
+ const range = config.selectionRange;
583
+ const fhX = range
584
+ ? layout.colX[range.endCol] - scrollX + layout.colW[range.endCol]
585
+ : x + w;
586
+ const fhY = range
587
+ ? layout.rowY[range.endRow] - scrollY + layout.rowH[range.endRow]
588
+ : y + h;
589
+ const hx = fhX - FILL_HANDLE_SIZE / 2 - 1;
590
+ const hy = fhY - FILL_HANDLE_SIZE / 2 - 1;
591
+ // White outer ring
592
+ ctx.fillStyle = "#FFFFFF";
593
+ ctx.fillRect(hx - 1, hy - 1, FILL_HANDLE_SIZE + 2, FILL_HANDLE_SIZE + 2);
594
+ // Green core
595
+ ctx.fillStyle = SELECTION_BORDER;
596
+ ctx.fillRect(hx, hy, FILL_HANDLE_SIZE, FILL_HANDLE_SIZE);
597
+ }
598
+
599
+ /**
600
+ * Marching-ants border for the cut source. The dash phase shifts each frame
601
+ * (driven by config.cutDashOffset incremented at ~30fps in the React layer)
602
+ * so the dashes appear to march around the perimeter — Excel's classic
603
+ * "you cut these cells" indicator.
604
+ */
605
+ function drawCutBorder(
606
+ ctx: CanvasRenderingContext2D,
607
+ config: RenderConfig,
608
+ range: { startRow: number; startCol: number; endRow: number; endCol: number },
609
+ scrollX: number,
610
+ scrollY: number,
611
+ dashOffset: number,
612
+ ): void {
613
+ const { layout } = config;
614
+ const x = layout.colX[range.startCol] - scrollX;
615
+ const y = layout.rowY[range.startRow] - scrollY;
616
+ let w = 0;
617
+ for (let c = range.startCol; c <= range.endCol; c++) w += layout.colW[c];
618
+ let h = 0;
619
+ for (let r = range.startRow; r <= range.endRow; r++) h += layout.rowH[r];
620
+
621
+ ctx.save();
622
+ ctx.strokeStyle = "#137333";
623
+ ctx.lineWidth = 1.5;
624
+ ctx.setLineDash([5, 3]);
625
+ ctx.lineDashOffset = -dashOffset;
626
+ ctx.strokeRect(x + 0.5, y + 0.5, w - 1, h - 1);
627
+ ctx.restore();
628
+ }
629
+
630
+ function drawFillTarget(
631
+ ctx: CanvasRenderingContext2D,
632
+ config: RenderConfig,
633
+ range: { startRow: number; startCol: number; endRow: number; endCol: number },
634
+ scrollX: number,
635
+ scrollY: number,
636
+ ): void {
637
+ const { layout } = config;
638
+ const x = layout.colX[range.startCol] - scrollX;
639
+ const y = layout.rowY[range.startRow] - scrollY;
640
+ let w = 0;
641
+ for (let c = range.startCol; c <= range.endCol; c++) w += layout.colW[c];
642
+ let h = 0;
643
+ for (let r = range.startRow; r <= range.endRow; r++) h += layout.rowH[r];
644
+
645
+ ctx.strokeStyle = SELECTION_BORDER;
646
+ ctx.lineWidth = 1;
647
+ ctx.setLineDash([4, 3]);
648
+ ctx.strokeRect(x, y, w, h);
649
+ ctx.setLineDash([]);
650
+ }
651
+
652
+ // =============================================================================
653
+ // Auto-filter arrows
654
+ // =============================================================================
655
+
656
+ function drawAutoFilterArrows(
657
+ ctx: CanvasRenderingContext2D,
658
+ config: RenderConfig,
659
+ autoFilter: AutoFilterRange,
660
+ scrollX: number,
661
+ scrollY: number,
662
+ ): void {
663
+ const { layout } = config;
664
+ const match = autoFilter.ref.match(/^([A-Z]+)(\d+):([A-Z]+)(\d+)$/);
665
+ if (!match) return;
666
+
667
+ const startCol = colLettersToNum(match[1]);
668
+ const row = parseInt(match[2], 10);
669
+ const endCol = colLettersToNum(match[3]);
670
+
671
+ for (let c = startCol; c <= endCol; c++) {
672
+ const x = (layout.colX[c] ?? 0) - scrollX;
673
+ const y = (layout.rowY[row] ?? 0) - scrollY;
674
+ const w = layout.colW[c] ?? 0;
675
+ const h = layout.rowH[row] ?? 0;
676
+
677
+ if (w === 0 || h === 0) continue;
678
+
679
+ // Draw small dropdown triangle in bottom-right corner
680
+ const arrowSize = 5;
681
+ const ax = x + w - arrowSize - 3;
682
+ const ay = y + h - arrowSize - 2;
683
+
684
+ ctx.fillStyle = "#6B7280";
685
+ ctx.beginPath();
686
+ ctx.moveTo(ax, ay);
687
+ ctx.lineTo(ax + arrowSize * 2, ay);
688
+ ctx.lineTo(ax + arrowSize, ay + arrowSize);
689
+ ctx.closePath();
690
+ ctx.fill();
691
+ }
692
+ }
693
+
694
+ // =============================================================================
695
+ // Page break indicators
696
+ // =============================================================================
697
+
698
+ const PAGE_BREAK_COLOR = "#94A3B8";
699
+ const PAGE_BREAK_DASH = [6, 3];
700
+
701
+ function drawPageBreaks(
702
+ ctx: CanvasRenderingContext2D,
703
+ config: RenderConfig,
704
+ scrollX: number,
705
+ scrollY: number,
706
+ ): void {
707
+ const { layout, viewWidth, viewHeight } = config;
708
+
709
+ ctx.strokeStyle = PAGE_BREAK_COLOR;
710
+ ctx.lineWidth = 1;
711
+ ctx.setLineDash(PAGE_BREAK_DASH);
712
+
713
+ // Row page breaks — horizontal dashed lines
714
+ if (config.rowPageBreaks) {
715
+ for (const row of config.rowPageBreaks) {
716
+ if (row >= layout.rowY.length) continue;
717
+ const y = layout.rowY[row] - scrollY;
718
+ if (y < COL_HEADER_HEIGHT || y > viewHeight) continue;
719
+ ctx.beginPath();
720
+ ctx.moveTo(ROW_HEADER_WIDTH, y);
721
+ ctx.lineTo(viewWidth, y);
722
+ ctx.stroke();
723
+ }
724
+ }
725
+
726
+ // Column page breaks — vertical dashed lines
727
+ if (config.colPageBreaks) {
728
+ for (const col of config.colPageBreaks) {
729
+ if (col >= layout.colX.length) continue;
730
+ const x = layout.colX[col] - scrollX;
731
+ if (x < ROW_HEADER_WIDTH || x > viewWidth) continue;
732
+ ctx.beginPath();
733
+ ctx.moveTo(x, COL_HEADER_HEIGHT);
734
+ ctx.lineTo(x, viewHeight);
735
+ ctx.stroke();
736
+ }
737
+ }
738
+
739
+ ctx.setLineDash([]);
740
+ }
741
+
742
+ // =============================================================================
743
+ // Outline grouping buttons
744
+ // =============================================================================
745
+
746
+ /** Describes a group boundary where a +/- button should appear. */
747
+ export interface OutlineGroupBoundary {
748
+ /** "row" or "col" */
749
+ axis: "row" | "col";
750
+ /** The row/col number where the group ends (last row/col at this level). */
751
+ endIndex: number;
752
+ /** The outline level (1-8). */
753
+ level: number;
754
+ /** The first row/col in this group. */
755
+ startIndex: number;
756
+ }
757
+
758
+ /**
759
+ * Find all outline group boundaries for a given axis.
760
+ * A boundary occurs at the last row/col of a contiguous run at a given level.
761
+ */
762
+ function findGroupBoundaries(
763
+ outlineLevels: Map<number, number>,
764
+ maxIndex: number,
765
+ ): OutlineGroupBoundary[] {
766
+ if (outlineLevels.size === 0) return [];
767
+
768
+ const boundaries: OutlineGroupBoundary[] = [];
769
+ // Determine max level
770
+ let maxLevel = 0;
771
+ for (const lvl of outlineLevels.values()) {
772
+ if (lvl > maxLevel) maxLevel = lvl;
773
+ }
774
+
775
+ // For each level, find contiguous runs
776
+ for (let level = 1; level <= maxLevel; level++) {
777
+ let groupStart = -1;
778
+ for (let i = 1; i <= maxIndex; i++) {
779
+ const lvl = outlineLevels.get(i) ?? 0;
780
+ if (lvl >= level) {
781
+ if (groupStart === -1) groupStart = i;
782
+ } else {
783
+ if (groupStart !== -1) {
784
+ boundaries.push({ axis: "row", endIndex: i - 1, level, startIndex: groupStart });
785
+ groupStart = -1;
786
+ }
787
+ }
788
+ }
789
+ if (groupStart !== -1) {
790
+ boundaries.push({ axis: "row", endIndex: maxIndex, level, startIndex: groupStart });
791
+ }
792
+ }
793
+
794
+ return boundaries;
795
+ }
796
+
797
+ function drawRowOutlineButtons(
798
+ ctx: CanvasRenderingContext2D,
799
+ config: RenderConfig,
800
+ visible: { startRow: number; endRow: number },
801
+ scrollY: number,
802
+ ): void {
803
+ const { layout } = config;
804
+ const levels = config.rowOutlineLevels!;
805
+ const collapsed = config.collapsedGroups ?? new Set<string>();
806
+ const freezeRow = config.freeze?.row ?? 0;
807
+
808
+ // Find the maximum row we care about
809
+ const maxRow = Math.max(visible.endRow, freezeRow);
810
+ const boundaries = findGroupBoundaries(levels, maxRow);
811
+
812
+ // Fix axis for row boundaries
813
+ for (const b of boundaries) b.axis = "row";
814
+
815
+ ctx.font = OUTLINE_BTN_FONT;
816
+ ctx.textAlign = "center";
817
+ ctx.textBaseline = "middle";
818
+
819
+ for (const b of boundaries) {
820
+ const isCollapsed = collapsed.has(`row:${b.endIndex}:${b.level}`);
821
+ // Position the button at the end row of the group
822
+ const endRow = b.endIndex;
823
+
824
+ // Determine if this row is visible (frozen or in scrollable range)
825
+ let btnY: number;
826
+ if (endRow <= freezeRow) {
827
+ btnY = layout.rowY[endRow];
828
+ } else {
829
+ if (endRow < visible.startRow || endRow > visible.endRow) continue;
830
+ btnY = layout.rowY[endRow] - scrollY;
831
+ }
832
+
833
+ const rowH = layout.rowH[endRow] ?? 0;
834
+ if (rowH === 0 && !isCollapsed) continue;
835
+
836
+ const x = 2 + (b.level - 1) * OUTLINE_LEVEL_STEP;
837
+ const y = isCollapsed
838
+ ? btnY - OUTLINE_BTN_SIZE / 2
839
+ : btnY + rowH / 2 - OUTLINE_BTN_SIZE / 2;
840
+
841
+ // Draw button background
842
+ ctx.fillStyle = OUTLINE_BTN_BG;
843
+ ctx.fillRect(x, y, OUTLINE_BTN_SIZE, OUTLINE_BTN_SIZE);
844
+ ctx.strokeStyle = OUTLINE_BTN_BORDER;
845
+ ctx.lineWidth = 1;
846
+ ctx.strokeRect(x, y, OUTLINE_BTN_SIZE, OUTLINE_BTN_SIZE);
847
+
848
+ // Draw +/- symbol
849
+ ctx.fillStyle = OUTLINE_BTN_TEXT;
850
+ ctx.fillText(
851
+ isCollapsed ? "+" : "\u2212",
852
+ x + OUTLINE_BTN_SIZE / 2,
853
+ y + OUTLINE_BTN_SIZE / 2 + 0.5,
854
+ );
855
+ }
856
+ }
857
+
858
+ function drawColOutlineButtons(
859
+ ctx: CanvasRenderingContext2D,
860
+ config: RenderConfig,
861
+ visible: { startCol: number; endCol: number },
862
+ scrollX: number,
863
+ ): void {
864
+ const { layout } = config;
865
+ const levels = config.colOutlineLevels!;
866
+ const collapsed = config.collapsedGroups ?? new Set<string>();
867
+ const freezeCol = config.freeze?.col ?? 0;
868
+
869
+ const maxCol = Math.max(visible.endCol, freezeCol);
870
+
871
+ // Reuse findGroupBoundaries (it uses "row" axis label, we override to "col")
872
+ const boundaries = findGroupBoundaries(levels, maxCol);
873
+ for (const b of boundaries) b.axis = "col";
874
+
875
+ ctx.font = OUTLINE_BTN_FONT;
876
+ ctx.textAlign = "center";
877
+ ctx.textBaseline = "middle";
878
+
879
+ for (const b of boundaries) {
880
+ const isCollapsed = collapsed.has(`col:${b.endIndex}:${b.level}`);
881
+ const endCol = b.endIndex;
882
+
883
+ let btnX: number;
884
+ if (endCol <= freezeCol) {
885
+ btnX = layout.colX[endCol];
886
+ } else {
887
+ if (endCol < visible.startCol || endCol > visible.endCol) continue;
888
+ btnX = layout.colX[endCol] - scrollX;
889
+ }
890
+
891
+ const colW = layout.colW[endCol] ?? 0;
892
+ if (colW === 0 && !isCollapsed) continue;
893
+
894
+ const x = isCollapsed
895
+ ? btnX - OUTLINE_BTN_SIZE / 2
896
+ : btnX + colW / 2 - OUTLINE_BTN_SIZE / 2;
897
+ const y = 2 + (b.level - 1) * OUTLINE_LEVEL_STEP;
898
+
899
+ ctx.fillStyle = OUTLINE_BTN_BG;
900
+ ctx.fillRect(x, y, OUTLINE_BTN_SIZE, OUTLINE_BTN_SIZE);
901
+ ctx.strokeStyle = OUTLINE_BTN_BORDER;
902
+ ctx.lineWidth = 1;
903
+ ctx.strokeRect(x, y, OUTLINE_BTN_SIZE, OUTLINE_BTN_SIZE);
904
+
905
+ ctx.fillStyle = OUTLINE_BTN_TEXT;
906
+ ctx.fillText(
907
+ isCollapsed ? "+" : "\u2212",
908
+ x + OUTLINE_BTN_SIZE / 2,
909
+ y + OUTLINE_BTN_SIZE / 2 + 0.5,
910
+ );
911
+ }
912
+ }
913
+
914
+ /**
915
+ * Hit-test outline buttons. Returns the boundary that was clicked, or undefined.
916
+ */
917
+ export function hitTestOutlineButton(
918
+ config: RenderConfig,
919
+ px: number,
920
+ py: number,
921
+ scrollX: number,
922
+ scrollY: number,
923
+ ): OutlineGroupBoundary | undefined {
924
+ const { layout } = config;
925
+ const freezeRow = config.freeze?.row ?? 0;
926
+ const freezeCol = config.freeze?.col ?? 0;
927
+ const collapsed = config.collapsedGroups ?? new Set<string>();
928
+
929
+ // Row outline buttons: check if click is in the row header area (x < ROW_HEADER_WIDTH)
930
+ if (px < ROW_HEADER_WIDTH && py > COL_HEADER_HEIGHT && config.rowOutlineLevels && config.rowOutlineLevels.size > 0) {
931
+ const visible = layout.getVisibleRange(scrollX, scrollY, config.viewWidth, config.viewHeight);
932
+ const maxRow = Math.max(visible.endRow, freezeRow);
933
+ const boundaries = findGroupBoundaries(config.rowOutlineLevels, maxRow);
934
+ for (const b of boundaries) {
935
+ b.axis = "row";
936
+ const isCollapsed = collapsed.has(`row:${b.endIndex}:${b.level}`);
937
+ const endRow = b.endIndex;
938
+
939
+ let btnY: number;
940
+ if (endRow <= freezeRow) {
941
+ btnY = layout.rowY[endRow];
942
+ } else {
943
+ if (endRow < visible.startRow || endRow > visible.endRow) continue;
944
+ btnY = layout.rowY[endRow] - scrollY;
945
+ }
946
+
947
+ const rowH = layout.rowH[endRow] ?? 0;
948
+ if (rowH === 0 && !isCollapsed) continue;
949
+
950
+ const bx = 2 + (b.level - 1) * OUTLINE_LEVEL_STEP;
951
+ const by = isCollapsed
952
+ ? btnY - OUTLINE_BTN_SIZE / 2
953
+ : btnY + rowH / 2 - OUTLINE_BTN_SIZE / 2;
954
+
955
+ if (px >= bx && px <= bx + OUTLINE_BTN_SIZE && py >= by && py <= by + OUTLINE_BTN_SIZE) {
956
+ return b;
957
+ }
958
+ }
959
+ }
960
+
961
+ // Column outline buttons: check if click is in the col header area (y < COL_HEADER_HEIGHT)
962
+ if (py < COL_HEADER_HEIGHT && px > ROW_HEADER_WIDTH && config.colOutlineLevels && config.colOutlineLevels.size > 0) {
963
+ const visible = layout.getVisibleRange(scrollX, scrollY, config.viewWidth, config.viewHeight);
964
+ const maxCol = Math.max(visible.endCol, freezeCol);
965
+ const boundaries = findGroupBoundaries(config.colOutlineLevels, maxCol);
966
+ for (const b of boundaries) {
967
+ b.axis = "col";
968
+ const isCollapsed = collapsed.has(`col:${b.endIndex}:${b.level}`);
969
+ const endCol = b.endIndex;
970
+
971
+ let btnX: number;
972
+ if (endCol <= freezeCol) {
973
+ btnX = layout.colX[endCol];
974
+ } else {
975
+ if (endCol < visible.startCol || endCol > visible.endCol) continue;
976
+ btnX = layout.colX[endCol] - scrollX;
977
+ }
978
+
979
+ const colW = layout.colW[endCol] ?? 0;
980
+ if (colW === 0 && !isCollapsed) continue;
981
+
982
+ const bx = isCollapsed
983
+ ? btnX - OUTLINE_BTN_SIZE / 2
984
+ : btnX + colW / 2 - OUTLINE_BTN_SIZE / 2;
985
+ const by = 2 + (b.level - 1) * OUTLINE_LEVEL_STEP;
986
+
987
+ if (px >= bx && px <= bx + OUTLINE_BTN_SIZE && py >= by && py <= by + OUTLINE_BTN_SIZE) {
988
+ return b;
989
+ }
990
+ }
991
+ }
992
+
993
+ return undefined;
994
+ }
995
+
996
+ // =============================================================================
997
+ // Merge map
998
+ // =============================================================================
999
+
1000
+ function buildMergeMap(mergedCells: MergedCellRange[]): Map<number, MergedCellRange> {
1001
+ const map = new Map<number, MergedCellRange>();
1002
+ for (const mc of mergedCells) {
1003
+ for (let r = mc.startRow; r <= mc.endRow; r++) {
1004
+ for (let c = mc.startCol; c <= mc.endCol; c++) {
1005
+ map.set(r * 100000 + c, mc);
1006
+ }
1007
+ }
1008
+ }
1009
+ return map;
1010
+ }