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,659 +0,0 @@
1
- <template>
2
- <div class="canvas-table-container" ref="containerRef">
3
- <div
4
- class="canvas-wrapper"
5
- :style="wrapperStyle"
6
- @wheel.prevent="handleWheel"
7
- >
8
- <canvas ref="canvasRef"></canvas>
9
-
10
- <!-- Horizontal Scrollbar -->
11
- <div
12
- class="scrollbar horizontal"
13
- ref="hScrollRef"
14
- @scroll="handleHScroll"
15
- >
16
- <div :style="{ width: totalWidth + 'px', height: '1px' }"></div>
17
- </div>
18
-
19
- <!-- Vertical Scrollbar -->
20
- <div
21
- class="scrollbar vertical"
22
- ref="vScrollRef"
23
- @scroll="handleVScroll"
24
- >
25
- <div :style="{ height: totalHeight + 'px', width: '1px' }"></div>
26
- </div>
27
-
28
- <!-- Overlays (Edit buttons, Dialogs, etc.) -->
29
- <div class="table-overlays" :style="overlayStyle">
30
- <div
31
- v-if="hoverCell && hoverCell.column.renderEdit"
32
- class="edit-btn"
33
- :style="editBtnStyle"
34
- @click.stop="openEdit(hoverCell)"
35
- >
36
- <svg viewBox="0 0 24 24" width="14" height="14"><path fill="currentColor" d="M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25zM20.71 7.04c.39-.39.39-1.02 0-1.41l-2.34-2.34c-.39-.39-1.02-.39-1.41 0l-1.83 1.83 3.75 3.75 1.83-1.83z"/></svg>
37
- </div>
38
-
39
- <!-- Header Menu -->
40
- <div
41
- v-if="headerMenu"
42
- class="header-menu-overlay"
43
- @click.self="headerMenu = null"
44
- >
45
- <div class="header-menu" :style="headerMenuStyle">
46
- <div
47
- v-for="item in headerMenuItems"
48
- :key="item.label"
49
- class="menu-item"
50
- @click="handleMenuCommand(item)"
51
- >
52
- <span class="icon">{{ item.icon || '•' }}</span>
53
- <span class="label">{{ item.label }}</span>
54
- </div>
55
- </div>
56
- </div>
57
-
58
- <!-- Expand Rows Overlay -->
59
- <div
60
- v-for="rowId in expandedRows"
61
- :key="rowId"
62
- class="expand-row-overlay"
63
- :style="getExpandStyle(rowId)"
64
- >
65
- <component :is="renderExpandContent(rowId)" />
66
- </div>
67
-
68
- <!-- Edit Dialog -->
69
- <Teleport to="body">
70
- <div v-if="editingCell" class="edit-dialog-popover" :style="editDialogStyle">
71
- <div class="edit-dialog-content">
72
- <v-node-renderer :vnode="renderCellEdit(editingCell)" />
73
- </div>
74
- <div class="edit-dialog-footer">
75
- <button class="small" @click="closeEdit">Cancel</button>
76
- <button class="small primary" @click="saveEdit">Save</button>
77
- </div>
78
- </div>
79
- <!-- Click outside listener for popover -->
80
- <div v-if="editingCell" class="popover-click-mask" @click="closeEdit"></div>
81
- </Teleport>
82
- </div>
83
- </div>
84
- </div>
85
- </template>
86
-
87
- <script setup lang="ts">
88
- import { ref, onMounted, onUnmounted, watch, computed, shallowRef, h, defineComponent } from 'vue';
89
- import { CanvasRenderer } from '../core/renderer';
90
- import type { ColumnConfig, TableRow, TableOptions, CellInfo, MenuItem } from '../types';
91
-
92
- // Helper component to render VNodes
93
- const VNodeRenderer = defineComponent({
94
- props: ['vnode'],
95
- setup(props) {
96
- return () => props.vnode;
97
- }
98
- });
99
-
100
- const props = defineProps<{
101
- columns: ColumnConfig[];
102
- data: TableRow[];
103
- options?: Partial<TableOptions>;
104
- }>();
105
-
106
- const emit = defineEmits(['update:data', 'select-change', 'header-command']);
107
-
108
- const containerRef = ref<HTMLDivElement | null>(null);
109
- const canvasRef = ref<HTMLCanvasElement | null>(null);
110
- const hScrollRef = ref<HTMLDivElement | null>(null);
111
- const vScrollRef = ref<HTMLDivElement | null>(null);
112
-
113
- const renderer = shallowRef<CanvasRenderer | null>(null);
114
- const totalWidth = ref(0);
115
- const totalHeight = ref(0);
116
- const viewWidth = ref(0);
117
- const viewHeight = ref(0);
118
- const scrollX = ref(0);
119
- const scrollY = ref(0);
120
-
121
- // Interaction state
122
- const hoverCell = ref<CellInfo | null>(null);
123
- const editingCell = ref<CellInfo | null>(null);
124
- const editingData = ref<TableRow | null>(null);
125
- const selectedRows = ref<Set<string | number>>(new Set());
126
- const expandedRows = ref<Set<string | number>>(new Set());
127
- const headerMenu = ref<{ column: ColumnConfig, rect: any } | null>(null);
128
-
129
- const wrapperStyle = computed(() => ({
130
- width: '100%',
131
- height: '100%',
132
- position: 'relative' as const,
133
- overflow: 'hidden'
134
- }));
135
-
136
- const overlayStyle = computed(() => ({
137
- position: 'absolute' as const,
138
- top: 0,
139
- left: 0,
140
- pointerEvents: 'none' as const,
141
- width: '100%',
142
- height: '100%'
143
- }));
144
-
145
- const editBtnStyle = computed(() => {
146
- if (!hoverCell.value) return {};
147
- const { rect } = hoverCell.value;
148
- return {
149
- position: 'absolute' as const,
150
- left: `${rect.x + rect.width - 24}px`,
151
- top: `${rect.y + (rect.height - 20) / 2}px`,
152
- pointerEvents: 'auto' as const
153
- };
154
- });
155
-
156
- const editDialogStyle = computed(() => {
157
- if (!editingCell.value || !canvasRef.value) return {};
158
-
159
- const canvasRect = canvasRef.value.getBoundingClientRect();
160
- const { rect } = editingCell.value;
161
-
162
- // Position popover relative to the cell
163
- // We place it below the cell by default
164
- let top = canvasRect.top + rect.y + rect.height + 5;
165
- let left = canvasRect.left + rect.x;
166
-
167
- // Simple viewport boundary check
168
- const popoverWidth = 300; // Estimated or fixed
169
- if (left + popoverWidth > window.innerWidth) {
170
- left = window.innerWidth - popoverWidth - 20;
171
- }
172
-
173
- return {
174
- position: 'fixed' as const,
175
- top: `${top}px`,
176
- left: `${left}px`,
177
- zIndex: 2000,
178
- pointerEvents: 'auto' as const
179
- };
180
- });
181
-
182
- const headerMenuStyle = computed(() => {
183
- if (!headerMenu.value) return {};
184
- const { rect } = headerMenu.value;
185
- return {
186
- position: 'absolute' as const,
187
- left: `${rect.x + rect.width - 120}px`,
188
- top: `${rect.y + rect.height}px`,
189
- pointerEvents: 'auto' as const
190
- };
191
- });
192
-
193
- const headerMenuItems = computed((): MenuItem[] => {
194
- if (!headerMenu.value) return [];
195
- const { column } = headerMenu.value;
196
- const defaultMenu: MenuItem[] = [
197
- {
198
- label: 'Fix to Left',
199
- onCommand: (col) => {
200
- // Find index of this column and fix all previous ones
201
- const idx = props.columns.findIndex(c => c.key === col.key);
202
- props.columns.forEach((c, i) => {
203
- if (i <= idx) c.fixed = 'left';
204
- });
205
- renderer.value?.setColumns(props.columns);
206
- }
207
- },
208
- {
209
- label: 'Unfix',
210
- onCommand: (col) => {
211
- col.fixed = false;
212
- renderer.value?.setColumns(props.columns);
213
- }
214
- }
215
- ];
216
- if (column.renderHeaderMenu) {
217
- return column.renderHeaderMenu(column, defaultMenu);
218
- }
219
- return defaultMenu;
220
- });
221
-
222
- const handleMenuCommand = (item: MenuItem) => {
223
- if (headerMenu.value) {
224
- item.onCommand(headerMenu.value.column);
225
- headerMenu.value = null;
226
- }
227
- };
228
-
229
- const handleMouseMove = (e: MouseEvent) => {
230
- if (!canvasRef.value || !renderer.value) return;
231
- const rect = canvasRef.value.getBoundingClientRect();
232
- const x = e.clientX - rect.left;
233
- const y = e.clientY - rect.top;
234
-
235
- // Handle header hover
236
- const header = renderer.value.getHeaderAt(x, y);
237
- if (header) {
238
- const idx = props.columns.findIndex(c => c.key === header.column.key);
239
- renderer.value.setHoverHeader(idx);
240
-
241
- // Set cursor style
242
- if (header.isMenu || header.column.type === 'selection') {
243
- canvasRef.value.style.cursor = 'pointer';
244
- } else {
245
- canvasRef.value.style.cursor = 'default';
246
- }
247
- } else {
248
- renderer.value.setHoverHeader(-1);
249
- canvasRef.value.style.cursor = 'default';
250
- }
251
-
252
- hoverCell.value = renderer.value.getCellAt(x, y);
253
-
254
- // Update cursor if hovering over expand button or selection
255
- if (hoverCell.value) {
256
- if (hoverCell.value.isExpandBtn || hoverCell.value.isSelection) {
257
- canvasRef.value.style.cursor = 'pointer';
258
- }
259
- }
260
- };
261
-
262
- const handleMouseDown = (e: MouseEvent) => {
263
- if (!canvasRef.value || !renderer.value) return;
264
- const rect = canvasRef.value.getBoundingClientRect();
265
- const x = e.clientX - rect.left;
266
- const y = e.clientY - rect.top;
267
-
268
- // Check header click
269
- const header = renderer.value.getHeaderAt(x, y);
270
- if (header) {
271
- if (header.isMenu) {
272
- headerMenu.value = { column: header.column, rect: header.rect };
273
- } else if (header.column.type === 'selection') {
274
- // Toggle all selection
275
- const allSelected = selectedRows.value.size === props.data.length;
276
- if (allSelected) {
277
- selectedRows.value.clear();
278
- } else {
279
- props.data.forEach(r => selectedRows.value.add(r.id));
280
- }
281
- renderer.value.setSelectedRows(Array.from(selectedRows.value));
282
- emit('select-change', Array.from(selectedRows.value));
283
- }
284
- return;
285
- }
286
-
287
- const cell = renderer.value.getCellAt(x, y);
288
- if (cell) {
289
- if (cell.isExpandBtn) {
290
- toggleExpand(cell.data.id);
291
- return;
292
- }
293
-
294
- const rowId = cell.data.id;
295
- if (cell.isSelection) {
296
- if (selectedRows.value.has(rowId)) {
297
- selectedRows.value.delete(rowId);
298
- } else {
299
- selectedRows.value.add(rowId);
300
- }
301
- renderer.value.setSelectedRows(Array.from(selectedRows.value));
302
- emit('select-change', Array.from(selectedRows.value));
303
- return;
304
- }
305
-
306
- if (props.options?.multiSelect) {
307
- if (selectedRows.value.has(rowId)) {
308
- selectedRows.value.delete(rowId);
309
- } else {
310
- selectedRows.value.add(rowId);
311
- }
312
- } else {
313
- selectedRows.value.clear();
314
- selectedRows.value.add(rowId);
315
- }
316
- emit('select-change', Array.from(selectedRows.value));
317
- }
318
- };
319
-
320
- const handleDoubleClick = (e: MouseEvent) => {
321
- if (!canvasRef.value || !renderer.value) return;
322
- const rect = canvasRef.value.getBoundingClientRect();
323
- const x = e.clientX - rect.left;
324
- const y = e.clientY - rect.top;
325
- const cell = renderer.value.getCellAt(x, y);
326
- if (cell && cell.column.renderEdit) {
327
- openEdit(cell);
328
- }
329
- };
330
-
331
- const openEdit = (cell: CellInfo) => {
332
- editingCell.value = cell;
333
- editingData.value = JSON.parse(JSON.stringify(cell.data));
334
- };
335
-
336
- const toggleExpand = (rowId: string | number) => {
337
- if (expandedRows.value.has(rowId)) {
338
- expandedRows.value.delete(rowId);
339
- } else {
340
- expandedRows.value.add(rowId);
341
- }
342
- renderer.value?.setExpandedRows(Array.from(expandedRows.value));
343
- updateTotalSize();
344
- };
345
-
346
- const closeEdit = () => {
347
- editingCell.value = null;
348
- editingData.value = null;
349
- };
350
-
351
- const saveEdit = () => {
352
- if (editingCell.value && editingData.value) {
353
- const rowId = editingCell.value.data.id;
354
- const rowIndex = props.data.findIndex(r => r.id === rowId);
355
- if (rowIndex !== -1) {
356
- // In a real application, you'd likely want to avoid direct prop mutation
357
- // We emit the change so the parent can update the data
358
- const newData = [...props.data];
359
- newData[rowIndex] = editingData.value;
360
- emit('update:data', newData);
361
-
362
- // Since renderer takes data as prop, we need to manually update it if it doesn't watch props
363
- renderer.value?.setData(newData);
364
- }
365
- }
366
- closeEdit();
367
- };
368
-
369
- const renderCellEdit = (cell: CellInfo) => {
370
- if (!cell.column.renderEdit || !editingData.value) return null;
371
- return cell.column.renderEdit(editingData.value, (comp: any, propsOrChildren?: any, children?: any) => {
372
- if (propsOrChildren && (typeof propsOrChildren === 'string' || Array.isArray(propsOrChildren) || propsOrChildren.__v_isVNode)) {
373
- return h(comp, null, propsOrChildren);
374
- }
375
- return h(comp, propsOrChildren, children);
376
- });
377
- };
378
-
379
- const getExpandStyle = (rowId: string | number) => {
380
- if (!renderer.value) return {};
381
- const rowIndex = props.data.findIndex(r => r.id === rowId);
382
- if (rowIndex === -1) return {};
383
-
384
- const rowOffsets = renderer.value.getRowOffsets();
385
- const y = (rowOffsets[rowIndex] ?? 0) - scrollY.value + (props.options?.headerHeight || 48);
386
- const h = (rowOffsets[rowIndex+1] ?? 0) - (rowOffsets[rowIndex] ?? 0) - (props.options?.rowHeight || 40);
387
- const fixedLeftWidth = renderer.value.getFixedLeftWidth();
388
-
389
- return {
390
- position: 'absolute' as const,
391
- top: `${y + (props.options?.rowHeight || 40)}px`,
392
- left: `${fixedLeftWidth}px`,
393
- width: `${viewWidth.value - fixedLeftWidth}px`,
394
- height: `${h}px`,
395
- pointerEvents: 'auto' as const,
396
- overflow: 'hidden',
397
- backgroundColor: '#fff',
398
- borderBottom: '1px solid #ebeef5',
399
- // Sync inner scrolling content
400
- display: 'flex'
401
- };
402
- };
403
-
404
- const getExpandContentStyle = () => {
405
- return {
406
- marginLeft: `-${scrollX.value}px`,
407
- minWidth: `${totalWidth.value - renderer.value?.getFixedLeftWidth()!}px`
408
- };
409
- };
410
-
411
- const renderExpandContent = (rowId: string | number) => {
412
- const row = props.data.find(r => r.id === rowId);
413
- if (!row || !props.options?.renderExpand) return null;
414
- return h('div', { style: getExpandContentStyle() }, [
415
- props.options.renderExpand(row, (comp: any, propsOrChildren?: any, children?: any) => {
416
- if (propsOrChildren && (typeof propsOrChildren === 'string' || Array.isArray(propsOrChildren) || propsOrChildren.__v_isVNode)) {
417
- return h(comp, null, propsOrChildren);
418
- }
419
- return h(comp, propsOrChildren, children);
420
- })
421
- ]);
422
- };
423
-
424
- const handleHScroll = (e: Event) => {
425
- const target = e.target as HTMLDivElement;
426
- scrollX.value = target.scrollLeft;
427
- renderer.value?.scrollTo(scrollX.value, scrollY.value);
428
- hoverCell.value = null;
429
- };
430
-
431
- const handleVScroll = (e: Event) => {
432
- const target = e.target as HTMLDivElement;
433
- scrollY.value = target.scrollTop;
434
- renderer.value?.scrollTo(scrollX.value, scrollY.value);
435
- hoverCell.value = null;
436
- };
437
-
438
- const handleWheel = (e: WheelEvent) => {
439
- if (!vScrollRef.value || !hScrollRef.value) return;
440
-
441
- // Use shift for horizontal or just deltaX
442
- const deltaY = e.deltaY;
443
- const deltaX = e.shiftKey ? e.deltaY : e.deltaX;
444
-
445
- vScrollRef.value.scrollTop += deltaY;
446
- hScrollRef.value.scrollLeft += deltaX;
447
- };
448
-
449
- const handleResize = (entries: ResizeObserverEntry[]) => {
450
- const entry = entries[0];
451
- if (!entry || !renderer.value) return;
452
-
453
- const { width, height } = entry.contentRect;
454
- viewWidth.value = width;
455
- viewHeight.value = height;
456
-
457
- renderer.value.resize(width, height);
458
- updateTotalSize();
459
- };
460
-
461
- const updateTotalSize = () => {
462
- if (!renderer.value) return;
463
- totalWidth.value = renderer.value.getTotalWidth();
464
- totalHeight.value = renderer.value.getTotalHeight();
465
- };
466
-
467
- let resizeObserver: ResizeObserver | null = null;
468
-
469
- onMounted(() => {
470
- if (canvasRef.value) {
471
- renderer.value = new CanvasRenderer(canvasRef.value);
472
- renderer.value.setColumns(props.columns);
473
- renderer.value.setData(props.data);
474
- renderer.value.setOptions(props.options || {});
475
-
476
- updateTotalSize();
477
-
478
- resizeObserver = new ResizeObserver(handleResize);
479
- if (containerRef.value) {
480
- resizeObserver.observe(containerRef.value);
481
- }
482
-
483
- canvasRef.value.addEventListener('mousemove', handleMouseMove);
484
- canvasRef.value.addEventListener('mousedown', handleMouseDown);
485
- canvasRef.value.addEventListener('dblclick', handleDoubleClick);
486
- }
487
- });
488
-
489
- onUnmounted(() => {
490
- resizeObserver?.disconnect();
491
- canvasRef.value?.removeEventListener('mousemove', handleMouseMove);
492
- canvasRef.value?.removeEventListener('mousedown', handleMouseDown);
493
- canvasRef.value?.removeEventListener('dblclick', handleDoubleClick);
494
-
495
- renderer.value?.destroy();
496
- renderer.value = null;
497
- });
498
-
499
- watch(() => props.columns, (cols) => {
500
- renderer.value?.setColumns(cols);
501
- updateTotalSize();
502
- }, { deep: true });
503
-
504
- watch(() => props.data, (data) => {
505
- renderer.value?.setData(data);
506
- updateTotalSize();
507
- }, { deep: true });
508
-
509
- watch(() => props.options, (opts) => {
510
- if (opts) renderer.value?.setOptions(opts);
511
- }, { deep: true });
512
-
513
- </script>
514
-
515
- <style scoped>
516
- .canvas-table-container {
517
- width: 100%;
518
- height: 100%;
519
- border: 1px solid #ebeef5;
520
- box-sizing: border-box;
521
- }
522
-
523
- .canvas-wrapper {
524
- background: #fff;
525
- }
526
-
527
- .scrollbar {
528
- position: absolute;
529
- overflow: auto;
530
- z-index: 10;
531
- }
532
-
533
- .scrollbar.horizontal {
534
- bottom: 0;
535
- left: 0;
536
- right: 0;
537
- height: 12px;
538
- }
539
-
540
- .scrollbar.vertical {
541
- top: 0;
542
- right: 0;
543
- bottom: 0;
544
- width: 12px;
545
- }
546
-
547
- /* Hide default scrollbars for the wrapper, use custom ones */
548
- .scrollbar::-webkit-scrollbar {
549
- width: 8px;
550
- height: 8px;
551
- }
552
-
553
- .scrollbar::-webkit-scrollbar-thumb {
554
- background: #c1c1c1;
555
- border-radius: 4px;
556
- }
557
-
558
- .scrollbar::-webkit-scrollbar-thumb:hover {
559
- background: #a8a8a8;
560
- }
561
-
562
- .table-overlays {
563
- z-index: 5;
564
- }
565
-
566
- .edit-btn {
567
- width: 20px;
568
- height: 20px;
569
- background: #fff;
570
- border: 1px solid #dcdfe6;
571
- border-radius: 4px;
572
- display: flex;
573
- align-items: center;
574
- justify-content: center;
575
- cursor: pointer;
576
- color: #409eff;
577
- box-shadow: 0 2px 4px rgba(0,0,0,0.1);
578
- }
579
-
580
- .edit-btn:hover {
581
- background: #f5f7fa;
582
- }
583
-
584
- .header-menu-overlay {
585
- position: absolute;
586
- top: 0;
587
- left: 0;
588
- right: 0;
589
- bottom: 0;
590
- pointer-events: auto;
591
- z-index: 20;
592
- }
593
-
594
- .header-menu {
595
- background: #fff;
596
- border: 1px solid #ebeef5;
597
- border-radius: 4px;
598
- box-shadow: 0 2px 12px 0 rgba(0,0,0,0.1);
599
- padding: 5px 0;
600
- min-width: 120px;
601
- }
602
-
603
- .menu-item {
604
- padding: 8px 12px;
605
- cursor: pointer;
606
- display: flex;
607
- align-items: center;
608
- gap: 8px;
609
- font-size: 14px;
610
- }
611
-
612
- .menu-item:hover {
613
- background: #f5f7fa;
614
- color: #409eff;
615
- }
616
-
617
- .edit-dialog-popover {
618
- background: #fff;
619
- border-radius: 4px;
620
- width: 300px;
621
- box-shadow: 0 2px 12px 0 rgba(0,0,0,0.1);
622
- border: 1px solid #ebeef5;
623
- display: flex;
624
- flex-direction: column;
625
- z-index: 2000;
626
- }
627
-
628
- .popover-click-mask {
629
- position: fixed;
630
- top: 0;
631
- left: 0;
632
- right: 0;
633
- bottom: 0;
634
- z-index: 1999;
635
- }
636
-
637
- .edit-dialog-content {
638
- padding: 12px;
639
- }
640
-
641
- .edit-dialog-footer {
642
- padding: 8px 12px;
643
- border-top: 1px solid #ebeef5;
644
- display: flex;
645
- justify-content: flex-end;
646
- gap: 8px;
647
- }
648
-
649
- button.small {
650
- padding: 4px 12px;
651
- font-size: 12px;
652
- }
653
-
654
- button.primary {
655
- background: #409eff;
656
- color: #fff;
657
- border-color: #409eff;
658
- }
659
- </style>