stk-table-vue 0.11.0 → 0.11.1

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,6 +1,6 @@
1
1
  /**
2
2
  * name: stk-table-vue
3
- * version: v0.11.0-beta.4
3
+ * version: v0.11.1
4
4
  * description: High performance realtime virtual table for vue3 and vue2.7
5
5
  * author: japlus
6
6
  * homepage: https://ja-plus.github.io/stk-table-vue/
@@ -3,8 +3,9 @@ import { CellKeyGen, ColKeyGen, StkTableColumn, UniqKey } from '../types';
3
3
  import { VirtualScrollStore, VirtualScrollXStore } from '../useVirtualScroll';
4
4
 
5
5
  /**
6
- * 单元格拖拽选区
7
- * en: Cell drag selection
6
+ * 单元格区域选择功能
7
+ * 支持鼠标拖拽选择、键盘导航、复制粘贴等功能
8
+ * en: Cell area selection feature with mouse drag, keyboard navigation, copy-paste, etc.
8
9
  */
9
10
  export declare function useAreaSelection<DT extends Record<string, any>>(props: any, emits: any, tableContainerRef: Ref<HTMLDivElement | undefined>, dataSourceCopy: ShallowRef<DT[]>, tableHeaderLast: ShallowRef<StkTableColumn<DT>[]>, colKeyGen: ColKeyGen, cellKeyGen: CellKeyGen, scrollTo: (top: number | null, left: number | null) => void, virtualScroll: Ref<VirtualScrollStore>, virtualScrollX: Ref<VirtualScrollXStore>): {
10
11
  isSelecting: Ref<boolean, boolean>;
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * name: stk-table-vue
3
- * version: v0.11.0-beta.4
3
+ * version: v0.11.1
4
4
  * description: High performance realtime virtual table for vue3 and vue2.7
5
5
  * author: japlus
6
6
  * homepage: https://ja-plus.github.io/stk-table-vue/
@@ -338,6 +338,19 @@ function useAreaSelection(props, emits, tableContainerRef, dataSourceCopy, table
338
338
  const EDGE_ZONE = 40;
339
339
  const SCROLL_SPEED_MAX = 15;
340
340
  const POINT_EDGE_OFFSET = 2;
341
+ const KEY_ARROW_UP = "ArrowUp";
342
+ const KEY_ARROW_DOWN = "ArrowDown";
343
+ const KEY_ARROW_LEFT = "ArrowLeft";
344
+ const KEY_ARROW_RIGHT = "ArrowRight";
345
+ const KEY_TAB = "Tab";
346
+ const KEY_ESCAPE = "Escape";
347
+ const KEY_ESC = "Esc";
348
+ const KEY_C = "c";
349
+ const CELL_RANGE_SELECTED = "cell-range-selected";
350
+ const CELL_RANGE_TOP = "cell-range-t";
351
+ const CELL_RANGE_BOTTOM = "cell-range-b";
352
+ const CELL_RANGE_LEFT = "cell-range-l";
353
+ const CELL_RANGE_RIGHT = "cell-range-r";
341
354
  const selectionRange = ref(null);
342
355
  const isSelecting = ref(false);
343
356
  let anchorCell = null;
@@ -411,6 +424,78 @@ function useAreaSelection(props, emits, tableContainerRef, dataSourceCopy, table
411
424
  if (!colKey) return -1;
412
425
  return colKeyToIndexMap.value.get(colKey) ?? -1;
413
426
  }
427
+ function getColPosition(colIndex) {
428
+ let l = 0;
429
+ let w = 0;
430
+ const cols = tableHeaderLast.value;
431
+ for (let i = 0; i < cols.length; i++) {
432
+ const colWidth = getCalculatedColWidth(cols[i]);
433
+ if (i < colIndex) {
434
+ l += colWidth;
435
+ } else if (i === colIndex) {
436
+ w = colWidth;
437
+ break;
438
+ }
439
+ }
440
+ return { l, w };
441
+ }
442
+ function getMovementDelta(key, shiftKey) {
443
+ let rowDelta = 0;
444
+ let colDelta = 0;
445
+ switch (key) {
446
+ case KEY_ARROW_UP:
447
+ rowDelta = -1;
448
+ break;
449
+ case KEY_ARROW_DOWN:
450
+ rowDelta = 1;
451
+ break;
452
+ case KEY_ARROW_LEFT:
453
+ colDelta = -1;
454
+ break;
455
+ case KEY_ARROW_RIGHT:
456
+ colDelta = 1;
457
+ break;
458
+ case KEY_TAB:
459
+ colDelta = shiftKey ? -1 : 1;
460
+ break;
461
+ }
462
+ return [rowDelta, colDelta];
463
+ }
464
+ function clamp(value, min, max) {
465
+ return Math.max(min, Math.min(value, max));
466
+ }
467
+ function handleTabWrap(row, col, rawCol, rowCount, colCount) {
468
+ let newRow = row;
469
+ let newCol = col;
470
+ if (rawCol >= colCount) {
471
+ newCol = 0;
472
+ newRow = Math.min(row + 1, rowCount - 1);
473
+ } else if (rawCol < 0) {
474
+ newCol = colCount - 1;
475
+ newRow = Math.max(row - 1, 0);
476
+ }
477
+ return [newRow, newCol];
478
+ }
479
+ function calculateAutoScrollDelta(mouseX, mouseY, rect) {
480
+ const { top, bottom, left, right } = rect;
481
+ let deltaX = 0;
482
+ let deltaY = 0;
483
+ if (mouseY < top + EDGE_ZONE) {
484
+ const dist = Math.max(0, top + EDGE_ZONE - mouseY);
485
+ deltaY = -Math.ceil(dist / EDGE_ZONE * SCROLL_SPEED_MAX);
486
+ } else if (mouseY > bottom - EDGE_ZONE) {
487
+ const dist = Math.max(0, mouseY - (bottom - EDGE_ZONE));
488
+ deltaY = Math.ceil(dist / EDGE_ZONE * SCROLL_SPEED_MAX);
489
+ }
490
+ if (mouseX < left + EDGE_ZONE) {
491
+ const dist = Math.max(0, left + EDGE_ZONE - mouseX);
492
+ deltaX = -Math.ceil(dist / EDGE_ZONE * SCROLL_SPEED_MAX);
493
+ } else if (mouseX > right - EDGE_ZONE) {
494
+ const dist = Math.max(0, mouseX - (right - EDGE_ZONE));
495
+ deltaX = Math.ceil(dist / EDGE_ZONE * SCROLL_SPEED_MAX);
496
+ }
497
+ return { deltaX, deltaY };
498
+ }
414
499
  function onSelectionMouseDown(e) {
415
500
  if (!props.areaSelection || e.button !== 0) return;
416
501
  const rowIndex = getClosestTrIndex(e.target);
@@ -484,23 +569,7 @@ function useAreaSelection(props, emits, tableContainerRef, dataSourceCopy, table
484
569
  return;
485
570
  }
486
571
  const rect = container.getBoundingClientRect();
487
- const { top, bottom, left, right } = rect;
488
- let deltaX = 0;
489
- let deltaY = 0;
490
- if (lastMouseClientY < top + EDGE_ZONE) {
491
- const dist = Math.max(0, top + EDGE_ZONE - lastMouseClientY);
492
- deltaY = -Math.ceil(dist / EDGE_ZONE * SCROLL_SPEED_MAX);
493
- } else if (lastMouseClientY > bottom - EDGE_ZONE) {
494
- const dist = Math.max(0, lastMouseClientY - (bottom - EDGE_ZONE));
495
- deltaY = Math.ceil(dist / EDGE_ZONE * SCROLL_SPEED_MAX);
496
- }
497
- if (lastMouseClientX < left + EDGE_ZONE) {
498
- const dist = Math.max(0, left + EDGE_ZONE - lastMouseClientX);
499
- deltaX = -Math.ceil(dist / EDGE_ZONE * SCROLL_SPEED_MAX);
500
- } else if (lastMouseClientX > right - EDGE_ZONE) {
501
- const dist = Math.max(0, lastMouseClientX - (right - EDGE_ZONE));
502
- deltaX = Math.ceil(dist / EDGE_ZONE * SCROLL_SPEED_MAX);
503
- }
572
+ const { deltaX, deltaY } = calculateAutoScrollDelta(lastMouseClientX, lastMouseClientY, rect);
504
573
  if (deltaX !== 0 || deltaY !== 0) {
505
574
  container.scrollTop += deltaY;
506
575
  container.scrollLeft += deltaX;
@@ -597,7 +666,7 @@ function useAreaSelection(props, emits, tableContainerRef, dataSourceCopy, table
597
666
  function onKeydown(e) {
598
667
  if (!props.areaSelection) return;
599
668
  const key = e.key;
600
- if (key === "Escape" || key === "Esc") {
669
+ if (key === KEY_ESCAPE || key === KEY_ESC) {
601
670
  if (selectionRange.value) {
602
671
  clearSelectedArea();
603
672
  emitSelectionChange();
@@ -605,14 +674,14 @@ function useAreaSelection(props, emits, tableContainerRef, dataSourceCopy, table
605
674
  }
606
675
  return;
607
676
  }
608
- if ((e.ctrlKey || e.metaKey) && key === "c" && selectionRange.value) {
677
+ if ((e.ctrlKey || e.metaKey) && key === KEY_C && selectionRange.value) {
609
678
  copySelectedArea();
610
679
  e.preventDefault();
611
680
  return;
612
681
  }
613
682
  if (!keyboardEnabled.value) return;
614
- const isArrowKey = ["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight"].includes(key);
615
- const isTabKey = key === "Tab";
683
+ const isArrowKey = [KEY_ARROW_UP, KEY_ARROW_DOWN, KEY_ARROW_LEFT, KEY_ARROW_RIGHT].includes(key);
684
+ const isTabKey = key === KEY_TAB;
616
685
  const isNavigationKey = isArrowKey || isTabKey;
617
686
  if (!isNavigationKey) return;
618
687
  e.preventDefault();
@@ -631,25 +700,13 @@ function useAreaSelection(props, emits, tableContainerRef, dataSourceCopy, table
631
700
  scrollToCell(0, 0);
632
701
  return;
633
702
  }
634
- let rowDelta = 0;
635
- let colDelta = 0;
636
- if (key === "ArrowUp") {
637
- rowDelta = -1;
638
- } else if (key === "ArrowDown") {
639
- rowDelta = 1;
640
- } else if (key === "ArrowLeft") {
641
- colDelta = -1;
642
- } else if (key === "ArrowRight") {
643
- colDelta = 1;
644
- } else if (key === "Tab") {
645
- colDelta = e.shiftKey ? -1 : 1;
646
- }
703
+ const [rowDelta, colDelta] = getMovementDelta(key, e.shiftKey);
647
704
  if (e.shiftKey && isArrowKey) {
648
705
  const range = selectionRange.value;
649
706
  let newEndRow = range.endRowIndex + rowDelta;
650
707
  let newEndCol = range.endColIndex + colDelta;
651
- newEndRow = Math.max(0, Math.min(newEndRow, rowCount - 1));
652
- newEndCol = Math.max(0, Math.min(newEndCol, colCount - 1));
708
+ newEndRow = clamp(newEndRow, 0, rowCount - 1);
709
+ newEndCol = clamp(newEndCol, 0, colCount - 1);
653
710
  selectionRange.value = {
654
711
  ...range,
655
712
  endRowIndex: newEndRow,
@@ -661,17 +718,13 @@ function useAreaSelection(props, emits, tableContainerRef, dataSourceCopy, table
661
718
  const { minRow, minCol } = normalizeRange(range);
662
719
  let newRow = minRow + rowDelta;
663
720
  let newCol = minCol + colDelta;
664
- newRow = Math.max(0, Math.min(newRow, rowCount - 1));
665
- newCol = Math.max(0, Math.min(newCol, colCount - 1));
721
+ newRow = clamp(newRow, 0, rowCount - 1);
722
+ newCol = clamp(newCol, 0, colCount - 1);
666
723
  if (isTabKey) {
667
724
  const rawCol = minCol + colDelta;
668
- if (rawCol >= colCount) {
669
- newCol = 0;
670
- newRow = Math.min(minRow + 1, rowCount - 1);
671
- } else if (rawCol < 0) {
672
- newCol = colCount - 1;
673
- newRow = Math.max(minRow - 1, 0);
674
- }
725
+ const [tabRow, tabCol] = handleTabWrap(minRow, newCol, rawCol, rowCount, colCount);
726
+ newRow = tabRow;
727
+ newCol = tabCol;
675
728
  }
676
729
  anchorCell = { rowIndex: newRow, colIndex: newCol };
677
730
  selectionRange.value = {
@@ -692,31 +745,22 @@ function useAreaSelection(props, emits, tableContainerRef, dataSourceCopy, table
692
745
  if (!row || !col) return;
693
746
  const thead = container.querySelector("thead");
694
747
  const headerHeight = thead ? thead.offsetHeight : 0;
748
+ const tfoot = container.querySelector("tfoot");
749
+ const footerHeight = tfoot ? tfoot.offsetHeight : 0;
695
750
  const vs = virtualScroll.value;
696
751
  const vsx = virtualScrollX.value;
697
752
  const rowHeight = vs.rowHeight;
698
753
  const targetRowTop = rowIndex * rowHeight;
699
754
  const targetRowBottom = targetRowTop + rowHeight;
700
755
  const visibleTop = container.scrollTop;
701
- const visibleBottom = visibleTop + vs.containerHeight - headerHeight;
756
+ const visibleBottom = visibleTop + vs.containerHeight - headerHeight - footerHeight;
702
757
  let newScrollTop = null;
703
758
  if (targetRowTop < visibleTop) {
704
759
  newScrollTop = targetRowTop;
705
760
  } else if (targetRowBottom > visibleBottom) {
706
- newScrollTop = targetRowBottom - (vs.containerHeight - headerHeight);
707
- }
708
- let targetColLeft = 0;
709
- let targetColWidth = 0;
710
- const cols = tableHeaderLast.value;
711
- for (let i = 0; i < cols.length; i++) {
712
- const colWidth = getCalculatedColWidth(cols[i]) || 100;
713
- if (i < colIndex) {
714
- targetColLeft += colWidth;
715
- } else if (i === colIndex) {
716
- targetColWidth = colWidth;
717
- break;
718
- }
761
+ newScrollTop = targetRowBottom - (vs.containerHeight - headerHeight - footerHeight);
719
762
  }
763
+ const { l: targetColLeft, w: targetColWidth } = getColPosition(colIndex);
720
764
  const targetColRight = targetColLeft + targetColWidth;
721
765
  const visibleLeft = container.scrollLeft;
722
766
  const visibleRight = visibleLeft + vsx.containerWidth;
@@ -735,11 +779,11 @@ function useAreaSelection(props, emits, tableContainerRef, dataSourceCopy, table
735
779
  if (!nr || !selectedCellKeys.value.has(cellKey)) return [];
736
780
  const colIndex = colKeyToIndexMap.value.get(colKey);
737
781
  if (colIndex === void 0 || colIndex < 0) return [];
738
- const classes = ["cell-range-selected"];
739
- if (absoluteRowIndex === nr.minRow) classes.push("cell-range-t");
740
- if (absoluteRowIndex === nr.maxRow) classes.push("cell-range-b");
741
- if (colIndex === nr.minCol) classes.push("cell-range-l");
742
- if (colIndex === nr.maxCol) classes.push("cell-range-r");
782
+ const classes = [CELL_RANGE_SELECTED];
783
+ if (absoluteRowIndex === nr.minRow) classes.push(CELL_RANGE_TOP);
784
+ if (absoluteRowIndex === nr.maxRow) classes.push(CELL_RANGE_BOTTOM);
785
+ if (colIndex === nr.minCol) classes.push(CELL_RANGE_LEFT);
786
+ if (colIndex === nr.maxCol) classes.push(CELL_RANGE_RIGHT);
743
787
  return classes;
744
788
  }
745
789
  function getSelectedArea() {
package/lib/style.css CHANGED
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * name: stk-table-vue
3
- * version: v0.11.0-beta.4
3
+ * version: v0.11.1
4
4
  * description: High performance realtime virtual table for vue3 and vue2.7
5
5
  * author: japlus
6
6
  * homepage: https://ja-plus.github.io/stk-table-vue/
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "stk-table-vue",
3
- "version": "0.11.0",
3
+ "version": "0.11.1",
4
4
  "description": "High performance realtime virtual table for vue3 and vue2.7",
5
5
  "main": "./lib/stk-table-vue.js",
6
6
  "types": "./lib/src/StkTable/index.d.ts",
@@ -5,8 +5,9 @@ import { getClosestColKey, getClosestTrIndex } from '../utils';
5
5
  import { getCalculatedColWidth } from '../utils/constRefUtils';
6
6
 
7
7
  /**
8
- * 单元格拖拽选区
9
- * en: Cell drag selection
8
+ * 单元格区域选择功能
9
+ * 支持鼠标拖拽选择、键盘导航、复制粘贴等功能
10
+ * en: Cell area selection feature with mouse drag, keyboard navigation, copy-paste, etc.
10
11
  */
11
12
  export function useAreaSelection<DT extends Record<string, any>>(
12
13
  props: any,
@@ -30,9 +31,24 @@ export function useAreaSelection<DT extends Record<string, any>>(
30
31
  * en: Maximum scroll pixels per frame
31
32
  */
32
33
  const SCROLL_SPEED_MAX = 15;
33
-
34
34
  const POINT_EDGE_OFFSET = 2;
35
35
 
36
+ const KEY_ARROW_UP = 'ArrowUp';
37
+ const KEY_ARROW_DOWN = 'ArrowDown';
38
+ const KEY_ARROW_LEFT = 'ArrowLeft';
39
+ const KEY_ARROW_RIGHT = 'ArrowRight';
40
+ const KEY_TAB = 'Tab';
41
+ const KEY_ESCAPE = 'Escape';
42
+ const KEY_ESC = 'Esc';
43
+ const KEY_C = 'c';
44
+
45
+ // CSS
46
+ const CELL_RANGE_SELECTED = 'cell-range-selected';
47
+ const CELL_RANGE_TOP = 'cell-range-t';
48
+ const CELL_RANGE_BOTTOM = 'cell-range-b';
49
+ const CELL_RANGE_LEFT = 'cell-range-l';
50
+ const CELL_RANGE_RIGHT = 'cell-range-r';
51
+
36
52
  /** 当前选区范围 */
37
53
  const selectionRange = ref<AreaSelectionRange | null>(null) as Ref<AreaSelectionRange | null>;
38
54
  /** 是否正在拖选 */
@@ -128,6 +144,98 @@ export function useAreaSelection<DT extends Record<string, any>>(
128
144
  return colKeyToIndexMap.value.get(colKey) ?? -1;
129
145
  }
130
146
 
147
+ /** 获取列的左边距和宽度 */
148
+ function getColPosition(colIndex: number): { l: number; w: number } {
149
+ let l = 0;
150
+ let w = 0;
151
+ const cols = tableHeaderLast.value;
152
+ for (let i = 0; i < cols.length; i++) {
153
+ const colWidth = getCalculatedColWidth(cols[i]);
154
+ if (i < colIndex) {
155
+ l += colWidth;
156
+ } else if (i === colIndex) {
157
+ w = colWidth;
158
+ break;
159
+ }
160
+ }
161
+ return { l, w };
162
+ }
163
+
164
+ /** 根据按键计算移动方向 */
165
+ function getMovementDelta(key: string, shiftKey: boolean): [number, number] {
166
+ let rowDelta = 0;
167
+ let colDelta = 0;
168
+
169
+ switch (key) {
170
+ case KEY_ARROW_UP:
171
+ rowDelta = -1;
172
+ break;
173
+ case KEY_ARROW_DOWN:
174
+ rowDelta = 1;
175
+ break;
176
+ case KEY_ARROW_LEFT:
177
+ colDelta = -1;
178
+ break;
179
+ case KEY_ARROW_RIGHT:
180
+ colDelta = 1;
181
+ break;
182
+ case KEY_TAB:
183
+ // Tab: 向右移动;Shift+Tab: 向左移动
184
+ colDelta = shiftKey ? -1 : 1;
185
+ break;
186
+ }
187
+
188
+ return [rowDelta, colDelta];
189
+ }
190
+
191
+ /** 钳制值到指定范围内 */
192
+ function clamp(value: number, min: number, max: number): number {
193
+ return Math.max(min, Math.min(value, max));
194
+ }
195
+
196
+ /** 处理Tab键的换行逻辑 */
197
+ function handleTabWrap(row: number, col: number, rawCol: number, rowCount: number, colCount: number): [number, number] {
198
+ let newRow = row;
199
+ let newCol = col;
200
+
201
+ if (rawCol >= colCount) {
202
+ newCol = 0;
203
+ newRow = Math.min(row + 1, rowCount - 1);
204
+ } else if (rawCol < 0) {
205
+ newCol = colCount - 1;
206
+ newRow = Math.max(row - 1, 0);
207
+ }
208
+
209
+ return [newRow, newCol];
210
+ }
211
+
212
+ /** 计算自动滚动的增量 */
213
+ function calculateAutoScrollDelta(mouseX: number, mouseY: number, rect: DOMRect): { deltaX: number; deltaY: number } {
214
+ const { top, bottom, left, right } = rect;
215
+ let deltaX = 0;
216
+ let deltaY = 0;
217
+
218
+ // Y方向
219
+ if (mouseY < top + EDGE_ZONE) {
220
+ const dist = Math.max(0, top + EDGE_ZONE - mouseY);
221
+ deltaY = -Math.ceil((dist / EDGE_ZONE) * SCROLL_SPEED_MAX);
222
+ } else if (mouseY > bottom - EDGE_ZONE) {
223
+ const dist = Math.max(0, mouseY - (bottom - EDGE_ZONE));
224
+ deltaY = Math.ceil((dist / EDGE_ZONE) * SCROLL_SPEED_MAX);
225
+ }
226
+
227
+ // X方向
228
+ if (mouseX < left + EDGE_ZONE) {
229
+ const dist = Math.max(0, left + EDGE_ZONE - mouseX);
230
+ deltaX = -Math.ceil((dist / EDGE_ZONE) * SCROLL_SPEED_MAX);
231
+ } else if (mouseX > right - EDGE_ZONE) {
232
+ const dist = Math.max(0, mouseX - (right - EDGE_ZONE));
233
+ deltaX = Math.ceil((dist / EDGE_ZONE) * SCROLL_SPEED_MAX);
234
+ }
235
+
236
+ return { deltaX, deltaY };
237
+ }
238
+
131
239
  /** mousedown 处理:设置锚点,开始拖选 */
132
240
  function onSelectionMouseDown(e: MouseEvent) {
133
241
  if (!props.areaSelection || e.button !== 0) return;
@@ -238,27 +346,7 @@ export function useAreaSelection<DT extends Record<string, any>>(
238
346
  }
239
347
 
240
348
  const rect = container.getBoundingClientRect();
241
- const { top, bottom, left, right } = rect;
242
- let deltaX = 0;
243
- let deltaY = 0;
244
-
245
- // Y方向
246
- if (lastMouseClientY < top + EDGE_ZONE) {
247
- const dist = Math.max(0, top + EDGE_ZONE - lastMouseClientY);
248
- deltaY = -Math.ceil((dist / EDGE_ZONE) * SCROLL_SPEED_MAX);
249
- } else if (lastMouseClientY > bottom - EDGE_ZONE) {
250
- const dist = Math.max(0, lastMouseClientY - (bottom - EDGE_ZONE));
251
- deltaY = Math.ceil((dist / EDGE_ZONE) * SCROLL_SPEED_MAX);
252
- }
253
-
254
- // X方向
255
- if (lastMouseClientX < left + EDGE_ZONE) {
256
- const dist = Math.max(0, left + EDGE_ZONE - lastMouseClientX);
257
- deltaX = -Math.ceil((dist / EDGE_ZONE) * SCROLL_SPEED_MAX);
258
- } else if (lastMouseClientX > right - EDGE_ZONE) {
259
- const dist = Math.max(0, lastMouseClientX - (right - EDGE_ZONE));
260
- deltaX = Math.ceil((dist / EDGE_ZONE) * SCROLL_SPEED_MAX);
261
- }
349
+ const { deltaX, deltaY } = calculateAutoScrollDelta(lastMouseClientX, lastMouseClientY, rect);
262
350
 
263
351
  if (deltaX !== 0 || deltaY !== 0) {
264
352
  container.scrollTop += deltaY;
@@ -401,7 +489,7 @@ export function useAreaSelection<DT extends Record<string, any>>(
401
489
  const key = e.key;
402
490
 
403
491
  // Esc 键:取消当前选区
404
- if (key === 'Escape' || key === 'Esc') {
492
+ if (key === KEY_ESCAPE || key === KEY_ESC) {
405
493
  if (selectionRange.value) {
406
494
  clearSelectedArea();
407
495
  emitSelectionChange();
@@ -411,7 +499,7 @@ export function useAreaSelection<DT extends Record<string, any>>(
411
499
  }
412
500
 
413
501
  // Ctrl/Cmd+C 复制选区
414
- if ((e.ctrlKey || e.metaKey) && key === 'c' && selectionRange.value) {
502
+ if ((e.ctrlKey || e.metaKey) && key === KEY_C && selectionRange.value) {
415
503
  copySelectedArea();
416
504
  e.preventDefault();
417
505
  return;
@@ -420,8 +508,8 @@ export function useAreaSelection<DT extends Record<string, any>>(
420
508
  // 键盘导航(需要启用 keyboard 选项)
421
509
  if (!keyboardEnabled.value) return;
422
510
 
423
- const isArrowKey = ['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(key);
424
- const isTabKey = key === 'Tab';
511
+ const isArrowKey = [KEY_ARROW_UP, KEY_ARROW_DOWN, KEY_ARROW_LEFT, KEY_ARROW_RIGHT].includes(key);
512
+ const isTabKey = key === KEY_TAB;
425
513
  const isNavigationKey = isArrowKey || isTabKey;
426
514
 
427
515
  if (!isNavigationKey) return;
@@ -447,21 +535,7 @@ export function useAreaSelection<DT extends Record<string, any>>(
447
535
  }
448
536
 
449
537
  // 计算移动方向
450
- let rowDelta = 0;
451
- let colDelta = 0;
452
-
453
- if (key === 'ArrowUp') {
454
- rowDelta = -1;
455
- } else if (key === 'ArrowDown') {
456
- rowDelta = 1;
457
- } else if (key === 'ArrowLeft') {
458
- colDelta = -1;
459
- } else if (key === 'ArrowRight') {
460
- colDelta = 1;
461
- } else if (key === 'Tab') {
462
- // Tab: 向右移动;Shift+Tab: 向左移动
463
- colDelta = e.shiftKey ? -1 : 1;
464
- }
538
+ const [rowDelta, colDelta] = getMovementDelta(key, e.shiftKey);
465
539
 
466
540
  // Shift 扩展选区,否则移动单格选区
467
541
  if (e.shiftKey && isArrowKey) {
@@ -471,8 +545,8 @@ export function useAreaSelection<DT extends Record<string, any>>(
471
545
  let newEndCol = range.endColIndex + colDelta;
472
546
 
473
547
  // 边界检查
474
- newEndRow = Math.max(0, Math.min(newEndRow, rowCount - 1));
475
- newEndCol = Math.max(0, Math.min(newEndCol, colCount - 1));
548
+ newEndRow = clamp(newEndRow, 0, rowCount - 1);
549
+ newEndCol = clamp(newEndCol, 0, colCount - 1);
476
550
 
477
551
  selectionRange.value = {
478
552
  ...range,
@@ -490,20 +564,16 @@ export function useAreaSelection<DT extends Record<string, any>>(
490
564
  let newCol = minCol + colDelta;
491
565
 
492
566
  // 边界检查(先检查,避免越界)
493
- newRow = Math.max(0, Math.min(newRow, rowCount - 1));
494
- newCol = Math.max(0, Math.min(newCol, colCount - 1));
567
+ newRow = clamp(newRow, 0, rowCount - 1);
568
+ newCol = clamp(newCol, 0, colCount - 1);
495
569
 
496
570
  // Tab 换行逻辑:如果到达行尾/行首,换行
497
571
  if (isTabKey) {
498
572
  // 计算原始未 clamp 的值
499
573
  const rawCol = minCol + colDelta;
500
- if (rawCol >= colCount) {
501
- newCol = 0;
502
- newRow = Math.min(minRow + 1, rowCount - 1);
503
- } else if (rawCol < 0) {
504
- newCol = colCount - 1;
505
- newRow = Math.max(minRow - 1, 0);
506
- }
574
+ const [tabRow, tabCol] = handleTabWrap(minRow, newCol, rawCol, rowCount, colCount);
575
+ newRow = tabRow;
576
+ newCol = tabCol;
507
577
  }
508
578
 
509
579
  // 更新锚点和选区
@@ -523,6 +593,8 @@ export function useAreaSelection<DT extends Record<string, any>>(
523
593
 
524
594
  /**
525
595
  * 滚动到指定单元格,确保其在可视区域内
596
+ * @param rowIndex 行索引
597
+ * @param colIndex 列索引
526
598
  */
527
599
  function scrollToCell(rowIndex: number, colIndex: number) {
528
600
  const container = tableContainerRef.value;
@@ -532,9 +604,10 @@ export function useAreaSelection<DT extends Record<string, any>>(
532
604
  const col = tableHeaderLast.value[colIndex];
533
605
  if (!row || !col) return;
534
606
 
535
- // 获取表头高度
536
607
  const thead = container.querySelector('thead');
537
608
  const headerHeight = thead ? thead.offsetHeight : 0;
609
+ const tfoot = container.querySelector('tfoot');
610
+ const footerHeight = tfoot ? tfoot.offsetHeight : 0;
538
611
 
539
612
  const vs = virtualScroll.value;
540
613
  const vsx = virtualScrollX.value;
@@ -546,7 +619,7 @@ export function useAreaSelection<DT extends Record<string, any>>(
546
619
 
547
620
  // 计算可视区域
548
621
  const visibleTop = container.scrollTop;
549
- const visibleBottom = visibleTop + vs.containerHeight - headerHeight;
622
+ const visibleBottom = visibleTop + vs.containerHeight - headerHeight - footerHeight;
550
623
 
551
624
  // 计算需要的垂直滚动位置
552
625
  let newScrollTop: number | null = null;
@@ -555,22 +628,11 @@ export function useAreaSelection<DT extends Record<string, any>>(
555
628
  newScrollTop = targetRowTop;
556
629
  } else if (targetRowBottom > visibleBottom) {
557
630
  // 目标行在可视区域下方,滚动到使目标行位于底部
558
- newScrollTop = targetRowBottom - (vs.containerHeight - headerHeight);
631
+ newScrollTop = targetRowBottom - (vs.containerHeight - headerHeight - footerHeight);
559
632
  }
560
633
 
561
634
  // 计算目标列的位置
562
- let targetColLeft = 0;
563
- let targetColWidth = 0;
564
- const cols = tableHeaderLast.value;
565
- for (let i = 0; i < cols.length; i++) {
566
- const colWidth = getCalculatedColWidth(cols[i]) || 100; // 默认100px
567
- if (i < colIndex) {
568
- targetColLeft += colWidth;
569
- } else if (i === colIndex) {
570
- targetColWidth = colWidth;
571
- break;
572
- }
573
- }
635
+ const { l: targetColLeft, w: targetColWidth } = getColPosition(colIndex);
574
636
  const targetColRight = targetColLeft + targetColWidth;
575
637
 
576
638
  // 计算可视区域(水平)
@@ -594,10 +656,11 @@ export function useAreaSelection<DT extends Record<string, any>>(
594
656
  }
595
657
 
596
658
  /**
597
- * 判断一个单元格的选区 class
659
+ * 判断一个单元格的选区样式类名
598
660
  * @param cellKey 单元格唯一键
599
661
  * @param absoluteRowIndex 行在 dataSourceCopy 中的绝对索引
600
662
  * @param colKey 列唯一键
663
+ * @returns 样式类名数组
601
664
  */
602
665
  function getAreaSelectionClasses(cellKey: string, absoluteRowIndex: number, colKey: UniqKey): string[] {
603
666
  const nr = normalizedRange.value;
@@ -606,11 +669,11 @@ export function useAreaSelection<DT extends Record<string, any>>(
606
669
  const colIndex = colKeyToIndexMap.value.get(colKey);
607
670
  if (colIndex === void 0 || colIndex < 0) return [];
608
671
 
609
- const classes: string[] = ['cell-range-selected'];
610
- if (absoluteRowIndex === nr.minRow) classes.push('cell-range-t');
611
- if (absoluteRowIndex === nr.maxRow) classes.push('cell-range-b');
612
- if (colIndex === nr.minCol) classes.push('cell-range-l');
613
- if (colIndex === nr.maxCol) classes.push('cell-range-r');
672
+ const classes: string[] = [CELL_RANGE_SELECTED];
673
+ if (absoluteRowIndex === nr.minRow) classes.push(CELL_RANGE_TOP);
674
+ if (absoluteRowIndex === nr.maxRow) classes.push(CELL_RANGE_BOTTOM);
675
+ if (colIndex === nr.minCol) classes.push(CELL_RANGE_LEFT);
676
+ if (colIndex === nr.maxCol) classes.push(CELL_RANGE_RIGHT);
614
677
  return classes;
615
678
  }
616
679