@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.
- package/package.json +30 -0
- package/src/auto_sum.test.ts +71 -0
- package/src/auto_sum.ts +83 -0
- package/src/canvas_cf.ts +135 -0
- package/src/canvas_chart.ts +687 -0
- package/src/canvas_fill.ts +156 -0
- package/src/canvas_images.ts +99 -0
- package/src/canvas_layout.test.ts +159 -0
- package/src/canvas_layout.ts +239 -0
- package/src/canvas_renderer.ts +1010 -0
- package/src/canvas_shape.ts +242 -0
- package/src/canvas_sparkline.ts +163 -0
- package/src/canvas_text.ts +454 -0
- package/src/cell_editor.ts +558 -0
- package/src/clipboard.test.ts +145 -0
- package/src/clipboard.ts +341 -0
- package/src/command_history.ts +102 -0
- package/src/csv_parser.test.ts +130 -0
- package/src/csv_parser.ts +172 -0
- package/src/data_validation.test.ts +95 -0
- package/src/data_validation.ts +114 -0
- package/src/editing_layer.test.ts +309 -0
- package/src/excel_cf_evaluate.test.ts +293 -0
- package/src/excel_cf_evaluate.ts +719 -0
- package/src/excel_cf_icons.ts +108 -0
- package/src/excel_cf_types.ts +67 -0
- package/src/excel_numfmt.test.ts +607 -0
- package/src/excel_numfmt.ts +1033 -0
- package/src/excel_parser.test.ts +1061 -0
- package/src/excel_parser.ts +393 -0
- package/src/excel_pattern_fills.ts +64 -0
- package/src/excel_table_styles.ts +160 -0
- package/src/excel_utils.test.ts +108 -0
- package/src/excel_utils.ts +162 -0
- package/src/fast-formula-parser.d.ts +53 -0
- package/src/fill_logic.ts +257 -0
- package/src/fill_patterns.test.ts +90 -0
- package/src/fill_patterns.ts +71 -0
- package/src/flash_fill.test.ts +75 -0
- package/src/flash_fill.ts +221 -0
- package/src/formula_deps.ts +189 -0
- package/src/formula_engine.test.ts +348 -0
- package/src/formula_engine.ts +401 -0
- package/src/formula_highlight.test.ts +81 -0
- package/src/formula_highlight.ts +139 -0
- package/src/formula_suggest.ts +100 -0
- package/src/hidden_sheets.test.ts +49 -0
- package/src/index.ts +149 -0
- package/src/keyboard_nav.test.ts +394 -0
- package/src/keyboard_nav.ts +891 -0
- package/src/move_logic.ts +63 -0
- package/src/numfmt_type.test.ts +158 -0
- package/src/numfmt_type.ts +231 -0
- package/src/ooxml_cell_format.ts +70 -0
- package/src/ooxml_chart.test.ts +85 -0
- package/src/ooxml_chart.ts +347 -0
- package/src/ooxml_chart_types.ts +207 -0
- package/src/ooxml_color.ts +339 -0
- package/src/ooxml_drawing.test.ts +87 -0
- package/src/ooxml_drawing.ts +287 -0
- package/src/ooxml_pivot.test.ts +195 -0
- package/src/ooxml_pivot.ts +468 -0
- package/src/ooxml_pivot_types.ts +165 -0
- package/src/ooxml_shape.ts +355 -0
- package/src/ooxml_sheet.ts +1271 -0
- package/src/ooxml_styles.ts +556 -0
- package/src/ooxml_table.test.ts +70 -0
- package/src/ooxml_table.ts +131 -0
- package/src/ooxml_workbook.test.ts +40 -0
- package/src/ooxml_workbook.ts +259 -0
- package/src/ooxml_zip.ts +33 -0
- package/src/pivot_model.ts +237 -0
- package/src/pivot_recompute.test.ts +210 -0
- package/src/pivot_recompute.ts +413 -0
- package/src/selection_model.ts +364 -0
- package/src/spreadsheet_commands.test.ts +772 -0
- package/src/spreadsheet_commands.ts +1805 -0
- package/src/structured_refs.test.ts +128 -0
- package/src/structured_refs.ts +160 -0
- package/src/style_helpers.test.ts +33 -0
- package/src/style_helpers.ts +30 -0
- package/src/template_builder.test.ts +139 -0
- package/src/template_builder.ts +36 -0
- package/src/types.ts +239 -0
- package/src/workbook_model.test.ts +536 -0
- package/src/workbook_model.ts +911 -0
- package/src/xlsx_round_trip.test.ts +279 -0
- package/src/xlsx_writer.test.ts +488 -0
- package/src/xlsx_writer.ts +1549 -0
- 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
|
+
}
|