draw-table-vue 0.2.0 → 0.3.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.
@@ -1,948 +0,0 @@
1
- import type { ColumnConfig, TableRow, TableOptions, CellInfo } from '../types';
2
-
3
- export class CanvasRenderer {
4
- private ctx: CanvasRenderingContext2D;
5
- private canvas: HTMLCanvasElement;
6
- private columns: ColumnConfig[] = [];
7
- private data: TableRow[] = [];
8
- private options: Required<TableOptions> = {
9
- rowHeight: 40,
10
- headerHeight: 48,
11
- border: true,
12
- stripe: true,
13
- multiSelect: false,
14
- fixedHeader: true,
15
- renderExpand: () => (null as any),
16
- spanMethod: () => undefined
17
- };
18
-
19
- private scrollX = 0;
20
- private scrollY = 0;
21
- private width = 0;
22
- private height = 0;
23
-
24
- // Layout info
25
- private columnPositions: number[] = [];
26
- private rowOffsets: number[] = [];
27
- private totalWidth = 0;
28
- private totalHeight = 0;
29
- private fixedLeftWidth = 0;
30
- private summaryRows: any[][] = [];
31
- private expandedRowKeys = new Set<string | number>();
32
- private selectedRowKeys = new Set<string | number>();
33
- private imageCache: Map<string, HTMLImageElement> = new Map();
34
- private hoverHeaderIndex: number = -1;
35
- private flattenedColumns: ColumnConfig[] = [];
36
- private columnLevels = 0;
37
-
38
- constructor(canvas: HTMLCanvasElement) {
39
- this.canvas = canvas;
40
- const ctx = canvas.getContext('2d');
41
- if (!ctx) throw new Error('Could not get canvas context');
42
- this.ctx = ctx;
43
- }
44
-
45
- public destroy() {
46
- this.imageCache.forEach(img => {
47
- img.onload = null;
48
- img.onerror = null;
49
- img.src = '';
50
- });
51
- this.imageCache.clear();
52
- this.data = [];
53
- this.columns = [];
54
- this.summaryRows = [];
55
- }
56
-
57
- public setExpandedRows(keys: (string | number)[]) {
58
- this.expandedRowKeys = new Set(keys);
59
- this.calculateLayout();
60
- this.render();
61
- }
62
-
63
- public setData(data: TableRow[]) {
64
- this.data = data;
65
- this.calculateLayout();
66
- this.render();
67
- }
68
-
69
- public setColumns(columns: ColumnConfig[]) {
70
- this.columns = columns;
71
- this.flattenColumns();
72
- this.calculateLayout();
73
- this.render();
74
- }
75
-
76
- private flattenColumns() {
77
- this.flattenedColumns = [];
78
- this.columnLevels = 0;
79
- const flatten = (cols: ColumnConfig[], level = 0) => {
80
- this.columnLevels = Math.max(this.columnLevels, level + 1);
81
- cols.forEach(col => {
82
- if (col.children && col.children.length > 0) {
83
- flatten(col.children, level + 1);
84
- } else {
85
- this.flattenedColumns.push(col);
86
- }
87
- });
88
- };
89
- flatten(this.columns);
90
-
91
- // Update header height if levels > 1
92
- const baseHeaderHeight = 40;
93
- if (this.columnLevels > 1) {
94
- this.options.headerHeight = this.columnLevels * baseHeaderHeight;
95
- } else {
96
- this.options.headerHeight = 48;
97
- }
98
- }
99
-
100
- public setOptions(options: Partial<TableOptions>) {
101
- this.options = { ...this.options, ...options };
102
- this.calculateLayout();
103
- this.render();
104
- }
105
-
106
- public resize(width: number, height: number) {
107
- this.width = width;
108
- this.height = height;
109
- const dpr = window.devicePixelRatio || 1;
110
- this.canvas.width = width * dpr;
111
- this.canvas.height = height * dpr;
112
- this.canvas.style.width = `${width}px`;
113
- this.canvas.style.height = `${height}px`;
114
- this.ctx.resetTransform();
115
- this.ctx.scale(dpr, dpr);
116
- this.render();
117
- }
118
-
119
- public scrollTo(x: number, y: number) {
120
- this.scrollX = x;
121
- this.scrollY = y;
122
- this.render();
123
- }
124
-
125
- private calculateLayout() {
126
- let currentX = 0;
127
- this.columnPositions = [0];
128
- let fixedWidth = 0;
129
- this.flattenedColumns.forEach((col) => {
130
- const w = col.width || 100;
131
- currentX += w;
132
- this.columnPositions.push(currentX);
133
- if (col.fixed === 'left' || col.fixed === true) {
134
- fixedWidth = currentX;
135
- }
136
- });
137
- this.totalWidth = currentX;
138
- this.fixedLeftWidth = fixedWidth;
139
-
140
- // Calculate row offsets for expanded rows
141
- let currentY = 0;
142
- this.rowOffsets = [0];
143
- const { rowHeight } = this.options;
144
- this.data.forEach(row => {
145
- currentY += rowHeight;
146
- if (this.expandedRowKeys.has(row.id)) {
147
- currentY += rowHeight * 3; // Fixed expand height for demo
148
- }
149
- this.rowOffsets.push(currentY);
150
- });
151
- this.totalHeight = currentY;
152
-
153
- this.calculateSummaries();
154
- }
155
-
156
- private getRowRange(rowspanOffset = 20) {
157
- const { headerHeight } = this.options;
158
- const summaryHeight = this.summaryRows.length * this.options.rowHeight;
159
-
160
- // Visible body height is total canvas height minus header and summary rows
161
- const visibleBodyHeight = this.height - headerHeight - summaryHeight;
162
-
163
- const startY = this.scrollY;
164
- const endY = startY + visibleBodyHeight;
165
-
166
- let startRow = 0;
167
- while (startRow < this.rowOffsets.length && (this.rowOffsets[startRow + 1] ?? 0) < startY) {
168
- startRow++;
169
- }
170
-
171
- let endRow = startRow;
172
- while (endRow < this.rowOffsets.length && (this.rowOffsets[endRow] ?? 0) < endY) {
173
- endRow++;
174
- }
175
-
176
- return {
177
- startRow: Math.max(0, startRow - rowspanOffset),
178
- endRow: Math.min(this.data.length, endRow + 1)
179
- };
180
- }
181
-
182
- private calculateSummaries() {
183
- this.summaryRows = [];
184
- const rowsByLabel = new Map<string, any[]>();
185
-
186
- this.flattenedColumns.forEach((col, j) => {
187
- if (!col.summary) return;
188
-
189
- col.summary.forEach(summaryFn => {
190
- try {
191
- const result = summaryFn(this.data);
192
- if (result && result.label) {
193
- const { label, value } = result;
194
- if (!rowsByLabel.has(label)) {
195
- rowsByLabel.set(label, new Array(this.flattenedColumns.length).fill(''));
196
- }
197
- rowsByLabel.get(label)![j] = value;
198
- }
199
- } catch (e) {
200
- console.error('Summary calculation error:', e);
201
- }
202
- });
203
- });
204
-
205
- // Convert to array and set the first column as label
206
- rowsByLabel.forEach((rowData, label) => {
207
- rowData[0] = label;
208
- this.summaryRows.push(rowData);
209
- });
210
- }
211
-
212
- render() {
213
- this.ctx.clearRect(0, 0, this.width, this.height);
214
-
215
- // 1. Draw Body (scrolling part)
216
- this.drawBody();
217
-
218
- // 2. Draw Fixed Left Body
219
- this.drawFixedLeftBody();
220
-
221
- // 3. Draw Header
222
- this.drawHeader();
223
-
224
- // 4. Draw Summary Rows (Fixed at bottom)
225
- this.drawSummaryRows();
226
- }
227
-
228
- private getColumnWidth(col: ColumnConfig): number {
229
- if (col.children && col.children.length > 0) {
230
- const width = col.children.reduce((acc, child) => acc + this.getColumnWidth(child), 0);
231
- return width;
232
- }
233
- return col.width || 100;
234
- }
235
-
236
- private isColumnFixed(col: ColumnConfig): boolean {
237
- if (col.fixed === 'left' || col.fixed === 'right' || col.fixed === true) return true;
238
- if (col.children && col.children.length > 0) {
239
- return col.children.some(child => this.isColumnFixed(child));
240
- }
241
- return false;
242
- }
243
-
244
- private drawHeader() {
245
- const { headerHeight } = this.options;
246
- const baseHeaderHeight = this.columnLevels > 1 ? 40 : headerHeight;
247
-
248
- this.ctx.save();
249
- this.ctx.fillStyle = '#f5f7fa';
250
- this.ctx.fillRect(0, 0, this.width, headerHeight);
251
-
252
- const drawHeaderRecursively = (cols: ColumnConfig[], x: number, y: number, level: number, isFixedPass: boolean) => {
253
- let currentX = x;
254
- cols.forEach((col) => {
255
- const w = this.getColumnWidth(col);
256
- const isFixedCol = this.isColumnFixed(col);
257
-
258
- // Skip based on pass
259
- if (isFixedPass !== isFixedCol) {
260
- currentX += w;
261
- return;
262
- }
263
-
264
- const isLeaf = !col.children || col.children.length === 0;
265
- const h = isLeaf ? (this.columnLevels - level) * baseHeaderHeight : baseHeaderHeight;
266
- const drawX = isFixedPass ? currentX : currentX - this.scrollX;
267
-
268
- // Only draw if within bounds or fixed
269
- const shouldDraw = isFixedPass || (drawX + w > this.fixedLeftWidth && drawX < this.width);
270
-
271
- if (shouldDraw) {
272
- if (!isFixedPass) {
273
- this.ctx.save();
274
- this.ctx.beginPath();
275
- this.ctx.rect(this.fixedLeftWidth, 0, this.width - this.fixedLeftWidth, headerHeight);
276
- this.ctx.clip();
277
- }
278
-
279
- this.drawHeaderCell(col, drawX, w, h, y, isLeaf);
280
-
281
- if (!isFixedPass) {
282
- this.ctx.restore();
283
- }
284
- }
285
-
286
- if (col.children && col.children.length > 0) {
287
- drawHeaderRecursively(col.children, currentX, y + baseHeaderHeight, level + 1, isFixedPass);
288
- }
289
-
290
- currentX += w;
291
- });
292
- };
293
-
294
- // Draw scrolling headers first
295
- drawHeaderRecursively(this.columns, 0, 0, 0, false);
296
- // Draw fixed headers on top
297
- drawHeaderRecursively(this.columns, 0, 0, 0, true);
298
-
299
- this.ctx.restore();
300
- }
301
-
302
- public setHoverHeader(index: number) {
303
- if (this.hoverHeaderIndex !== index) {
304
- this.hoverHeaderIndex = index;
305
- this.render();
306
- }
307
- }
308
-
309
- public setSelectedRows(keys: (string | number)[]) {
310
- this.selectedRowKeys = new Set(keys);
311
- this.render();
312
- }
313
-
314
- private drawHeaderCell(col: ColumnConfig, x: number, w: number, h: number, y: number, isLeaf: boolean) {
315
- this.ctx.save();
316
-
317
- // Clip to cell bounds to prevent text overflow
318
- this.ctx.beginPath();
319
- this.ctx.rect(x, y, w, h);
320
- this.ctx.clip();
321
-
322
- // Cell background
323
- this.ctx.fillStyle = '#f5f7fa';
324
- this.ctx.fillRect(x, y, w, h);
325
-
326
- if (this.options.border) {
327
- this.ctx.strokeStyle = '#ebeef5';
328
- this.ctx.lineWidth = 1;
329
- this.ctx.strokeRect(x + 0.5, y + 0.5, w, h);
330
- }
331
-
332
- if ((col.type as any) === 'selection') {
333
- const allSelected = this.data.length > 0 && this.selectedRowKeys.size === this.data.length;
334
- this.drawCheckboxCell(allSelected, x, y, w, h, 'center');
335
- } else {
336
- // Use custom renderer if provided (Canvas fallback)
337
- this.ctx.fillStyle = '#303133';
338
- this.ctx.font = 'bold 14px sans-serif';
339
- this.ctx.textBaseline = 'middle';
340
-
341
- const align = col.align || (isLeaf ? 'left' : 'center');
342
- this.ctx.textAlign = align as any;
343
-
344
- let textX = x + 10;
345
- if (align === 'center') textX = x + w / 2;
346
- if (align === 'right') textX = x + w - 24;
347
-
348
- const title = col.title || '';
349
- this.ctx.fillText(title, textX, y + h / 2);
350
-
351
- // Draw menu icon if leaf and hovered
352
- if (isLeaf && col.type !== 'selection' && col.type !== 'expand') {
353
- const idx = this.flattenedColumns.indexOf(col);
354
- if (this.hoverHeaderIndex === idx) {
355
- this.ctx.fillStyle = '#909399';
356
- this.ctx.beginPath();
357
- const iconX = x + w - 18;
358
- const iconY = y + h / 2;
359
- this.ctx.arc(iconX, iconY - 4, 1.5, 0, Math.PI * 2);
360
- this.ctx.arc(iconX, iconY, 1.5, 0, Math.PI * 2);
361
- this.ctx.arc(iconX, iconY + 4, 1.5, 0, Math.PI * 2);
362
- this.ctx.fill();
363
- }
364
- }
365
- }
366
- this.ctx.restore();
367
- }
368
-
369
- public getHeaderAt(x: number, y: number): { column: ColumnConfig, rect: any, isMenu: boolean } | null {
370
- const { headerHeight } = this.options;
371
- if (y > headerHeight) return null;
372
-
373
- const baseHeaderHeight = this.columnLevels > 1 ? 40 : headerHeight;
374
-
375
- const findHeaderRecursively = (cols: ColumnConfig[], currentX: number, currentLevel: number, isFixedPass: boolean): any => {
376
- let xOffset = currentX;
377
- for (const col of cols) {
378
- const isFixedCol = this.isColumnFixed(col);
379
-
380
- // Skip based on pass
381
- if (isFixedPass !== isFixedCol) {
382
- xOffset += this.getColumnWidth(col);
383
- continue;
384
- }
385
-
386
- const w = this.getColumnWidth(col);
387
- const isLeaf = !col.children || col.children.length === 0;
388
- const h = isLeaf ? (this.columnLevels - currentLevel) * baseHeaderHeight : baseHeaderHeight;
389
- const startY = currentLevel * baseHeaderHeight;
390
-
391
- const drawX = isFixedPass ? xOffset : xOffset - this.scrollX;
392
-
393
- if (!isFixedPass && drawX < this.fixedLeftWidth) {
394
- xOffset += w;
395
- continue;
396
- }
397
-
398
- if (x >= drawX && x < drawX + w && y >= startY && y < startY + h) {
399
- const isMenu = isLeaf && col.type !== 'selection' && col.type !== 'expand' && x >= drawX + w - 24;
400
- return {
401
- column: col,
402
- rect: { x: drawX, y: startY, width: w, height: h },
403
- isMenu
404
- };
405
- }
406
-
407
- if (col.children && col.children.length > 0) {
408
- const result = findHeaderRecursively(col.children, xOffset, currentLevel + 1, isFixedPass);
409
- if (result) return result;
410
- }
411
-
412
- xOffset += w;
413
- }
414
- return null;
415
- };
416
-
417
- // Check fixed first
418
- const fixedResult = findHeaderRecursively(this.columns, 0, 0, true);
419
- if (fixedResult) return fixedResult;
420
-
421
- // Then check scrolling
422
- return findHeaderRecursively(this.columns, 0, 0, false);
423
- }
424
-
425
- private drawBody() {
426
- const { headerHeight } = this.options;
427
- const { startRow, endRow } = this.getRowRange(this.height);
428
- const summaryHeight = this.summaryRows.length * this.options.rowHeight;
429
-
430
- this.ctx.save();
431
- this.ctx.beginPath();
432
- this.ctx.rect(this.fixedLeftWidth, headerHeight, this.width - this.fixedLeftWidth, this.height - headerHeight - summaryHeight);
433
- this.ctx.clip();
434
-
435
- const processedCells = new Set<string>();
436
-
437
- const mergedCellsToDraw: Array<{ col: ColumnConfig, val: any, x: number, y: number, w: number, h: number, r: number, c: number, bgColor: string }> = [];
438
-
439
- for (let i = startRow; i < endRow; i++) {
440
- const row = this.data[i];
441
- if (!row) continue;
442
- const y = (this.rowOffsets[i] ?? 0) - this.scrollY + headerHeight;
443
- const h = (this.rowOffsets[i+1] ?? 0) - (this.rowOffsets[i] ?? 0);
444
-
445
- const rowBgColor = i % 2 === 1 && this.options.stripe ? '#fafafa' : '#fff';
446
-
447
- this.flattenedColumns.forEach((col, j) => {
448
- if (col.fixed === 'left' || col.fixed === true) return;
449
- if (processedCells.has(`${i},${j}`)) return;
450
-
451
- const x = (this.columnPositions[j] ?? 0) - this.scrollX;
452
- const w = (this.columnPositions[j+1] ?? 0) - (this.columnPositions[j] ?? 0);
453
-
454
- // Handle merged cells
455
- const span = this.options.spanMethod({ row: i, column: col, rowIndex: i, columnIndex: j });
456
- if (span) {
457
- const { rowspan, colspan } = span;
458
- if (rowspan === 0 || colspan === 0) return;
459
-
460
- const spanW = (this.columnPositions[j + colspan] ?? this.totalWidth) - (this.columnPositions[j] ?? 0);
461
- const spanH = (this.rowOffsets[i + rowspan] ?? this.totalHeight) - (this.rowOffsets[i] ?? 0);
462
-
463
- for (let r = 0; r < rowspan; r++) {
464
- for (let c = 0; c < colspan; c++) {
465
- processedCells.add(`${i + r},${j + c}`);
466
- }
467
- }
468
-
469
- mergedCellsToDraw.push({ col, val: row[col.key || ''], x, y, w: spanW, h: spanH, r: i, c: j, bgColor: rowBgColor });
470
- } else {
471
- this.ctx.fillStyle = rowBgColor;
472
- this.ctx.fillRect(x, y, w, this.options.rowHeight);
473
-
474
- this.drawCell(col, row[col.key || ''], x, y, w, this.options.rowHeight, i);
475
- if (this.options.border) {
476
- this.ctx.strokeStyle = '#ebeef5';
477
- this.ctx.strokeRect(x + 0.5, y + 0.5, w, this.options.rowHeight);
478
- }
479
- }
480
- });
481
-
482
- // Draw expand content if expanded
483
- if (this.expandedRowKeys.has(row.id)) {
484
- const expandY = y + this.options.rowHeight;
485
- const expandH = h - this.options.rowHeight;
486
-
487
- // Draw expand area border/background
488
- this.ctx.fillStyle = '#fdfdfd';
489
- this.ctx.fillRect(0, expandY, this.width, expandH);
490
- if (this.options.border) {
491
- this.ctx.strokeStyle = '#ebeef5';
492
- this.ctx.beginPath();
493
- this.ctx.moveTo(0, expandY);
494
- this.ctx.lineTo(this.width, expandY);
495
- this.ctx.stroke();
496
- }
497
-
498
- this.drawExpandPlaceholder(row, this.fixedLeftWidth - this.scrollX, expandY, this.totalWidth - this.fixedLeftWidth, expandH);
499
- }
500
- }
501
-
502
- // Draw merged cells at the end to be on top
503
- mergedCellsToDraw.forEach(({ col, val, x, y, w, h, r, bgColor }) => {
504
- this.ctx.fillStyle = bgColor;
505
- this.ctx.fillRect(x, y, w, h);
506
- this.drawCell(col, val, x, y, w, h, r);
507
- if (this.options.border) {
508
- this.ctx.strokeStyle = '#ebeef5';
509
- this.ctx.strokeRect(x + 0.5, y + 0.5, w, h);
510
- }
511
- });
512
-
513
- this.ctx.restore();
514
- }
515
-
516
- private drawFixedLeftBody() {
517
- const { headerHeight } = this.options;
518
- const { startRow, endRow } = this.getRowRange(this.height);
519
- const summaryHeight = this.summaryRows.length * this.options.rowHeight;
520
-
521
- this.ctx.save();
522
- this.ctx.beginPath();
523
- this.ctx.rect(0, headerHeight, this.fixedLeftWidth, this.height - headerHeight - summaryHeight);
524
- this.ctx.clip();
525
-
526
- const processedCells = new Set<string>();
527
- const mergedCellsToDraw: Array<{ col: ColumnConfig, val: any, x: number, y: number, w: number, h: number, r: number, c: number, bgColor: string }> = [];
528
-
529
- for (let i = startRow; i < endRow; i++) {
530
- const row = this.data[i];
531
- if (!row) continue;
532
- const y = (this.rowOffsets[i] ?? 0) - this.scrollY + headerHeight;
533
-
534
- const rowBgColor = i % 2 === 1 && this.options.stripe ? '#fafafa' : '#fff';
535
-
536
- this.flattenedColumns.forEach((col, j) => {
537
- if (!(col.fixed === 'left' || col.fixed === true)) return;
538
- if (processedCells.has(`${i},${j}`)) return;
539
-
540
- const x = (this.columnPositions[j] ?? 0);
541
- const w = (this.columnPositions[j+1] ?? 0) - (this.columnPositions[j] ?? 0);
542
-
543
- const span = this.options.spanMethod({ row: i, column: col, rowIndex: i, columnIndex: j });
544
- if (span) {
545
- const { rowspan, colspan } = span;
546
- if (rowspan === 0 || colspan === 0) return;
547
- const spanW = (this.columnPositions[j + colspan] ?? this.fixedLeftWidth) - (this.columnPositions[j] ?? 0);
548
- const spanH = (this.rowOffsets[i + rowspan] ?? this.totalHeight) - (this.rowOffsets[i] ?? 0);
549
-
550
- for (let r = 0; r < rowspan; r++) {
551
- for (let c = 0; c < colspan; c++) {
552
- processedCells.add(`${i + r},${j + c}`);
553
- }
554
- }
555
-
556
- mergedCellsToDraw.push({ col, val: row[col.key || ''], x, y, w: spanW, h: spanH, r: i, c: j, bgColor: rowBgColor });
557
- } else {
558
- this.ctx.fillStyle = rowBgColor;
559
- this.ctx.fillRect(x, y, w, this.options.rowHeight);
560
-
561
- this.drawCell(col, row[col.key || ''], x, y, w, this.options.rowHeight, i);
562
- if (this.options.border) {
563
- this.ctx.strokeStyle = '#ebeef5';
564
- this.ctx.strokeRect(x + 0.5, y + 0.5, w, this.options.rowHeight);
565
- }
566
- }
567
- });
568
- }
569
-
570
- // Draw merged cells at the end to be on top
571
- mergedCellsToDraw.forEach(({ col, val, x, y, w, h, r, bgColor }) => {
572
- this.ctx.fillStyle = bgColor;
573
- this.ctx.fillRect(x, y, w, h);
574
- this.drawCell(col, val, x, y, w, h, r);
575
- if (this.options.border) {
576
- this.ctx.strokeStyle = '#ebeef5';
577
- this.ctx.strokeRect(x + 0.5, y + 0.5, w, h);
578
- }
579
- });
580
-
581
- this.ctx.restore();
582
- }
583
-
584
- private drawExpandPlaceholder(row: TableRow, x: number, y: number, w: number, h: number) {
585
- this.ctx.save();
586
- this.ctx.fillStyle = '#fef0f0';
587
- this.ctx.fillRect(x, y, w, h);
588
- this.ctx.fillStyle = '#f56c6c';
589
- this.ctx.font = 'italic 12px sans-serif';
590
- this.ctx.textAlign = 'center';
591
- this.ctx.fillText(`Expanded content for row ${row.id} (Rendered via h function in overlay)`, x + w / 2, y + h / 2);
592
- this.ctx.restore();
593
- }
594
-
595
- private drawSummaryRows() {
596
- if (this.summaryRows.length === 0) return;
597
- const { rowHeight } = this.options;
598
- const summaryHeight = this.summaryRows.length * rowHeight;
599
- const startY = this.height - summaryHeight;
600
-
601
- this.ctx.save();
602
- this.ctx.fillStyle = '#fdf6ec';
603
- this.ctx.fillRect(0, startY, this.width, summaryHeight);
604
-
605
- // Calculate label colspan (merge selection, expand, id columns if they are at the start)
606
- let labelColSpan = 0;
607
- for (let j = 0; j < this.flattenedColumns.length; j++) {
608
- const col = this.flattenedColumns[j];
609
- if (col && (col.type === 'selection' || col.type === 'expand' || col.key === 'id')) {
610
- labelColSpan++;
611
- } else {
612
- break;
613
- }
614
- }
615
- if (labelColSpan === 0) labelColSpan = 1;
616
-
617
- this.summaryRows.forEach((row, i) => {
618
- const y = startY + i * rowHeight;
619
-
620
- // Scrolling part
621
- this.ctx.save();
622
- this.ctx.beginPath();
623
- this.ctx.rect(this.fixedLeftWidth, y, this.width - this.fixedLeftWidth, rowHeight);
624
- this.ctx.clip();
625
- this.flattenedColumns.forEach((col, j) => {
626
- if (col.fixed === 'left' || col.fixed === true) return;
627
- if (j < labelColSpan) return; // Skip columns covered by label
628
-
629
- const x = (this.columnPositions[j] ?? 0) - this.scrollX;
630
- const w = (this.columnPositions[j+1] ?? 0) - (this.columnPositions[j] ?? 0);
631
-
632
- this.drawTextCell(row[j], x, y, w, rowHeight, col.align);
633
-
634
- if (this.options.border) {
635
- this.ctx.strokeStyle = '#ebeef5';
636
- this.ctx.strokeRect(x + 0.5, y + 0.5, w, rowHeight);
637
- }
638
- });
639
- this.ctx.restore();
640
-
641
- // Fixed part
642
- this.flattenedColumns.forEach((col, j) => {
643
- if (col.fixed === 'left' || col.fixed === true) {
644
- const x = (this.columnPositions[j] ?? 0);
645
- const w = (this.columnPositions[j+1] ?? 0) - (this.columnPositions[j] ?? 0);
646
-
647
- if (j === 0) {
648
- // Draw merged label
649
- const labelW = (this.columnPositions[labelColSpan] ?? this.fixedLeftWidth) - (this.columnPositions[0] ?? 0);
650
- this.ctx.save();
651
- this.ctx.font = 'bold 13px sans-serif';
652
- this.ctx.fillStyle = '#e6a23c';
653
- this.drawTextCell(row[0], x, y, labelW, rowHeight, 'center');
654
- this.ctx.restore();
655
-
656
- if (this.options.border) {
657
- this.ctx.strokeStyle = '#ebeef5';
658
- this.ctx.strokeRect(x + 0.5, y + 0.5, labelW, rowHeight);
659
- }
660
- } else if (j < labelColSpan) {
661
- // Skip columns covered by label
662
- return;
663
- } else {
664
- this.drawTextCell(row[j], x, y, w, rowHeight, col.align);
665
- if (this.options.border) {
666
- this.ctx.strokeStyle = '#ebeef5';
667
- this.ctx.strokeRect(x + 0.5, y + 0.5, w, rowHeight);
668
- }
669
- }
670
- }
671
- });
672
- });
673
- this.ctx.restore();
674
- }
675
-
676
- private drawCell(col: ColumnConfig, value: any, x: number, y: number, w: number, h: number, rowIndex: number) {
677
- this.ctx.save();
678
- this.ctx.beginPath();
679
- this.ctx.rect(x, y, w, h);
680
- this.ctx.clip();
681
-
682
- // Use custom renderer if provided
683
- if (col.renderCell) {
684
- // In a real Canvas app, renderCell might need to return a VNode that we render as an overlay
685
- // For now, we'll draw a placeholder or the text if possible
686
- this.drawTextCell(value, x, y, w, h, col.align);
687
- } else {
688
- switch (col.type) {
689
- case 'expand':
690
- this.drawExpandButton(x + (w - 16) / 2, y + (this.options.rowHeight - 16) / 2, this.expandedRowKeys.has(this.data[rowIndex]?.id ?? ''));
691
- break;
692
- case 'selection':
693
- const isSelected = this.selectedRowKeys.has(this.data[rowIndex]?.id ?? '');
694
- this.drawCheckboxCell(isSelected, x, y, w, this.options.rowHeight, 'center');
695
- break;
696
- case 'image':
697
- this.drawImageCell(value, x, y, w, h, col.align);
698
- break;
699
- case 'checkbox':
700
- this.drawCheckboxCell(value, x, y, w, h, col.align);
701
- break;
702
- case 'radio':
703
- this.drawRadioCell(value, x, y, w, h, col.align);
704
- break;
705
- case 'switch':
706
- this.drawSwitchCell(value, x, y, w, h, col.align);
707
- break;
708
- case 'color-picker':
709
- this.drawColorPickerCell(value, x, y, w, h, col.align);
710
- break;
711
- case 'tags':
712
- this.drawTagsCell(value, x, y, w, h);
713
- break;
714
- case 'text':
715
- default:
716
- this.drawTextCell(value, x, y, w, h, col.align);
717
- break;
718
- }
719
- }
720
- this.ctx.restore();
721
- }
722
-
723
- private drawExpandButton(x: number, y: number, isExpanded: boolean) {
724
- this.ctx.save();
725
- this.ctx.translate(x + 8, y + 8);
726
- if (isExpanded) {
727
- this.ctx.rotate(Math.PI / 2);
728
- }
729
- this.ctx.strokeStyle = '#909399';
730
- this.ctx.lineWidth = 2;
731
- this.ctx.beginPath();
732
- this.ctx.moveTo(-3, -5);
733
- this.ctx.lineTo(3, 0);
734
- this.ctx.lineTo(-3, 5);
735
- this.ctx.stroke();
736
- this.ctx.restore();
737
- }
738
-
739
- private drawTextCell(value: any, x: number, y: number, w: number, h: number, align?: string) {
740
- this.ctx.fillStyle = '#606266';
741
- this.ctx.font = '14px sans-serif';
742
- this.ctx.textAlign = (align as any) || 'left';
743
- this.ctx.textBaseline = 'middle';
744
-
745
- let textX = x + 10;
746
- if (align === 'center') textX = x + w / 2;
747
- if (align === 'right') textX = x + w - 10;
748
-
749
- const cellValue = value === undefined || value === null ? '' : String(value);
750
- this.ctx.fillText(cellValue, textX, y + h / 2);
751
- }
752
-
753
- private drawImageCell(value: any, x: number, y: number, w: number, h: number, align?: string) {
754
- if (!value) return;
755
- const src = String(value);
756
- const size = Math.min(w, h) - 10;
757
- let imgX = x + 5;
758
- if (align === 'center') imgX = x + (w - size) / 2;
759
- if (align === 'right') imgX = x + w - size - 5;
760
-
761
- let img = this.imageCache.get(src);
762
- if (!img) {
763
- img = new Image();
764
- img.src = src;
765
- this.imageCache.set(src, img);
766
- img.onload = () => this.render();
767
- img.onerror = () => {
768
- console.error(`Failed to load image: ${src}`);
769
- this.imageCache.delete(src);
770
- };
771
- }
772
-
773
- if (img.complete && img.naturalWidth > 0) {
774
- this.ctx.drawImage(img, imgX, y + (h - size) / 2, size, size);
775
- } else {
776
- // Placeholder
777
- this.ctx.fillStyle = '#f5f7fa';
778
- this.ctx.fillRect(imgX, y + (h - size) / 2, size, size);
779
- }
780
- }
781
-
782
- private drawCheckboxCell(value: any, x: number, y: number, w: number, h: number, align?: string) {
783
- const size = 16;
784
- let rectX = x + 10;
785
- if (align === 'center') rectX = x + (w - size) / 2;
786
- if (align === 'right') rectX = x + w - size - 10;
787
-
788
- const rectY = y + (h - size) / 2;
789
-
790
- this.ctx.strokeStyle = value ? '#409eff' : '#dcdfe6';
791
- this.ctx.fillStyle = value ? '#409eff' : '#fff';
792
- this.ctx.lineWidth = 1;
793
- this.ctx.strokeRect(rectX + 0.5, rectY + 0.5, size, size);
794
- if (value) {
795
- this.ctx.fillRect(rectX + 1, rectY + 1, size - 1, size - 1);
796
- // Draw checkmark
797
- this.ctx.strokeStyle = '#fff';
798
- this.ctx.beginPath();
799
- this.ctx.moveTo(rectX + 4, rectY + 8);
800
- this.ctx.lineTo(rectX + 7, rectY + 11);
801
- this.ctx.lineTo(rectX + 12, rectY + 5);
802
- this.ctx.stroke();
803
- }
804
- }
805
-
806
- private drawRadioCell(value: any, x: number, y: number, w: number, h: number, align?: string) {
807
- const size = 16;
808
- let rectX = x + 10;
809
- if (align === 'center') rectX = x + (w - size) / 2;
810
- if (align === 'right') rectX = x + w - size - 10;
811
- const rectY = y + (h - size) / 2;
812
-
813
- const centerX = rectX + size / 2;
814
- const centerY = rectY + size / 2;
815
-
816
- this.ctx.strokeStyle = value ? '#409eff' : '#dcdfe6';
817
- this.ctx.fillStyle = '#fff';
818
- this.ctx.beginPath();
819
- this.ctx.arc(centerX, centerY, size / 2, 0, Math.PI * 2);
820
- this.ctx.stroke();
821
- this.ctx.fill();
822
-
823
- if (value) {
824
- this.ctx.fillStyle = '#409eff';
825
- this.ctx.beginPath();
826
- this.ctx.arc(centerX, centerY, size / 4, 0, Math.PI * 2);
827
- this.ctx.fill();
828
- }
829
- }
830
-
831
- private drawSwitchCell(value: any, x: number, y: number, w: number, h: number, align?: string) {
832
- const swWidth = 32;
833
- const swHeight = 16;
834
- let rectX = x + 10;
835
- if (align === 'center') rectX = x + (w - swWidth) / 2;
836
- if (align === 'right') rectX = x + w - swWidth - 10;
837
- const rectY = y + (h - swHeight) / 2;
838
-
839
- this.ctx.fillStyle = value ? '#13ce66' : '#ff4949';
840
- this.ctx.beginPath();
841
- this.ctx.roundRect?.(rectX, rectY, swWidth, swHeight, swHeight / 2);
842
- this.ctx.fill();
843
-
844
- this.ctx.fillStyle = '#fff';
845
- this.ctx.beginPath();
846
- const knobX = value ? rectX + swWidth - swHeight + 2 : rectX + 2;
847
- this.ctx.arc(knobX + (swHeight - 4) / 2, rectY + swHeight / 2, (swHeight - 4) / 2, 0, Math.PI * 2);
848
- this.ctx.fill();
849
- }
850
-
851
- private drawColorPickerCell(value: any, x: number, y: number, w: number, h: number, align?: string) {
852
- const size = 20;
853
- let rectX = x + 10;
854
- if (align === 'center') rectX = x + (w - size) / 2;
855
- if (align === 'right') rectX = x + w - size - 10;
856
- const rectY = y + (h - size) / 2;
857
-
858
- this.ctx.fillStyle = value || '#000';
859
- this.ctx.fillRect(rectX, rectY, size, size);
860
- this.ctx.strokeStyle = '#dcdfe6';
861
- this.ctx.strokeRect(rectX + 0.5, rectY + 0.5, size, size);
862
- }
863
-
864
- private drawTagsCell(value: any[], x: number, y: number, w: number, h: number) {
865
- if (!Array.isArray(value)) return;
866
- let currentX = x + 5;
867
- const tagHeight = 20;
868
- const tagPadding = 5;
869
-
870
- const fontSize = 12
871
-
872
- value.forEach(tag => {
873
- const text = String(tag);
874
- this.ctx.font = `${fontSize}px sans-serif`;
875
- const textWidth = this.ctx.measureText(text).width;
876
- const tagWidth = textWidth + tagPadding * 2;
877
-
878
- if (currentX + tagWidth > x + w - 5) return; // Basic overflow handling
879
-
880
- const tagY = y + (h - tagHeight) / 2;
881
- this.ctx.fillStyle = '#ecf5ff';
882
- this.ctx.strokeStyle = '#d9ecff';
883
- this.ctx.beginPath();
884
- this.ctx.roundRect?.(currentX, tagY, tagWidth, tagHeight, 4);
885
- this.ctx.fill();
886
- this.ctx.stroke();
887
-
888
- this.ctx.fillStyle = '#409eff';
889
- this.ctx.textAlign = 'center';
890
- this.ctx.fillText(text, currentX + tagWidth / 2, tagY + tagHeight - fontSize / 2);
891
-
892
- currentX += tagWidth + 5;
893
- });
894
- }
895
-
896
- public getCellAt(x: number, y: number): CellInfo | null {
897
- const { headerHeight } = this.options;
898
- if (y < headerHeight) return null;
899
-
900
- const { startRow, endRow } = this.getRowRange(this.height);
901
- for (let i = startRow; i < endRow; i++) {
902
- const row = this.data[i];
903
- if (!row) continue;
904
- const cellY = (this.rowOffsets[i] ?? 0) - this.scrollY + headerHeight;
905
- const cellH = (this.rowOffsets[i+1] ?? 0) - (this.rowOffsets[i] ?? 0);
906
-
907
- if (y >= cellY && y < cellY + cellH) {
908
- // Check columns
909
- for (let j = 0; j < this.flattenedColumns.length; j++) {
910
- const column = this.flattenedColumns[j]!;
911
- const w = (this.columnPositions[j+1] ?? 0) - (this.columnPositions[j] ?? 0);
912
-
913
- let cellX = 0;
914
- if (column.fixed === 'left' || column.fixed === true) {
915
- cellX = this.columnPositions[j] ?? 0;
916
- } else {
917
- cellX = (this.columnPositions[j] ?? 0) - this.scrollX;
918
- if (cellX < this.fixedLeftWidth) continue;
919
- }
920
-
921
- if (x >= cellX && x < cellX + w) {
922
- const isExpandBtn = column.type === 'expand';
923
- const isSelection = column.type === 'selection';
924
-
925
- return {
926
- row: i,
927
- col: j,
928
- rect: { x: cellX, y: cellY, width: w, height: cellH },
929
- column,
930
- data: row,
931
- isExpandBtn,
932
- isSelection
933
- };
934
- }
935
- }
936
- }
937
- }
938
- return null;
939
- }
940
-
941
- public getRowOffsets() { return this.rowOffsets; }
942
- public getScrollX() { return this.scrollX; }
943
- public getScrollY() { return this.scrollY; }
944
- public getFixedLeftWidth() { return this.fixedLeftWidth; }
945
- public getTotalWidth() { return this.totalWidth; }
946
- public getTotalHeight() { return this.totalHeight; }
947
- }
948
-