stk-table-vue 1.2.4 → 1.2.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/README.md +194 -194
  2. package/lib/{Dropdown-D_9cB0rD.js → Dropdown-BAANgg_5.js} +2 -2
  3. package/lib/{StkTable-CVcmlZi9.js → StkTable-CGcxwpdb.js} +22 -6
  4. package/lib/src/StkTable/StkTable.vue.d.ts +18 -4
  5. package/lib/src/StkTable/types/index.d.ts +4 -1
  6. package/lib/src/StkTable/useVirtualScroll.d.ts +6 -1
  7. package/lib/stk-table-vue.js +3 -3
  8. package/lib/style.css +1 -1
  9. package/package.json +93 -93
  10. package/src/StkTable/StkTable.vue +2362 -2338
  11. package/src/StkTable/components/DragHandle.vue +9 -9
  12. package/src/StkTable/components/SortIcon.vue +6 -6
  13. package/src/StkTable/components/TreeNodeCell.vue +19 -19
  14. package/src/StkTable/components/TriangleIcon.vue +3 -3
  15. package/src/StkTable/const.ts +50 -50
  16. package/src/StkTable/custom-cells/ChangeCell/ChangeCell.less +34 -34
  17. package/src/StkTable/custom-cells/ChangeCell/createChangeCell.ts +90 -90
  18. package/src/StkTable/custom-cells/ChangeCell/index.ts +4 -4
  19. package/src/StkTable/custom-cells/CheckboxCell/CheckboxCell.less +14 -14
  20. package/src/StkTable/custom-cells/CheckboxCell/CheckboxCell.vue +56 -56
  21. package/src/StkTable/custom-cells/CheckboxCell/createCheckboxCell.ts +146 -146
  22. package/src/StkTable/custom-cells/CheckboxCell/index.ts +5 -5
  23. package/src/StkTable/custom-cells/FilterCell/Dropdown/index.ts +16 -16
  24. package/src/StkTable/custom-cells/FilterCell/Dropdown/index.vue +186 -186
  25. package/src/StkTable/custom-cells/FilterCell/Filter.less +118 -118
  26. package/src/StkTable/custom-cells/FilterCell/Filter.vue +66 -66
  27. package/src/StkTable/custom-cells/FilterCell/createFilterCell.ts +120 -120
  28. package/src/StkTable/custom-cells/FilterCell/index.ts +6 -6
  29. package/src/StkTable/custom-cells/FilterCell/types.ts +43 -43
  30. package/src/StkTable/custom-cells/NumberCell/createNumberCell.ts +43 -43
  31. package/src/StkTable/custom-cells/NumberCell/index.ts +2 -2
  32. package/src/StkTable/custom-cells/utils/formatNumber.ts +137 -137
  33. package/src/StkTable/features/const.ts +1 -1
  34. package/src/StkTable/features/index.ts +1 -1
  35. package/src/StkTable/features/useAreaSelection.ts +1240 -1239
  36. package/src/StkTable/index.ts +20 -20
  37. package/src/StkTable/mergeCellsCache.ts +69 -69
  38. package/src/StkTable/registerFeature.ts +40 -40
  39. package/src/StkTable/style.less +791 -791
  40. package/src/StkTable/types/highlightDimOptions.ts +28 -28
  41. package/src/StkTable/types/index.ts +503 -500
  42. package/src/StkTable/useAutoResize.ts +87 -87
  43. package/src/StkTable/useColResize.ts +202 -202
  44. package/src/StkTable/useFixedCol.ts +168 -168
  45. package/src/StkTable/useFixedStyle.ts +75 -75
  46. package/src/StkTable/useGetFixedColPosition.ts +63 -63
  47. package/src/StkTable/useHighlight.ts +270 -270
  48. package/src/StkTable/useIndexResolver.ts +29 -29
  49. package/src/StkTable/useKeyboardArrowScroll.ts +114 -114
  50. package/src/StkTable/useMaxRowSpan.ts +60 -60
  51. package/src/StkTable/useMergeCells.ts +541 -541
  52. package/src/StkTable/useRowExpand.ts +81 -81
  53. package/src/StkTable/useScrollRowByRow.ts +96 -96
  54. package/src/StkTable/useScrollbar.ts +177 -177
  55. package/src/StkTable/useSorter.ts +267 -267
  56. package/src/StkTable/useTableColumns.ts +142 -142
  57. package/src/StkTable/useThDrag.ts +94 -94
  58. package/src/StkTable/useTrDrag.ts +100 -100
  59. package/src/StkTable/useTree.ts +253 -253
  60. package/src/StkTable/useVirtualScroll.ts +1032 -1025
  61. package/src/StkTable/useWheeling.ts +23 -23
  62. package/src/StkTable/utils/constRefUtils.ts +29 -29
  63. package/src/StkTable/utils/index.ts +331 -331
  64. package/src/StkTable/utils/rowHeightFenwickTree.ts +74 -74
  65. package/src/StkTable/utils/useTriggerRef.ts +33 -33
  66. package/src/VirtualTree.vue +622 -622
  67. package/src/VirtualTreeSelect.vue +367 -367
  68. package/src/vite-env.d.ts +10 -10
@@ -1,1239 +1,1240 @@
1
- import { Ref, ShallowRef, computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue';
2
- import {
3
- AreaSelectionConfig,
4
- AreaSelectionRange,
5
- CellKeyGen,
6
- ColKeyGen,
7
- StkTableColumn,
8
- UniqKey,
9
- AreaSelectionSetterRange,
10
- AreaSelectionSetterOption,
11
- } from '../types';
12
- import { VirtualScrollStore, VirtualScrollXStore } from '../useVirtualScroll';
13
- import { getClosestColKey, getClosestTd, getClosestTr, getClosestTrIndex } from '../utils';
14
- import { getCalculatedColWidth } from '../utils/constRefUtils';
15
- import { MY_FN_NAME } from './const';
16
-
17
- /**
18
- * 单元格区域选择功能(Excel 式交互)。
19
- * 支持鼠标拖拽选择、键盘导航(方向键/Tab/Shift+Tab,Ctrl 多选、Shift 扩选)、复制到剪贴板。
20
- * en: Cell area selection feature with mouse drag, keyboard navigation, copy-paste, etc.
21
- *
22
- * 分区说明:
23
- * - 选区状态:锚点 begin / 终点 end、选区集合、是否正在拖拽选中(isAreaSelecting)。
24
- * - 鼠标交互:mousedown 起锚、mousemove 扩展选区、拖拽靠近容器边缘时自动滚动(EDGE_ZONE/SCROLL_SPEED_MAX)。
25
- * - 键盘交互:方向键/Tab 移动选区(见 useKeyboardArrowScroll 联动)、Ctrl 切换多选、Shift 扩选。
26
- * - 复制:通过 formatCellForClipboard 回调(若配置了 customCell 应自定义)生成剪贴板文本。
27
- * - 选区转换:单元格的可见/数据坐标通过 getRowIndex/getColumnIndex、cellKeyGen/colKeyGen 与
28
- * 虚拟滚动(虚拟窗口裁剪)和列宽缓存(getCalculatedColWidth)相互换算。
29
- *
30
- * 对外返回:选区配置/状态、选区范围读写(setAreaSelection/getAreaSelection)、复制处理、选择/取消等。
31
- */
32
- function useAreaSelectionImpl<DT extends Record<string, any>>(
33
- props: any,
34
- emits: any,
35
- tableContainerRef: Ref<HTMLDivElement | undefined>,
36
- dataSourceCopy: ShallowRef<DT[]>,
37
- tableHeaderLast: ShallowRef<StkTableColumn<DT>[]>,
38
- colKeyGen: ColKeyGen,
39
- cellKeyGen: CellKeyGen,
40
- scrollTo: (top: number | null, left: number | null) => void,
41
- virtualScroll: Ref<VirtualScrollStore>,
42
- virtualScrollX: Ref<VirtualScrollXStore>,
43
- getRowIndex: (row: DT) => number,
44
- getColumnIndex: (col: StkTableColumn<DT>) => number,
45
- ) {
46
- /**
47
- * 自动滚动:鼠标距容器边缘多少px开始触发
48
- * en: Mouse distance from container edge to start auto scroll
49
- */
50
- const EDGE_ZONE = 40;
51
- /**
52
- * 自动滚动:每帧最大滚动像素
53
- * en: Maximum scroll pixels per frame
54
- */
55
- const SCROLL_SPEED_MAX = 15;
56
- const POINT_EDGE_OFFSET = 2;
57
-
58
- const KEY_ARROW_UP = 'ArrowUp';
59
- const KEY_ARROW_DOWN = 'ArrowDown';
60
- const KEY_ARROW_LEFT = 'ArrowLeft';
61
- const KEY_ARROW_RIGHT = 'ArrowRight';
62
- const KEY_TAB = 'Tab';
63
- const KEY_ESCAPE = 'Escape';
64
- const KEY_ESC = 'Esc';
65
- const KEY_C = 'c';
66
-
67
- // 选区样式通过 data-* 属性(而非 classList)应用。
68
- // 原因:合并单元格的 td 存在响应式 class 绑定(cell-hover/cell-active),
69
- // hover 时 Vue 的 patchClass 会用 `el.className = value` 整体覆盖 className,
70
- // 从而抹掉命令式添加的选区 class,导致选区高亮/边框在鼠标经过合并单元格时消失。
71
- // data-* 属性不在 vnode props 中,Vue 不会清除它们,可稳定保留选区样式。
72
- // en: Apply selection styles via data-* attributes instead of classList, so Vue's
73
- // reactive class patching on merged cells (cell-hover/cell-active) won't wipe them.
74
- // const CELL_RANGE_SELECTED = 'cell-range-selected';
75
- // const CELL_RANGE_TOP = 'cell-range-t';
76
- // const CELL_RANGE_BOTTOM = 'cell-range-b';
77
- // const CELL_RANGE_LEFT = 'cell-range-l';
78
- // const CELL_RANGE_RIGHT = 'cell-range-r';
79
- // const ROW_RANGE_SELECTED = 'row-range-selected';
80
- const ATTR_CELL_SELECTED = 'data-cs-s';
81
- const ATTR_CELL_TOP = 'data-cs-t';
82
- const ATTR_CELL_BOTTOM = 'data-cs-b';
83
- const ATTR_CELL_LEFT = 'data-cs-l';
84
- const ATTR_CELL_RIGHT = 'data-cs-r';
85
- const ATTR_ROW_SELECTED = 'data-rs-s';
86
-
87
- const selectionRanges = ref<AreaSelectionRange[]>([]);
88
- const isSelecting = ref(false);
89
- /** start cell */
90
- let anchorCell: { rowIndex: number; colIndex: number } | null = null;
91
-
92
- /** auto scroll rAF id */
93
- let autoScrollRafId = 0;
94
- /**
95
- * 最后一次鼠标位置(用于边界自动滚动计算)
96
- * en: Last mouse position (for boundary auto scroll calculation)
97
- */
98
- let lastMouseClientX = 0;
99
- let lastMouseClientY = 0;
100
-
101
- const config = computed<AreaSelectionConfig>(() => {
102
- if (typeof props.areaSelection === 'boolean') {
103
- const b = props.areaSelection;
104
- return { enabled: b, keyboard: b, ctrl: b, shift: b, highlight: { cell: b, row: false } };
105
- }
106
- const { highlight: userHighlight, ...restConfig } = props.areaSelection || {};
107
- return {
108
- enabled: true,
109
- ctrl: true,
110
- shift: true,
111
- highlight: {
112
- cell: true,
113
- row: false,
114
- ...userHighlight,
115
- },
116
- ...restConfig,
117
- };
118
- });
119
-
120
- /** 是否启用键盘控制选区移动 */
121
- const keyboardEnabled = computed(() => config.value.keyboard);
122
-
123
- /** 是否启用 Ctrl 多选 */
124
- const ctrlEnabled = computed(() => config.value.ctrl);
125
-
126
- /** 是否启用 Shift 扩选 */
127
- const shiftEnabled = computed(() => config.value.shift);
128
-
129
- /** 是否启用单元格高亮 */
130
- const highlightCellEnabled = computed(() => config.value.highlight?.cell);
131
-
132
- /** 是否启用行高亮 */
133
- const highlightRowEnabled = computed(() => config.value.highlight?.row);
134
-
135
- /** colKey → absolute index 映射 */
136
- const colKeyToIndexMap = computed(() => {
137
- const headers = tableHeaderLast.value;
138
- const map = new Map<UniqKey, number>();
139
- for (let i = 0; i < headers.length; i++) {
140
- map.set(colKeyGen.value(headers[i]), i);
141
- }
142
- return map;
143
- });
144
-
145
- /**
146
- * 获取固定列宽度的函数
147
- * 缓存每个固定列位置的累计宽度,查询时直接返回
148
- * @param colIndex 目标列索引
149
- * @returns [leftFixedWidth, rightFixedWidth]
150
- */
151
- const getFixedColWidths = computed(() => {
152
- const cols = tableHeaderLast.value;
153
- // 预计算每个列索引位置对应的左右固定列累计宽度
154
- // 左固定列:从左向右累加,非固定列继承前一个值
155
- const leftWidths: number[] = new Array(cols.length + 1).fill(0);
156
- // 右固定列:从右向左累加,非固定列继承后一个值
157
- const rightWidths: number[] = new Array(cols.length + 1).fill(0);
158
-
159
- let leftSum = 0;
160
- for (let i = 0; i < cols.length; i++) {
161
- leftWidths[i] = leftSum;
162
- if (cols[i]?.fixed === 'left') {
163
- leftSum += getCalculatedColWidth(cols[i]);
164
- }
165
- }
166
- leftWidths[cols.length] = leftSum;
167
-
168
- let rightSum = 0;
169
- for (let i = cols.length - 1; i >= 0; i--) {
170
- rightWidths[i] = rightSum;
171
- if (cols[i]?.fixed === 'right') {
172
- rightSum += getCalculatedColWidth(cols[i]);
173
- }
174
- }
175
-
176
- return (colIndex: number) => {
177
- return [leftWidths[colIndex] ?? 0, rightWidths[colIndex + 1] ?? 0] as const;
178
- };
179
- });
180
-
181
- /** 根据 selectionRanges 计算所有选区内 cellKey 的并集(非响应式,用于 DOM 操作) */
182
- let selectedCellKeys: Set<string> = new Set();
183
-
184
- /** 重新计算 selectedCellKeys */
185
- function recomputeSelectedCellKeys() {
186
- const ranges = selectionRanges.value;
187
- if (!ranges.length) {
188
- selectedCellKeys = new Set();
189
- return;
190
- }
191
- const keys = new Set<string>();
192
- const cols = tableHeaderLast.value;
193
- const data = dataSourceCopy.value;
194
- for (const range of ranges) {
195
- const {
196
- begin: { row: r1, col: c1 },
197
- end: { row: r2, col: c2 },
198
- } = range.index;
199
- const [rStart, rEnd] = r1 < r2 ? [r1, r2] : [r2, r1];
200
- const [cStart, cEnd] = c1 < c2 ? [c1, c2] : [c2, c1];
201
- for (let r = rStart; r <= rEnd; r++) {
202
- const row = data[r];
203
- if (!row) continue;
204
- for (let c = cStart; c <= cEnd; c++) {
205
- const col = cols[c];
206
- if (col) keys.add(cellKeyGen(row, col));
207
- }
208
- }
209
- }
210
- selectedCellKeys = keys;
211
- }
212
-
213
- /**
214
- * 直接操作 DOM 更新选区样式(不触发 Vue re-render)
215
- * 用于替代在渲染函数中读取响应式选区数据,避免拖选时频繁触发整个表格重渲染
216
- */
217
- function updateSelectionDOM() {
218
- const container = tableContainerRef.value;
219
- if (!container) return;
220
-
221
- const cellHighlight = highlightCellEnabled.value;
222
- const rowHighlight = highlightRowEnabled.value;
223
-
224
- // 1. 清除所有旧的选区属性
225
- const oldSelectedCells = container.querySelectorAll(`[${ATTR_CELL_SELECTED}]`);
226
- for (let i = 0; i < oldSelectedCells.length; i++) {
227
- const el = oldSelectedCells[i] as HTMLElement;
228
- el.removeAttribute(ATTR_CELL_SELECTED);
229
- el.removeAttribute(ATTR_CELL_TOP);
230
- el.removeAttribute(ATTR_CELL_BOTTOM);
231
- el.removeAttribute(ATTR_CELL_LEFT);
232
- el.removeAttribute(ATTR_CELL_RIGHT);
233
- }
234
- const oldSelectedRows = container.querySelectorAll(`[${ATTR_ROW_SELECTED}]`);
235
- for (let i = 0; i < oldSelectedRows.length; i++) {
236
- (oldSelectedRows[i] as HTMLElement).removeAttribute(ATTR_ROW_SELECTED);
237
- }
238
-
239
- // 2. 重算 selectedCellKeys
240
- recomputeSelectedCellKeys();
241
-
242
- const ranges = selectionRanges.value;
243
- if (!ranges.length) return;
244
-
245
- const tbody = container.querySelector('.stk-tbody-main');
246
- if (!tbody) return;
247
-
248
- // 3. 应用行高亮 class
249
- if (rowHighlight) {
250
- for (const range of ranges) {
251
- const { minRow, maxRow } = normalizeRange(range);
252
- for (let r = minRow; r <= maxRow; r++) {
253
- const tr = tbody.querySelector(`tr[data-row-i="${r}"]`) as HTMLElement | null;
254
- if (tr) tr.setAttribute(ATTR_ROW_SELECTED, '');
255
- }
256
- }
257
- }
258
-
259
- // 4. 应用单元格高亮 class
260
- if (cellHighlight) {
261
- const lastRange = ranges[ranges.length - 1];
262
- const { minRow: lrMinRow, maxRow: lrMaxRow, minCol: lrMinCol, maxCol: lrMaxCol } = normalizeRange(lastRange);
263
-
264
- // 遍历所有可见行
265
- const trs = tbody.querySelectorAll('tr[data-row-i]');
266
- for (let t = 0; t < trs.length; t++) {
267
- const tr = trs[t] as HTMLElement;
268
- const rowIndex = parseInt(tr.getAttribute('data-row-i')!, 10);
269
-
270
- // 检查此行是否在任何选区内
271
- let inAnyRange = false;
272
- for (const range of ranges) {
273
- const { minRow, maxRow } = normalizeRange(range);
274
- if (rowIndex >= minRow && rowIndex <= maxRow) {
275
- inAnyRange = true;
276
- break;
277
- }
278
- }
279
- if (!inAnyRange) continue;
280
-
281
- // 遍历此行中的单元格
282
- const tds = tr.querySelectorAll('td[data-col-key]');
283
- for (let d = 0; d < tds.length; d++) {
284
- const td = tds[d] as HTMLElement;
285
- const colKey = td.getAttribute('data-col-key')!;
286
- const colIndex = colKeyToIndexMap.value.get(colKey);
287
- if (colIndex === void 0 || colIndex < 0) continue;
288
-
289
- // 生成 cellKey 检查是否在选区内
290
- const data = dataSourceCopy.value;
291
- const row = data[rowIndex];
292
- const cols = tableHeaderLast.value;
293
- if (!row || !cols[colIndex]) continue;
294
- const ck = cellKeyGen(row, cols[colIndex]);
295
- if (!selectedCellKeys.has(ck)) continue;
296
-
297
- td.setAttribute(ATTR_CELL_SELECTED, '');
298
-
299
- // 判断是否在最后一个区域的边界(考虑合并单元格的 rowspan/colspan)
300
- const isInLastRange = rowIndex >= lrMinRow && rowIndex <= lrMaxRow && colIndex >= lrMinCol && colIndex <= lrMaxCol;
301
- if (isInLastRange) {
302
- // 合并单元格的实际结束行/列
303
- const effEndRow = rowIndex + (parseInt(td.getAttribute('rowspan') || '1', 10) || 1) - 1;
304
- const effEndCol = colIndex + (parseInt(td.getAttribute('colspan') || '1', 10) || 1) - 1;
305
- if (rowIndex === lrMinRow) td.setAttribute(ATTR_CELL_TOP, '');
306
- if (effEndRow === lrMaxRow) td.setAttribute(ATTR_CELL_BOTTOM, '');
307
- if (colIndex === lrMinCol) td.setAttribute(ATTR_CELL_LEFT, '');
308
- if (effEndCol === lrMaxCol) td.setAttribute(ATTR_CELL_RIGHT, '');
309
- }
310
- }
311
- }
312
- }
313
- }
314
-
315
- /**
316
- * 监听 selectionRanges 变化,在 DOM 更新后直接操作选区 class
317
- * 同时在虚拟滚动等导致 DOM 重建时也能重新应用选区样式
318
- */
319
- watch(
320
- () => {
321
- // 组合依赖:选区变化 或 虚拟滚动可见范围变化 都触发
322
- const ranges = selectionRanges.value;
323
- const vs = virtualScroll.value;
324
- const vsx = virtualScrollX.value;
325
- return [
326
- ranges.length,
327
- ranges.length > 0 ? JSON.stringify(ranges.map(r => r.index)) : '',
328
- vsx.scrollLeft,
329
- vs.startIndex,
330
- vs.endIndex,
331
- vsx.startIndex,
332
- vsx.endIndex,
333
- dataSourceCopy.value.length,
334
- tableHeaderLast.value.length,
335
- ];
336
- },
337
- () => {
338
- // flush: 'post' 等效 — watch callback 在 DOM patch 后执行
339
- nextTick(updateSelectionDOM);
340
- },
341
- { flush: 'post' },
342
- );
343
-
344
- onMounted(() => {
345
- addListener();
346
- });
347
- onBeforeUnmount(() => {
348
- removeListener();
349
- });
350
-
351
- /**
352
- * 监听数据行数/列数变化,当行列变少时钳制选区与锚点,避免越界
353
- * en: Watch row/col count changes, clamp selection ranges and anchor to avoid out-of-bounds
354
- */
355
- watch([() => dataSourceCopy.value.length, () => tableHeaderLast.value.length], ([rowCount, colCount]) => {
356
- if (!config.value.enabled) return;
357
-
358
- // 钳制锚点
359
- if (anchorCell) {
360
- if (rowCount === 0 || colCount === 0) {
361
- anchorCell = null;
362
- } else {
363
- anchorCell.rowIndex = clamp(anchorCell.rowIndex, 0, rowCount - 1);
364
- anchorCell.colIndex = clamp(anchorCell.colIndex, 0, colCount - 1);
365
- }
366
- }
367
-
368
- if (!selectionRanges.value.length) return;
369
-
370
- // 行或列为 0 时清空选区
371
- if (rowCount === 0 || colCount === 0) {
372
- clearSelectedArea();
373
- emitSelectionChange();
374
- return;
375
- }
376
-
377
- const maxRow = rowCount - 1;
378
- const maxCol = colCount - 1;
379
- let changed = false;
380
- const newRanges: AreaSelectionRange[] = [];
381
- for (const range of selectionRanges.value) {
382
- const { begin, end } = range.index;
383
- const nbRow = clamp(begin.row, 0, maxRow);
384
- const nbCol = clamp(begin.col, 0, maxCol);
385
- const neRow = clamp(end.row, 0, maxRow);
386
- const neCol = clamp(end.col, 0, maxCol);
387
- if (nbRow !== begin.row || nbCol !== begin.col || neRow !== end.row || neCol !== end.col) {
388
- changed = true;
389
- newRanges.push(makeRange(nbRow, nbCol, neRow, neCol));
390
- } else {
391
- newRanges.push(range);
392
- }
393
- }
394
-
395
- if (changed) {
396
- selectionRanges.value = newRanges;
397
- emitSelectionChange();
398
- }
399
- });
400
-
401
- function addListener() {
402
- removeListener();
403
- tableContainerRef.value?.addEventListener('keydown', onKeydown);
404
- }
405
-
406
- function removeListener() {
407
- tableContainerRef.value?.removeEventListener('keydown', onKeydown);
408
- document.removeEventListener('mousemove', onDocumentMouseMove);
409
- document.removeEventListener('mouseup', onDocumentMouseUp);
410
- stopAutoScroll();
411
- }
412
-
413
- /** 获取归一化(min/max)后的选区范围 */
414
- function normalizeRange(range: AreaSelectionRange) {
415
- const { begin, end } = range.index;
416
- return {
417
- minRow: Math.min(begin.row, end.row),
418
- maxRow: Math.max(begin.row, end.row),
419
- minCol: Math.min(begin.col, end.col),
420
- maxCol: Math.max(begin.col, end.col),
421
- };
422
- }
423
-
424
- /**
425
- * 构造选区范围。begin = 拖拽起点,end = 拖拽终点。
426
- * 同时填充已废弃的 x/y 字段以保证向后兼容。
427
- */
428
- function makeRange(beginRow: number, beginCol: number, endRow: number, endCol: number): AreaSelectionRange {
429
- return {
430
- index: {
431
- x: [beginCol, endCol],
432
- y: [beginRow, endRow],
433
- begin: { row: beginRow, col: beginCol },
434
- end: { row: endRow, col: endCol },
435
- },
436
- };
437
- }
438
-
439
- /** 根据colKey获取列的绝对索引 */
440
- function getColIndexByKey(colKey: string | undefined): number {
441
- if (!colKey) return -1;
442
- return colKeyToIndexMap.value.get(colKey) ?? -1;
443
- }
444
-
445
- // ---- 合并单元格支持 ----
446
-
447
- /** 获取指定单元格的合并信息 [rowspan, colspan],无合并返回 [1, 1] */
448
- function getMergeSpan(rowIndex: number, colIndex: number): [number, number] {
449
- const data = dataSourceCopy.value;
450
- const cols = tableHeaderLast.value;
451
- const row = data[rowIndex];
452
- const col = cols[colIndex];
453
- if (!row || !col || !col.mergeCells) return [1, 1];
454
- const { rowspan = 1, colspan = 1 } = col.mergeCells({ row, col, rowIndex, colIndex }) || {};
455
- return [rowspan || 1, colspan || 1];
456
- }
457
-
458
- /**
459
- * 扩展选区范围,使其完整覆盖边界上部分选中的合并单元格
460
- * en: Expand selection range to fully cover merged cells that are partially selected at boundaries
461
- */
462
- function expandRangeToCoverMergedCells(range: AreaSelectionRange): AreaSelectionRange {
463
- const { minRow, maxRow, minCol, maxCol } = normalizeRange(range);
464
- const data = dataSourceCopy.value;
465
- const cols = tableHeaderLast.value;
466
- const rowCount = data.length;
467
- const colCount = cols.length;
468
-
469
- // 预计算含 mergeCells 的列索引
470
- const mergeColIndices: number[] = [];
471
- for (let c = 0; c < colCount; c++) {
472
- if (cols[c]?.mergeCells) mergeColIndices.push(c);
473
- }
474
- if (!mergeColIndices.length) return range;
475
-
476
- let [eMinRow, eMaxRow, eMinCol, eMaxCol] = [minRow, maxRow, minCol, maxCol];
477
-
478
- // 迭代扩展直到稳定(级联合并需要多轮)
479
- let changed = true;
480
- let guard = 0;
481
- while (changed && guard++ < 100) {
482
- changed = false;
483
-
484
- // 下边界:最后一行中的单元格 rowspan 是否超出边界
485
- for (const c of mergeColIndices) {
486
- if (c < eMinCol || c > eMaxCol) continue;
487
- const [rs] = getMergeSpan(eMaxRow, c);
488
- if (rs > 1 && eMaxRow + rs - 1 < rowCount && eMaxRow + rs - 1 > eMaxRow) {
489
- eMaxRow = eMaxRow + rs - 1;
490
- changed = true;
491
- }
492
- }
493
-
494
- // 右边界:最后一列中的单元格 colspan 是否超出边界
495
- for (let r = eMinRow; r <= eMaxRow; r++) {
496
- const [, cs] = getMergeSpan(r, eMaxCol);
497
- if (cs > 1 && eMaxCol + cs - 1 < colCount && eMaxCol + cs - 1 > eMaxCol) {
498
- eMaxCol = eMaxCol + cs - 1;
499
- changed = true;
500
- }
501
- }
502
-
503
- // 上边界:检查 minRow 上方的单元格是否延伸进选区
504
- for (const c of mergeColIndices) {
505
- if (c < eMinCol || c > eMaxCol) continue;
506
- for (let r = eMinRow - 1; r >= 0 && r > eMinRow - 500; r--) {
507
- const [rs] = getMergeSpan(r, c);
508
- if (rs <= 1) continue;
509
- const endRow = r + rs - 1;
510
- if (endRow >= eMinRow) {
511
- // 合并单元格延伸进选区,扩展上边界
512
- if (r < eMinRow) {
513
- eMinRow = r;
514
- changed = true;
515
- }
516
- } else {
517
- // 该合并单元格未到达当前边界,更上方的也不会到达,停止扫描此列
518
- break;
519
- }
520
- }
521
- }
522
-
523
- // 左边界:检查 minCol 左侧的单元格是否延伸进选区
524
- for (let r = eMinRow; r <= eMaxRow; r++) {
525
- for (let c = eMinCol - 1; c >= 0 && c > eMinCol - 500; c--) {
526
- const [, cs] = getMergeSpan(r, c);
527
- if (cs <= 1) continue;
528
- const endCol = c + cs - 1;
529
- if (endCol >= eMinCol) {
530
- if (c < eMinCol) {
531
- eMinCol = c;
532
- changed = true;
533
- }
534
- } else {
535
- break;
536
- }
537
- }
538
- }
539
- }
540
-
541
- if (eMinRow === minRow && eMaxRow === maxRow && eMinCol === minCol && eMaxCol === maxCol) {
542
- return range;
543
- }
544
-
545
- // 保持 begin/end 的原始方向
546
- const { begin, end } = range.index;
547
- const newBeginRow = begin.row < end.row || begin.row === end.row ? eMinRow : eMaxRow;
548
- const newEndRow = begin.row < end.row || begin.row === end.row ? eMaxRow : eMinRow;
549
- const newBeginCol = begin.col <= end.col ? eMinCol : eMaxCol;
550
- const newEndCol = begin.col <= end.col ? eMaxCol : eMinCol;
551
- return makeRange(newBeginRow, newBeginCol, newEndRow, newEndCol);
552
- }
553
-
554
- /** 获取列的左边距和宽度
555
- * @param colIndex 列的绝对索引
556
- * @returns [left, width]
557
- */
558
- function getColPosition(colIndex: number): [number, number] {
559
- let left = 0;
560
- const cols = tableHeaderLast.value;
561
- for (let i = 0; i < cols.length; i++) {
562
- const colWidth = getCalculatedColWidth(cols[i]);
563
- if (i === colIndex) return [left, colWidth];
564
- left += colWidth;
565
- }
566
- return [left, 0];
567
- }
568
-
569
- /** 根据按键计算移动方向 */
570
- function getMovementDelta(key: string, shiftKey: boolean): [number, number] {
571
- let rowDelta = 0;
572
- let colDelta = 0;
573
-
574
- switch (key) {
575
- case KEY_ARROW_UP:
576
- rowDelta = -1;
577
- break;
578
- case KEY_ARROW_DOWN:
579
- rowDelta = 1;
580
- break;
581
- case KEY_ARROW_LEFT:
582
- colDelta = -1;
583
- break;
584
- case KEY_ARROW_RIGHT:
585
- colDelta = 1;
586
- break;
587
- case KEY_TAB:
588
- // Tab: right; Shift+Tab: left
589
- colDelta = shiftKey ? -1 : 1;
590
- break;
591
- }
592
-
593
- return [rowDelta, colDelta];
594
- }
595
-
596
- /** 钳制值到指定范围内 */
597
- function clamp(value: number, min: number, max: number): number {
598
- return Math.max(min, Math.min(value, max));
599
- }
600
-
601
- /** 处理Tab键的换行逻辑 */
602
- function handleTabWrap(row: number, col: number, rawCol: number, rowCount: number, colCount: number): [number, number] {
603
- if (rawCol >= colCount) return [Math.min(row + 1, rowCount - 1), 0];
604
- if (rawCol < 0) return [Math.max(row - 1, 0), colCount - 1];
605
- return [row, col];
606
- }
607
-
608
- /** 计算自动滚动的增量 */
609
- function calculateAutoScrollDelta(mouseX: number, mouseY: number, rect: DOMRect): { deltaX: number; deltaY: number } {
610
- const { top, bottom, left, right } = rect;
611
- let deltaX = 0;
612
- let deltaY = 0;
613
-
614
- // Y方向
615
- if (mouseY < top + EDGE_ZONE) {
616
- const dist = Math.max(0, top + EDGE_ZONE - mouseY);
617
- deltaY = -Math.ceil((dist / EDGE_ZONE) * SCROLL_SPEED_MAX);
618
- } else if (mouseY > bottom - EDGE_ZONE) {
619
- const dist = Math.max(0, mouseY - (bottom - EDGE_ZONE));
620
- deltaY = Math.ceil((dist / EDGE_ZONE) * SCROLL_SPEED_MAX);
621
- }
622
-
623
- // X方向
624
- if (mouseX < left + EDGE_ZONE) {
625
- const dist = Math.max(0, left + EDGE_ZONE - mouseX);
626
- deltaX = -Math.ceil((dist / EDGE_ZONE) * SCROLL_SPEED_MAX);
627
- } else if (mouseX > right - EDGE_ZONE) {
628
- const dist = Math.max(0, mouseX - (right - EDGE_ZONE));
629
- deltaX = Math.ceil((dist / EDGE_ZONE) * SCROLL_SPEED_MAX);
630
- }
631
-
632
- return { deltaX, deltaY };
633
- }
634
-
635
- /** mousedown 处理:设置锚点,开始拖选 */
636
- function onSelectionMouseDown(e: MouseEvent) {
637
- if (!config.value.enabled || e.button !== 0) return;
638
-
639
- const rowIndex = getClosestTrIndex(e.target as HTMLElement);
640
- const colKey = getClosestColKey(e.target as HTMLElement);
641
- const colIndex = getColIndexByKey(colKey);
642
-
643
- if (rowIndex < 0 || colIndex < 0) return;
644
-
645
- const ctrlKey = e.ctrlKey || e.metaKey;
646
-
647
- // 立即扩展以完整覆盖合并单元格(修复 mousedown 合并单元格时边框显示不全)
648
- const range: AreaSelectionRange = expandRangeToCoverMergedCells(makeRange(rowIndex, colIndex, rowIndex, colIndex));
649
- // Shift 扩选:从锚点扩展到当前位置,更新最后一个区域
650
- if (e.shiftKey && anchorCell && shiftEnabled.value) {
651
- const ranges = selectionRanges.value.slice();
652
- const shiftRange: AreaSelectionRange = expandRangeToCoverMergedCells(
653
- makeRange(anchorCell.rowIndex, anchorCell.colIndex, rowIndex, colIndex),
654
- );
655
- if (ranges.length) {
656
- ranges[ranges.length - 1] = shiftRange;
657
- } else {
658
- ranges.push(shiftRange);
659
- }
660
- selectionRanges.value = ranges;
661
- } else {
662
- anchorCell = { rowIndex, colIndex };
663
- if (ctrlKey && ctrlEnabled.value) {
664
- // Ctrl multiple
665
- selectionRanges.value = selectionRanges.value.concat([range]);
666
- } else {
667
- // normal click
668
- selectionRanges.value = [range];
669
- }
670
- }
671
-
672
- isSelecting.value = true;
673
- lastMouseClientX = e.clientX;
674
- lastMouseClientY = e.clientY;
675
-
676
- // 防止拖选时选中文字
677
- // en: Prevent text selection during drag
678
- document.addEventListener('mousemove', onDocumentMouseMove);
679
- document.addEventListener('mouseup', onDocumentMouseUp);
680
- }
681
-
682
- /** document mousemove 处理:更新选区终点 + 检测边界自动滚动 */
683
- function onDocumentMouseMove(e: MouseEvent) {
684
- if (!isSelecting.value) return;
685
-
686
- lastMouseClientX = e.clientX;
687
- lastMouseClientY = e.clientY;
688
-
689
- // 尝试从当前鼠标位置更新选区
690
- updateSelectionFromEvent(e);
691
-
692
- // 检测是否需要边界自动滚动
693
- checkAutoScroll();
694
- }
695
-
696
- /** 从 MouseEvent 目标元素更新选区 */
697
- function updateSelectionFromEvent(e: MouseEvent) {
698
- const target = e.target as HTMLElement;
699
- if (!target) return;
700
-
701
- const rowIndex = getClosestTrIndex(target);
702
- if (Number.isNaN(rowIndex) || rowIndex < 0) return;
703
-
704
- const colKey = getClosestColKey(target);
705
- const colIndex = getColIndexByKey(colKey);
706
- if (colIndex < 0) return;
707
-
708
- updateSelectionEnd(rowIndex, colIndex);
709
- }
710
-
711
- /** 更新最后一个选区的终点(拖拽过程中) */
712
- function updateSelectionEnd(endRowIndex: number, endColIndex: number) {
713
- if (!anchorCell) return;
714
- // 拖拽过程中即扩展,使选区实时覆盖合并单元格的完整边界
715
- const newRange: AreaSelectionRange = expandRangeToCoverMergedCells(
716
- makeRange(anchorCell.rowIndex, anchorCell.colIndex, endRowIndex, endColIndex),
717
- );
718
- const ranges = [...selectionRanges.value];
719
- if (ranges.length > 0) {
720
- ranges[ranges.length - 1] = newRange;
721
- } else {
722
- ranges.push(newRange);
723
- }
724
- selectionRanges.value = ranges;
725
- }
726
-
727
- // ---- 边界自动滚动 ----
728
-
729
- /** 检查鼠标是否在容器边缘附近,启动或停止自动滚动 */
730
- function checkAutoScroll() {
731
- const container = tableContainerRef.value;
732
- if (!container) return;
733
-
734
- const rect = container.getBoundingClientRect();
735
- const { top, bottom, left, right } = rect;
736
-
737
- const nearEdge =
738
- lastMouseClientY < top + EDGE_ZONE ||
739
- lastMouseClientY > bottom - EDGE_ZONE ||
740
- lastMouseClientX < left + EDGE_ZONE ||
741
- lastMouseClientX > right - EDGE_ZONE;
742
-
743
- if (nearEdge && !autoScrollRafId) {
744
- autoScrollLoop();
745
- } else if (!nearEdge && autoScrollRafId) {
746
- stopAutoScroll();
747
- }
748
- }
749
-
750
- /** rAF 循环:边界自动滚动 + 更新选区 */
751
- function autoScrollLoop() {
752
- const container = tableContainerRef.value;
753
- if (!container || !isSelecting.value) {
754
- stopAutoScroll();
755
- return;
756
- }
757
-
758
- const rect = container.getBoundingClientRect();
759
- const { deltaX, deltaY } = calculateAutoScrollDelta(lastMouseClientX, lastMouseClientY, rect);
760
-
761
- if (deltaX !== 0 || deltaY !== 0) {
762
- container.scrollTop += deltaY;
763
- container.scrollLeft += deltaX;
764
-
765
- // 滚动后,在容器内边缘处用 elementFromPoint 找到当前单元格更新选区
766
- updateSelectionFromPoint(container, rect);
767
- }
768
-
769
- // 如果还在拖选且仍需滚动,继续循环
770
- if (isSelecting.value && (deltaX !== 0 || deltaY !== 0)) {
771
- autoScrollRafId = requestAnimationFrame(autoScrollLoop);
772
- } else {
773
- autoScrollRafId = 0;
774
- }
775
- }
776
-
777
- /** 将鼠标位置钳制到容器内部,用 elementFromPoint 找到边缘单元格并更新选区 */
778
- function updateSelectionFromPoint(container: HTMLElement, containerRect: DOMRect) {
779
- // 获取表头高度,钳制 Y 时跳过表头区域
780
- const thead = container.querySelector('thead');
781
- const { top, bottom, left, right } = containerRect;
782
-
783
- const headerBottom = thead ? top + thead.offsetHeight : top;
784
-
785
- const x = Math.max(left + POINT_EDGE_OFFSET, Math.min(lastMouseClientX, right - POINT_EDGE_OFFSET));
786
- const y = Math.max(headerBottom + POINT_EDGE_OFFSET, Math.min(lastMouseClientY, bottom - POINT_EDGE_OFFSET));
787
-
788
- const el = document.elementFromPoint(x, y);
789
- if (!el) return;
790
-
791
- const td = getClosestTd(el as HTMLElement);
792
- const tr = getClosestTr(el as HTMLElement);
793
- if (!td || !tr) return;
794
-
795
- const rowIndex = getClosestTrIndex(tr);
796
- const colKey = getClosestColKey(td);
797
- const colIndex = getColIndexByKey(colKey);
798
-
799
- if (Number.isNaN(rowIndex) || rowIndex < 0 || colIndex < 0) return;
800
-
801
- updateSelectionEnd(rowIndex, colIndex);
802
- }
803
-
804
- /** 停止自动滚动 */
805
- function stopAutoScroll() {
806
- if (autoScrollRafId) {
807
- cancelAnimationFrame(autoScrollRafId);
808
- autoScrollRafId = 0;
809
- }
810
- }
811
-
812
- /** document mouseup 处理:结束拖选 */
813
- function onDocumentMouseUp() {
814
- if (!isSelecting.value) return;
815
- isSelecting.value = false;
816
- stopAutoScroll();
817
-
818
- document.removeEventListener('mousemove', onDocumentMouseMove);
819
- document.removeEventListener('mouseup', onDocumentMouseUp);
820
-
821
- // 扩展最后一个选区,使其完整覆盖合并单元格
822
- const ranges = selectionRanges.value;
823
- if (ranges.length) {
824
- const expanded = expandRangeToCoverMergedCells(ranges[ranges.length - 1]);
825
- if (expanded !== ranges[ranges.length - 1]) {
826
- const newRanges = [...ranges];
827
- newRanges[newRanges.length - 1] = expanded;
828
- selectionRanges.value = newRanges;
829
- }
830
- }
831
-
832
- // 发出事件
833
- emitSelectionChange();
834
- }
835
-
836
- function emitSelectionChange() {
837
- emits('area-selection-change', selectionRanges.value);
838
- }
839
-
840
- /** 获取 areaSelection 配置中的格式化回调 */
841
- function getFormatCellFn() {
842
- const cfg = config.value;
843
- return typeof cfg.formatCellForClipboard === 'function' ? cfg.formatCellForClipboard : null;
844
- }
845
-
846
- /**
847
- * 复制选区内容到剪贴板,只复制最后一个选区
848
- * en: Copy selected area content to clipboard, only copy the last selected area
849
- * @returns {string} text
850
- */
851
- function copySelectedArea(): string {
852
- const ranges = selectionRanges.value;
853
- if (!ranges.length) return '';
854
-
855
- const range = ranges[ranges.length - 1];
856
- const { minRow, maxRow, minCol, maxCol } = normalizeRange(range);
857
- const data = dataSourceCopy.value;
858
- const cols = tableHeaderLast.value;
859
- const formatCell = getFormatCellFn();
860
-
861
- const lines: string[] = [];
862
- for (let r = minRow; r <= maxRow; r++) {
863
- const row = data[r];
864
- if (!row) continue;
865
-
866
- const cells: string[] = [];
867
- for (let c = minCol; c <= maxCol; c++) {
868
- const col = cols[c];
869
- if (!col) {
870
- cells.push('');
871
- continue;
872
- }
873
- const rawValue = row[col.dataIndex];
874
- cells.push(formatCell ? formatCell(row, col, rawValue) : !rawValue ? '' : String(rawValue));
875
- }
876
- lines.push(cells.join('\t'));
877
- }
878
- const text = lines.join('\n');
879
-
880
- navigator.clipboard.writeText(text).catch(() => {
881
- console.warn('Failed to copy to clipboard');
882
- });
883
-
884
- return text;
885
- }
886
-
887
- function blurCellElement() {
888
- // 防止虚拟滚动移除 DOM 时焦点被动丢失,导致后续 keydown 无法冒泡到容器
889
- // en: Prevent focus loss when virtual scroll removes DOM, causing subsequent keydown events to not bubble to the container
890
- const container = tableContainerRef.value;
891
- const activeEl = document.activeElement as HTMLElement | null;
892
- if (container && activeEl && container.contains(activeEl) && activeEl !== container) {
893
- container.focus({ preventScroll: true });
894
- }
895
- }
896
-
897
- /**
898
- * Ctrl+C / Cmd+C copy
899
- * Esc :cancel
900
- * Arrow keys / Tab move (when keyboard=true)
901
- **/
902
- function onKeydown(e: KeyboardEvent) {
903
- if (!config.value.enabled) return;
904
-
905
- const key = e.key;
906
-
907
- // Esc :cancel
908
- if (key === KEY_ESCAPE || key === KEY_ESC) {
909
- blurCellElement();
910
- if (selectionRanges.value.length) {
911
- // clearSelectedArea();
912
- // emitSelectionChange();
913
- e.preventDefault();
914
- }
915
- return;
916
- }
917
-
918
- // Ctrl/Cmd+C copy
919
- if ((e.ctrlKey || e.metaKey) && key === KEY_C && selectionRanges.value.length) {
920
- copySelectedArea();
921
- e.preventDefault();
922
- return;
923
- }
924
-
925
- if (!keyboardEnabled.value) return;
926
-
927
- const isArrowKey = [KEY_ARROW_UP, KEY_ARROW_DOWN, KEY_ARROW_LEFT, KEY_ARROW_RIGHT].includes(key);
928
- const isTabKey = key === KEY_TAB;
929
- const isNavigationKey = isArrowKey || isTabKey;
930
-
931
- if (!isNavigationKey) return;
932
-
933
- e.preventDefault();
934
-
935
- const rowCount = dataSourceCopy.value.length;
936
- const colCount = tableHeaderLast.value.length;
937
- if (rowCount === 0 || colCount === 0) return;
938
-
939
- // 如果没有选区,默认从第一个单元格开始
940
- // en: If no selection, start from the first cell
941
- if (!selectionRanges.value.length) {
942
- anchorCell = { rowIndex: 0, colIndex: 0 };
943
- selectionRanges.value = [makeRange(0, 0, 0, 0)];
944
- emitSelectionChange();
945
- scrollToCell(0, 0);
946
- return;
947
- }
948
-
949
- // 计算移动方向
950
- const [rowDelta, colDelta] = getMovementDelta(key, e.shiftKey);
951
-
952
- // Shift 扩展选区,否则移动单格选区
953
- if (e.shiftKey && isArrowKey && shiftEnabled.value) {
954
- blurCellElement();
955
- // 扩展选区:保留 begin,更新最后一个区域的 end
956
- const ranges = [...selectionRanges.value];
957
- const range = ranges.length > 0 ? ranges[ranges.length - 1] : null;
958
- if (!range) return;
959
- const { begin, end } = range.index;
960
- let newEndRow = end.row + rowDelta;
961
- let newEndCol = end.col + colDelta;
962
-
963
- // 边界检查
964
- newEndRow = clamp(newEndRow, 0, rowCount - 1);
965
- newEndCol = clamp(newEndCol, 0, colCount - 1);
966
-
967
- ranges[ranges.length - 1] = makeRange(begin.row, begin.col, newEndRow, newEndCol);
968
- selectionRanges.value = ranges;
969
-
970
- scrollToCell(newEndRow, newEndCol);
971
- } else {
972
- blurCellElement();
973
- // 移动单格选区
974
- // 取最后一个区域的 end 位置作为基础,清空旧选区重建
975
- const ranges = selectionRanges.value;
976
- const range = ranges.length > 0 ? ranges[ranges.length - 1] : null;
977
- const baseRow = range ? normalizeRange(range).minRow : 0;
978
- const baseCol = range ? normalizeRange(range).minCol : 0;
979
- let newRow = baseRow + rowDelta;
980
- let newCol = baseCol + colDelta;
981
-
982
- // 边界检查(先检查,避免越界)
983
- newRow = clamp(newRow, 0, rowCount - 1);
984
- newCol = clamp(newCol, 0, colCount - 1);
985
-
986
- // Tab 换行逻辑:如果到达行尾/行首,换行
987
- if (isTabKey) {
988
- // 计算原始未 clamp 的值
989
- const rawCol = baseCol + colDelta;
990
- const [tabRow, tabCol] = handleTabWrap(baseRow, newCol, rawCol, rowCount, colCount);
991
- newRow = tabRow;
992
- newCol = tabCol;
993
- }
994
-
995
- // 更新锚点和选区(移动单格时清空其他区域,仅保留新位置)
996
- anchorCell = { rowIndex: newRow, colIndex: newCol };
997
- selectionRanges.value = [makeRange(newRow, newCol, newRow, newCol)];
998
-
999
- scrollToCell(newRow, newCol);
1000
- }
1001
-
1002
- emitSelectionChange();
1003
- }
1004
-
1005
- /**
1006
- * 滚动到指定单元格,确保其在可视区域内
1007
- * @param rowIndex 行索引
1008
- * @param colIndex 列索引
1009
- */
1010
- function scrollToCell(rowIndex: number, colIndex: number) {
1011
- const container = tableContainerRef.value;
1012
- if (!container) return;
1013
-
1014
- const row = dataSourceCopy.value[rowIndex];
1015
- const col = tableHeaderLast.value[colIndex];
1016
- if (!row || !col) return;
1017
-
1018
- const thead = container.querySelector('thead');
1019
- const headerHeight = thead ? thead.offsetHeight : 0;
1020
- const tfoot = container.querySelector('tfoot');
1021
- const footerHeight = tfoot ? tfoot.offsetHeight : 0;
1022
-
1023
- const vs = virtualScroll.value;
1024
- const vsx = virtualScrollX.value;
1025
-
1026
- // 是否开启按行滚动模式(experimental.scrollY 模式)
1027
- const isScrollRowByRow = props.scrollRowByRow;
1028
-
1029
- // 计算目标行的位置(基于虚拟滚动数据)
1030
- const rowHeight = vs.rowHeight;
1031
- const targetRowTop = rowIndex * rowHeight;
1032
- const targetRowBottom = targetRowTop + rowHeight;
1033
-
1034
- // 计算可视区域
1035
- // experimental.scrollY 模式下,容器 scrollTop 始终为 0,需要使用 virtualScroll.scrollTop
1036
- const visibleTop = isScrollRowByRow ? vs.scrollTop : container.scrollTop;
1037
- const visibleBottom = visibleTop + vs.containerHeight - headerHeight - footerHeight;
1038
-
1039
- // 计算需要的垂直滚动位置
1040
- let newScrollTop: number | null = null;
1041
- if (targetRowTop < visibleTop) {
1042
- // 目标行在可视区域上方,滚动到使目标行位于顶部
1043
- newScrollTop = targetRowTop;
1044
- } else if (targetRowBottom > visibleBottom) {
1045
- // 目标行在可视区域下方
1046
- newScrollTop = targetRowBottom - (vs.containerHeight - headerHeight - footerHeight);
1047
- }
1048
-
1049
- // 计算目标列的位置
1050
- const [targetColLeft, targetColWidth] = getColPosition(colIndex);
1051
- const targetColRight = targetColLeft + targetColWidth;
1052
-
1053
- // 计算可视区域(水平)
1054
- const visibleLeft = container.scrollLeft;
1055
- const visibleRight = visibleLeft + vsx.containerWidth;
1056
-
1057
- // 计算固定列的宽度(用于检测遮挡)
1058
- const [leftFixedWidth, rightFixedWidth] = getFixedColWidths.value(colIndex);
1059
- let newScrollLeft: number | null = null;
1060
- if (targetColLeft < visibleLeft + leftFixedWidth) {
1061
- // 目标列在左侧固定列遮挡区域内,需要向左滚动
1062
- newScrollLeft = targetColLeft - leftFixedWidth;
1063
- } else if (targetColRight > visibleRight - rightFixedWidth) {
1064
- // 目标列在右侧固定列遮挡区域内,需要向右滚动
1065
- newScrollLeft = targetColRight - vsx.containerWidth + rightFixedWidth;
1066
- }
1067
-
1068
- if (newScrollTop !== null || newScrollLeft !== null) {
1069
- scrollTo(newScrollTop, newScrollLeft);
1070
- }
1071
- }
1072
-
1073
- /**
1074
- * 判断一个单元格的选区样式类名
1075
- * @param cellKey 单元格唯一键
1076
- * @param absoluteRowIndex 行在 dataSourceCopy 中的绝对索引
1077
- * @param colKey 列唯一键
1078
- * @returns 样式类名数组
1079
- */
1080
- // function getAreaSelectionClasses(cellKey: string, absoluteRowIndex: number, colKey: UniqKey): string[] {
1081
- // if (!highlightCellEnabled.value) return [];
1082
- // if (!selectedCellKeys.has(cellKey)) return [];
1083
-
1084
- // const colIndex = colKeyToIndexMap.value.get(colKey);
1085
- // if (colIndex === void 0 || colIndex < 0) return [];
1086
-
1087
- // const classes: string[] = [CELL_RANGE_SELECTED];
1088
- // const ranges = selectionRanges.value;
1089
- // if (!ranges.length) return classes;
1090
-
1091
- // const lastRange = ranges[ranges.length - 1];
1092
- // const { minRow, maxRow, minCol, maxCol } = normalizeRange(lastRange);
1093
-
1094
- // // 判断当前单元格是否在最后一个区域内
1095
- // const isInLastRange = absoluteRowIndex >= minRow && absoluteRowIndex <= maxRow && colIndex >= minCol && colIndex <= maxCol;
1096
-
1097
- // if (isInLastRange) {
1098
- // if (absoluteRowIndex === minRow) classes.push(CELL_RANGE_TOP);
1099
- // if (absoluteRowIndex === maxRow) classes.push(CELL_RANGE_BOTTOM);
1100
- // if (colIndex === minCol) classes.push(CELL_RANGE_LEFT);
1101
- // if (colIndex === maxCol) classes.push(CELL_RANGE_RIGHT);
1102
- // }
1103
-
1104
- // return classes;
1105
- // }
1106
-
1107
- /**
1108
- * 判断一行的选区样式类名(行高亮)
1109
- * @param absoluteRowIndex 行在 dataSourceCopy 中的绝对索引
1110
- * @returns 样式类名数组
1111
- */
1112
- // function getAreaSelectionRowClasses(absoluteRowIndex: number): string[] {
1113
- // if (!highlightRowEnabled.value) return [];
1114
-
1115
- // const ranges = selectionRanges.value;
1116
- // if (!ranges.length) return [];
1117
-
1118
- // // 检查该行是否在任何选区内
1119
- // for (const range of ranges) {
1120
- // const {
1121
- // begin: { row: r1 },
1122
- // end: { row: r2 },
1123
- // } = range.index;
1124
- // const [minRow, maxRow] = r1 < r2 ? [r1, r2] : [r2, r1];
1125
- // if (absoluteRowIndex >= minRow && absoluteRowIndex <= maxRow) {
1126
- // return ['row-range-selected'];
1127
- // }
1128
- // }
1129
-
1130
- // return [];
1131
- // }
1132
-
1133
- // expose function
1134
-
1135
- /** 获取选中的单元格信息 */
1136
- function getSelectedArea() {
1137
- const ranges = selectionRanges.value;
1138
- if (!ranges.length) return { rows: [] as DT[], cols: [] as StkTableColumn<DT>[], ranges: [] as AreaSelectionRange[] };
1139
- const data = dataSourceCopy.value;
1140
- const cols = tableHeaderLast.value;
1141
- // 收集所有区域的行和列
1142
- const rowSet = new Set<number>();
1143
- const colSet = new Set<number>();
1144
- for (const range of ranges) {
1145
- const { minRow, maxRow, minCol, maxCol } = normalizeRange(range);
1146
- for (let r = minRow; r <= maxRow; r++) rowSet.add(r);
1147
- for (let c = minCol; c <= maxCol; c++) colSet.add(c);
1148
- }
1149
- const sortedRows = [...rowSet].sort((a, b) => a - b);
1150
- const sortedCols = [...colSet].sort((a, b) => a - b);
1151
- return {
1152
- rows: sortedRows.map(i => data[i]).filter(Boolean),
1153
- cols: sortedCols.map(i => cols[i]).filter(Boolean),
1154
- ranges: ranges.map(r => ({ ...r })),
1155
- };
1156
- }
1157
-
1158
- function clearSelectedArea() {
1159
- selectionRanges.value = [];
1160
- isSelecting.value = false;
1161
- }
1162
-
1163
- function setAreaSelection(ranges?: AreaSelectionSetterRange<DT>, option: AreaSelectionSetterOption = {}): AreaSelectionRange[] {
1164
- if (!config.value.enabled) return selectionRanges.value;
1165
-
1166
- const { silent = false, scrollToView = false } = option;
1167
- const rowCount = dataSourceCopy.value.length;
1168
- const colCount = tableHeaderLast.value.length;
1169
-
1170
- if (rowCount <= 0 || colCount <= 0) {
1171
- clearSelectedArea();
1172
- if (!silent) emitSelectionChange();
1173
- return selectionRanges.value;
1174
- }
1175
-
1176
- const maxRow = rowCount - 1;
1177
- const maxCol = colCount - 1;
1178
-
1179
- let beginRow = 0;
1180
- let endRow = maxRow;
1181
- let beginCol = 0;
1182
- let endCol = maxCol;
1183
-
1184
- if (ranges) {
1185
- const begin = ranges.begin;
1186
- const end = ranges.end ?? begin;
1187
-
1188
- beginRow = typeof begin.row === 'number' ? begin.row : getRowIndex(begin.row);
1189
- endRow = typeof end.row === 'number' ? end.row : getRowIndex(end.row);
1190
-
1191
- const beginColInput = typeof begin.col === 'number' ? begin.col : begin.col ? getColumnIndex(begin.col) : void 0;
1192
- const endColInput = typeof end.col === 'number' ? end.col : end.col ? getColumnIndex(end.col) : void 0;
1193
-
1194
- if (beginColInput !== void 0) {
1195
- beginCol = beginColInput;
1196
- endCol = endColInput !== void 0 ? endColInput : beginColInput;
1197
- } else if (endColInput !== void 0) {
1198
- beginCol = 0;
1199
- endCol = endColInput;
1200
- }
1201
- }
1202
-
1203
- beginRow = clamp(beginRow, 0, maxRow);
1204
- endRow = clamp(endRow, 0, maxRow);
1205
- beginCol = clamp(beginCol, 0, maxCol);
1206
- endCol = clamp(endCol, 0, maxCol);
1207
-
1208
- selectionRanges.value = [makeRange(beginRow, beginCol, endRow, endCol)];
1209
- anchorCell = { rowIndex: beginRow, colIndex: beginCol };
1210
- isSelecting.value = false;
1211
-
1212
- if (scrollToView) {
1213
- scrollToCell(endRow, endCol);
1214
- }
1215
-
1216
- if (!silent) emitSelectionChange();
1217
- return selectionRanges.value;
1218
- }
1219
-
1220
- return {
1221
- config,
1222
- isSelecting,
1223
- // getClass: getAreaSelectionClasses,
1224
- // getRowClass: getAreaSelectionRowClasses,
1225
- get: getSelectedArea,
1226
- set: setAreaSelection,
1227
- clear: clearSelectedArea,
1228
- copy: copySelectedArea,
1229
- onMD: onSelectionMouseDown,
1230
- };
1231
- }
1232
- export const useAreaSelectionName = 'useAreaSelection';
1233
-
1234
- // 挂载特性名供 registerFeature 识别。
1235
- // 顶层属性赋值会阻止消费方 tree-shaking,改用 @__PURE__ 包装并绑定到导出:
1236
- // 本库构建时导出存活故保留;消费方未使用该导出时可整体摇掉。
1237
- export const useAreaSelection: typeof useAreaSelectionImpl = /* @__PURE__ */ Object.assign(useAreaSelectionImpl, {
1238
- [MY_FN_NAME]: useAreaSelectionName,
1239
- });
1
+ import { Ref, ShallowRef, computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue';
2
+ import {
3
+ AreaSelectionConfig,
4
+ AreaSelectionRange,
5
+ CellKeyGen,
6
+ ColKeyGen,
7
+ StkTableColumn,
8
+ UniqKey,
9
+ AreaSelectionSetterRange,
10
+ AreaSelectionSetterOption,
11
+ } from '../types';
12
+ import { VirtualScrollStore, VirtualScrollXStore } from '../useVirtualScroll';
13
+ import { getClosestColKey, getClosestTd, getClosestTr, getClosestTrIndex } from '../utils';
14
+ import { getCalculatedColWidth } from '../utils/constRefUtils';
15
+ import { MY_FN_NAME } from './const';
16
+
17
+ /**
18
+ * 单元格区域选择功能(Excel 式交互)。
19
+ * 支持鼠标拖拽选择、键盘导航(方向键/Tab/Shift+Tab,Ctrl 多选、Shift 扩选)、复制到剪贴板。
20
+ * en: Cell area selection feature with mouse drag, keyboard navigation, copy-paste, etc.
21
+ *
22
+ * 分区说明:
23
+ * - 选区状态:锚点 begin / 终点 end、选区集合、是否正在拖拽选中(isAreaSelecting)。
24
+ * - 鼠标交互:mousedown 起锚、mousemove 扩展选区、拖拽靠近容器边缘时自动滚动(EDGE_ZONE/SCROLL_SPEED_MAX)。
25
+ * - 键盘交互:方向键/Tab 移动选区(见 useKeyboardArrowScroll 联动)、Ctrl 切换多选、Shift 扩选。
26
+ * - 复制:通过 formatCellForClipboard 回调(若配置了 customCell 应自定义)生成剪贴板文本。
27
+ * - 选区转换:单元格的可见/数据坐标通过 getRowIndex/getColumnIndex、cellKeyGen/colKeyGen 与
28
+ * 虚拟滚动(虚拟窗口裁剪)和列宽缓存(getCalculatedColWidth)相互换算。
29
+ *
30
+ * 对外返回:选区配置/状态、选区范围读写(setAreaSelection/getAreaSelection)、复制处理、选择/取消等。
31
+ */
32
+ function useAreaSelectionImpl<DT extends Record<string, any>>(
33
+ props: any,
34
+ emits: any,
35
+ tableContainerRef: Ref<HTMLDivElement | undefined>,
36
+ dataSourceCopy: ShallowRef<DT[]>,
37
+ tableHeaderLast: ShallowRef<StkTableColumn<DT>[]>,
38
+ colKeyGen: ColKeyGen,
39
+ cellKeyGen: CellKeyGen,
40
+ scrollTo: (top: number | null, left: number | null) => void,
41
+ virtualScroll: Ref<VirtualScrollStore>,
42
+ virtualScrollX: Ref<VirtualScrollXStore>,
43
+ getRowIndex: (row: DT) => number,
44
+ getColumnIndex: (col: StkTableColumn<DT>) => number,
45
+ ) {
46
+ /**
47
+ * 自动滚动:鼠标距容器边缘多少px开始触发
48
+ * en: Mouse distance from container edge to start auto scroll
49
+ */
50
+ const EDGE_ZONE = 40;
51
+ /**
52
+ * 自动滚动:每帧最大滚动像素
53
+ * en: Maximum scroll pixels per frame
54
+ */
55
+ const SCROLL_SPEED_MAX = 15;
56
+ const POINT_EDGE_OFFSET = 2;
57
+
58
+ const KEY_ARROW_UP = 'ArrowUp';
59
+ const KEY_ARROW_DOWN = 'ArrowDown';
60
+ const KEY_ARROW_LEFT = 'ArrowLeft';
61
+ const KEY_ARROW_RIGHT = 'ArrowRight';
62
+ const KEY_TAB = 'Tab';
63
+ const KEY_ESCAPE = 'Escape';
64
+ const KEY_ESC = 'Esc';
65
+ const KEY_C = 'c';
66
+
67
+ // 选区样式通过 data-* 属性(而非 classList)应用。
68
+ // 原因:合并单元格的 td 存在响应式 class 绑定(cell-hover/cell-active),
69
+ // hover 时 Vue 的 patchClass 会用 `el.className = value` 整体覆盖 className,
70
+ // 从而抹掉命令式添加的选区 class,导致选区高亮/边框在鼠标经过合并单元格时消失。
71
+ // data-* 属性不在 vnode props 中,Vue 不会清除它们,可稳定保留选区样式。
72
+ // en: Apply selection styles via data-* attributes instead of classList, so Vue's
73
+ // reactive class patching on merged cells (cell-hover/cell-active) won't wipe them.
74
+ // const CELL_RANGE_SELECTED = 'cell-range-selected';
75
+ // const CELL_RANGE_TOP = 'cell-range-t';
76
+ // const CELL_RANGE_BOTTOM = 'cell-range-b';
77
+ // const CELL_RANGE_LEFT = 'cell-range-l';
78
+ // const CELL_RANGE_RIGHT = 'cell-range-r';
79
+ // const ROW_RANGE_SELECTED = 'row-range-selected';
80
+ const ATTR_CELL_SELECTED = 'data-cs-s';
81
+ const ATTR_CELL_TOP = 'data-cs-t';
82
+ const ATTR_CELL_BOTTOM = 'data-cs-b';
83
+ const ATTR_CELL_LEFT = 'data-cs-l';
84
+ const ATTR_CELL_RIGHT = 'data-cs-r';
85
+ const ATTR_ROW_SELECTED = 'data-rs-s';
86
+
87
+ const selectionRanges = ref<AreaSelectionRange[]>([]);
88
+ const isSelecting = ref(false);
89
+ /** start cell */
90
+ let anchorCell: { rowIndex: number; colIndex: number } | null = null;
91
+
92
+ /** auto scroll rAF id */
93
+ let autoScrollRafId = 0;
94
+ /**
95
+ * 最后一次鼠标位置(用于边界自动滚动计算)
96
+ * en: Last mouse position (for boundary auto scroll calculation)
97
+ */
98
+ let lastMouseClientX = 0;
99
+ let lastMouseClientY = 0;
100
+
101
+ const config = computed<AreaSelectionConfig>(() => {
102
+ if (typeof props.areaSelection === 'boolean') {
103
+ const b = props.areaSelection;
104
+ return { enabled: b, keyboard: b, ctrl: b, shift: b, highlight: { cell: b, row: false } };
105
+ }
106
+ const { highlight: userHighlight, ...restConfig } = props.areaSelection || {};
107
+ return {
108
+ enabled: true,
109
+ ctrl: true,
110
+ shift: true,
111
+ highlight: {
112
+ cell: true,
113
+ row: false,
114
+ ...userHighlight,
115
+ },
116
+ ...restConfig,
117
+ };
118
+ });
119
+
120
+ /** 是否启用键盘控制选区移动 */
121
+ const keyboardEnabled = computed(() => config.value.keyboard);
122
+
123
+ /** 是否启用 Ctrl 多选 */
124
+ const ctrlEnabled = computed(() => config.value.ctrl);
125
+
126
+ /** 是否启用 Shift 扩选 */
127
+ const shiftEnabled = computed(() => config.value.shift);
128
+
129
+ /** 是否启用单元格高亮 */
130
+ const highlightCellEnabled = computed(() => config.value.highlight?.cell);
131
+
132
+ /** 是否启用行高亮 */
133
+ const highlightRowEnabled = computed(() => config.value.highlight?.row);
134
+
135
+ /** colKey → absolute index 映射 */
136
+ const colKeyToIndexMap = computed(() => {
137
+ const headers = tableHeaderLast.value;
138
+ const map = new Map<UniqKey, number>();
139
+ for (let i = 0; i < headers.length; i++) {
140
+ map.set(colKeyGen.value(headers[i]), i);
141
+ }
142
+ return map;
143
+ });
144
+
145
+ /**
146
+ * 获取固定列宽度的函数
147
+ * 缓存每个固定列位置的累计宽度,查询时直接返回
148
+ * @param colIndex 目标列索引
149
+ * @returns [leftFixedWidth, rightFixedWidth]
150
+ */
151
+ const getFixedColWidths = computed(() => {
152
+ const cols = tableHeaderLast.value;
153
+ // 预计算每个列索引位置对应的左右固定列累计宽度
154
+ // 左固定列:从左向右累加,非固定列继承前一个值
155
+ const leftWidths: number[] = new Array(cols.length + 1).fill(0);
156
+ // 右固定列:从右向左累加,非固定列继承后一个值
157
+ const rightWidths: number[] = new Array(cols.length + 1).fill(0);
158
+
159
+ let leftSum = 0;
160
+ for (let i = 0; i < cols.length; i++) {
161
+ leftWidths[i] = leftSum;
162
+ if (cols[i]?.fixed === 'left') {
163
+ leftSum += getCalculatedColWidth(cols[i]);
164
+ }
165
+ }
166
+ leftWidths[cols.length] = leftSum;
167
+
168
+ let rightSum = 0;
169
+ for (let i = cols.length - 1; i >= 0; i--) {
170
+ rightWidths[i] = rightSum;
171
+ if (cols[i]?.fixed === 'right') {
172
+ rightSum += getCalculatedColWidth(cols[i]);
173
+ }
174
+ }
175
+
176
+ return (colIndex: number) => {
177
+ return [leftWidths[colIndex] ?? 0, rightWidths[colIndex + 1] ?? 0] as const;
178
+ };
179
+ });
180
+
181
+ /** 根据 selectionRanges 计算所有选区内 cellKey 的并集(非响应式,用于 DOM 操作) */
182
+ let selectedCellKeys: Set<string> = new Set();
183
+
184
+ /** 重新计算 selectedCellKeys */
185
+ function recomputeSelectedCellKeys() {
186
+ const ranges = selectionRanges.value;
187
+ if (!ranges.length) {
188
+ selectedCellKeys = new Set();
189
+ return;
190
+ }
191
+ const keys = new Set<string>();
192
+ const cols = tableHeaderLast.value;
193
+ const data = dataSourceCopy.value;
194
+ for (const range of ranges) {
195
+ const {
196
+ begin: { row: r1, col: c1 },
197
+ end: { row: r2, col: c2 },
198
+ } = range.index;
199
+ const [rStart, rEnd] = r1 < r2 ? [r1, r2] : [r2, r1];
200
+ const [cStart, cEnd] = c1 < c2 ? [c1, c2] : [c2, c1];
201
+ for (let r = rStart; r <= rEnd; r++) {
202
+ const row = data[r];
203
+ if (!row) continue;
204
+ for (let c = cStart; c <= cEnd; c++) {
205
+ const col = cols[c];
206
+ if (col) keys.add(cellKeyGen(row, col));
207
+ }
208
+ }
209
+ }
210
+ selectedCellKeys = keys;
211
+ }
212
+
213
+ /**
214
+ * 直接操作 DOM 更新选区样式(不触发 Vue re-render)
215
+ * 用于替代在渲染函数中读取响应式选区数据,避免拖选时频繁触发整个表格重渲染
216
+ */
217
+ function updateSelectionDOM() {
218
+ const container = tableContainerRef.value;
219
+ if (!container) return;
220
+
221
+ const cellHighlight = highlightCellEnabled.value;
222
+ const rowHighlight = highlightRowEnabled.value;
223
+
224
+ // 1. 清除所有旧的选区属性
225
+ const oldSelectedCells = container.querySelectorAll(`[${ATTR_CELL_SELECTED}]`);
226
+ for (let i = 0; i < oldSelectedCells.length; i++) {
227
+ const el = oldSelectedCells[i] as HTMLElement;
228
+ el.removeAttribute(ATTR_CELL_SELECTED);
229
+ el.removeAttribute(ATTR_CELL_TOP);
230
+ el.removeAttribute(ATTR_CELL_BOTTOM);
231
+ el.removeAttribute(ATTR_CELL_LEFT);
232
+ el.removeAttribute(ATTR_CELL_RIGHT);
233
+ }
234
+ const oldSelectedRows = container.querySelectorAll(`[${ATTR_ROW_SELECTED}]`);
235
+ for (let i = 0; i < oldSelectedRows.length; i++) {
236
+ (oldSelectedRows[i] as HTMLElement).removeAttribute(ATTR_ROW_SELECTED);
237
+ }
238
+
239
+ // 2. 重算 selectedCellKeys
240
+ recomputeSelectedCellKeys();
241
+
242
+ const ranges = selectionRanges.value;
243
+ if (!ranges.length) return;
244
+
245
+ const tbody = container.querySelector('.stk-tbody-main');
246
+ if (!tbody) return;
247
+
248
+ // 3. 应用行高亮 class
249
+ if (rowHighlight) {
250
+ for (const range of ranges) {
251
+ const { minRow, maxRow } = normalizeRange(range);
252
+ for (let r = minRow; r <= maxRow; r++) {
253
+ const tr = tbody.querySelector(`tr[data-row-i="${r}"]`) as HTMLElement | null;
254
+ if (tr) tr.setAttribute(ATTR_ROW_SELECTED, '');
255
+ }
256
+ }
257
+ }
258
+
259
+ // 4. 应用单元格高亮 class
260
+ if (cellHighlight) {
261
+ const lastRange = ranges[ranges.length - 1];
262
+ const { minRow: lrMinRow, maxRow: lrMaxRow, minCol: lrMinCol, maxCol: lrMaxCol } = normalizeRange(lastRange);
263
+
264
+ // 遍历所有可见行
265
+ const trs = tbody.querySelectorAll('tr[data-row-i]');
266
+ for (let t = 0; t < trs.length; t++) {
267
+ const tr = trs[t] as HTMLElement;
268
+ const rowIndex = parseInt(tr.getAttribute('data-row-i')!, 10);
269
+
270
+ // 检查此行是否在任何选区内
271
+ let inAnyRange = false;
272
+ for (const range of ranges) {
273
+ const { minRow, maxRow } = normalizeRange(range);
274
+ if (rowIndex >= minRow && rowIndex <= maxRow) {
275
+ inAnyRange = true;
276
+ break;
277
+ }
278
+ }
279
+ if (!inAnyRange) continue;
280
+
281
+ // 遍历此行中的单元格
282
+ const tds = tr.querySelectorAll('td[data-col-key]');
283
+ for (let d = 0; d < tds.length; d++) {
284
+ const td = tds[d] as HTMLElement;
285
+ const colKey = td.getAttribute('data-col-key')!;
286
+ const colIndex = colKeyToIndexMap.value.get(colKey);
287
+ if (colIndex === void 0 || colIndex < 0) continue;
288
+
289
+ // 生成 cellKey 检查是否在选区内
290
+ const data = dataSourceCopy.value;
291
+ const row = data[rowIndex];
292
+ const cols = tableHeaderLast.value;
293
+ if (!row || !cols[colIndex]) continue;
294
+ const ck = cellKeyGen(row, cols[colIndex]);
295
+ if (!selectedCellKeys.has(ck)) continue;
296
+
297
+ td.setAttribute(ATTR_CELL_SELECTED, '');
298
+
299
+ // 判断是否在最后一个区域的边界(考虑合并单元格的 rowspan/colspan)
300
+ const isInLastRange = rowIndex >= lrMinRow && rowIndex <= lrMaxRow && colIndex >= lrMinCol && colIndex <= lrMaxCol;
301
+ if (isInLastRange) {
302
+ // 合并单元格的实际结束行/列
303
+ const effEndRow = rowIndex + (parseInt(td.getAttribute('rowspan') || '1', 10) || 1) - 1;
304
+ const effEndCol = colIndex + (parseInt(td.getAttribute('colspan') || '1', 10) || 1) - 1;
305
+ if (rowIndex === lrMinRow) td.setAttribute(ATTR_CELL_TOP, '');
306
+ if (effEndRow === lrMaxRow) td.setAttribute(ATTR_CELL_BOTTOM, '');
307
+ if (colIndex === lrMinCol) td.setAttribute(ATTR_CELL_LEFT, '');
308
+ if (effEndCol === lrMaxCol) td.setAttribute(ATTR_CELL_RIGHT, '');
309
+ }
310
+ }
311
+ }
312
+ }
313
+ }
314
+
315
+ /**
316
+ * 监听 selectionRanges 变化,在 DOM 更新后直接操作选区 class
317
+ * 同时在虚拟滚动等导致 DOM 重建时也能重新应用选区样式
318
+ */
319
+ watch(
320
+ () => {
321
+ // 组合依赖:选区变化 或 虚拟滚动可见范围变化 都触发
322
+ const ranges = selectionRanges.value;
323
+ const vs = virtualScroll.value;
324
+ const vsx = virtualScrollX.value;
325
+ return [
326
+ ranges.length,
327
+ ranges.length > 0 ? JSON.stringify(ranges.map(r => r.index)) : '',
328
+ vsx.scrollLeft,
329
+ vs.startIndex,
330
+ vs.endIndex,
331
+ vsx.startIndex,
332
+ vsx.endIndex,
333
+ dataSourceCopy.value.length,
334
+ tableHeaderLast.value.length,
335
+ ];
336
+ },
337
+ () => {
338
+ // flush: 'post' 等效 — watch callback 在 DOM patch 后执行
339
+ nextTick(updateSelectionDOM);
340
+ },
341
+ { flush: 'post' },
342
+ );
343
+
344
+ onMounted(() => {
345
+ addListener();
346
+ });
347
+ onBeforeUnmount(() => {
348
+ removeListener();
349
+ });
350
+
351
+ /**
352
+ * 监听数据行数/列数变化,当行列变少时钳制选区与锚点,避免越界
353
+ * en: Watch row/col count changes, clamp selection ranges and anchor to avoid out-of-bounds
354
+ */
355
+ watch([() => dataSourceCopy.value.length, () => tableHeaderLast.value.length], ([rowCount, colCount]) => {
356
+ if (!config.value.enabled) return;
357
+
358
+ // 钳制锚点
359
+ if (anchorCell) {
360
+ if (rowCount === 0 || colCount === 0) {
361
+ anchorCell = null;
362
+ } else {
363
+ anchorCell.rowIndex = clamp(anchorCell.rowIndex, 0, rowCount - 1);
364
+ anchorCell.colIndex = clamp(anchorCell.colIndex, 0, colCount - 1);
365
+ }
366
+ }
367
+
368
+ if (!selectionRanges.value.length) return;
369
+
370
+ // 行或列为 0 时清空选区
371
+ if (rowCount === 0 || colCount === 0) {
372
+ clearSelectedArea();
373
+ emitSelectionChange();
374
+ return;
375
+ }
376
+
377
+ const maxRow = rowCount - 1;
378
+ const maxCol = colCount - 1;
379
+ let changed = false;
380
+ const newRanges: AreaSelectionRange[] = [];
381
+ for (const range of selectionRanges.value) {
382
+ const { begin, end } = range.index;
383
+ const nbRow = clamp(begin.row, 0, maxRow);
384
+ const nbCol = clamp(begin.col, 0, maxCol);
385
+ const neRow = clamp(end.row, 0, maxRow);
386
+ const neCol = clamp(end.col, 0, maxCol);
387
+ if (nbRow !== begin.row || nbCol !== begin.col || neRow !== end.row || neCol !== end.col) {
388
+ changed = true;
389
+ newRanges.push(makeRange(nbRow, nbCol, neRow, neCol));
390
+ } else {
391
+ newRanges.push(range);
392
+ }
393
+ }
394
+
395
+ if (changed) {
396
+ selectionRanges.value = newRanges;
397
+ emitSelectionChange();
398
+ }
399
+ });
400
+
401
+ function addListener() {
402
+ removeListener();
403
+ tableContainerRef.value?.addEventListener('keydown', onKeydown);
404
+ }
405
+
406
+ function removeListener() {
407
+ tableContainerRef.value?.removeEventListener('keydown', onKeydown);
408
+ document.removeEventListener('mousemove', onDocumentMouseMove);
409
+ document.removeEventListener('mouseup', onDocumentMouseUp);
410
+ stopAutoScroll();
411
+ }
412
+
413
+ /** 获取归一化(min/max)后的选区范围 */
414
+ function normalizeRange(range: AreaSelectionRange) {
415
+ const { begin, end } = range.index;
416
+ return {
417
+ minRow: Math.min(begin.row, end.row),
418
+ maxRow: Math.max(begin.row, end.row),
419
+ minCol: Math.min(begin.col, end.col),
420
+ maxCol: Math.max(begin.col, end.col),
421
+ };
422
+ }
423
+
424
+ /**
425
+ * 构造选区范围。begin = 拖拽起点,end = 拖拽终点。
426
+ * 同时填充已废弃的 x/y 字段以保证向后兼容。
427
+ */
428
+ function makeRange(beginRow: number, beginCol: number, endRow: number, endCol: number): AreaSelectionRange {
429
+ return {
430
+ index: {
431
+ x: [beginCol, endCol],
432
+ y: [beginRow, endRow],
433
+ begin: { row: beginRow, col: beginCol },
434
+ end: { row: endRow, col: endCol },
435
+ },
436
+ };
437
+ }
438
+
439
+ /** 根据colKey获取列的绝对索引 */
440
+ function getColIndexByKey(colKey: string | undefined): number {
441
+ if (!colKey) return -1;
442
+ return colKeyToIndexMap.value.get(colKey) ?? -1;
443
+ }
444
+
445
+ // ---- 合并单元格支持 ----
446
+
447
+ /** 获取指定单元格的合并信息 [rowspan, colspan],无合并返回 [1, 1] */
448
+ function getMergeSpan(rowIndex: number, colIndex: number): [number, number] {
449
+ const data = dataSourceCopy.value;
450
+ const cols = tableHeaderLast.value;
451
+ const row = data[rowIndex];
452
+ const col = cols[colIndex];
453
+ if (!row || !col || !col.mergeCells) return [1, 1];
454
+ const { rowspan = 1, colspan = 1 } = col.mergeCells({ row, col, rowIndex, colIndex }) || {};
455
+ return [rowspan || 1, colspan || 1];
456
+ }
457
+
458
+ /**
459
+ * 扩展选区范围,使其完整覆盖边界上部分选中的合并单元格
460
+ * en: Expand selection range to fully cover merged cells that are partially selected at boundaries
461
+ */
462
+ function expandRangeToCoverMergedCells(range: AreaSelectionRange): AreaSelectionRange {
463
+ const { minRow, maxRow, minCol, maxCol } = normalizeRange(range);
464
+ const data = dataSourceCopy.value;
465
+ const cols = tableHeaderLast.value;
466
+ const rowCount = data.length;
467
+ const colCount = cols.length;
468
+
469
+ // 预计算含 mergeCells 的列索引
470
+ const mergeColIndices: number[] = [];
471
+ for (let c = 0; c < colCount; c++) {
472
+ if (cols[c]?.mergeCells) mergeColIndices.push(c);
473
+ }
474
+ if (!mergeColIndices.length) return range;
475
+
476
+ let [eMinRow, eMaxRow, eMinCol, eMaxCol] = [minRow, maxRow, minCol, maxCol];
477
+
478
+ // 迭代扩展直到稳定(级联合并需要多轮)
479
+ let changed = true;
480
+ let guard = 0;
481
+ while (changed && guard++ < 100) {
482
+ changed = false;
483
+
484
+ // 下边界:最后一行中的单元格 rowspan 是否超出边界
485
+ for (const c of mergeColIndices) {
486
+ if (c < eMinCol || c > eMaxCol) continue;
487
+ const [rs] = getMergeSpan(eMaxRow, c);
488
+ if (rs > 1 && eMaxRow + rs - 1 < rowCount && eMaxRow + rs - 1 > eMaxRow) {
489
+ eMaxRow = eMaxRow + rs - 1;
490
+ changed = true;
491
+ }
492
+ }
493
+
494
+ // 右边界:最后一列中的单元格 colspan 是否超出边界
495
+ for (let r = eMinRow; r <= eMaxRow; r++) {
496
+ const [, cs] = getMergeSpan(r, eMaxCol);
497
+ if (cs > 1 && eMaxCol + cs - 1 < colCount && eMaxCol + cs - 1 > eMaxCol) {
498
+ eMaxCol = eMaxCol + cs - 1;
499
+ changed = true;
500
+ }
501
+ }
502
+
503
+ // 上边界:检查 minRow 上方的单元格是否延伸进选区
504
+ for (const c of mergeColIndices) {
505
+ if (c < eMinCol || c > eMaxCol) continue;
506
+ for (let r = eMinRow - 1; r >= 0 && r > eMinRow - 500; r--) {
507
+ const [rs] = getMergeSpan(r, c);
508
+ if (rs <= 1) continue;
509
+ const endRow = r + rs - 1;
510
+ if (endRow >= eMinRow) {
511
+ // 合并单元格延伸进选区,扩展上边界
512
+ if (r < eMinRow) {
513
+ eMinRow = r;
514
+ changed = true;
515
+ }
516
+ } else {
517
+ // 该合并单元格未到达当前边界,更上方的也不会到达,停止扫描此列
518
+ break;
519
+ }
520
+ }
521
+ }
522
+
523
+ // 左边界:检查 minCol 左侧的单元格是否延伸进选区
524
+ for (let r = eMinRow; r <= eMaxRow; r++) {
525
+ for (let c = eMinCol - 1; c >= 0 && c > eMinCol - 500; c--) {
526
+ const [, cs] = getMergeSpan(r, c);
527
+ if (cs <= 1) continue;
528
+ const endCol = c + cs - 1;
529
+ if (endCol >= eMinCol) {
530
+ if (c < eMinCol) {
531
+ eMinCol = c;
532
+ changed = true;
533
+ }
534
+ } else {
535
+ break;
536
+ }
537
+ }
538
+ }
539
+ }
540
+
541
+ if (eMinRow === minRow && eMaxRow === maxRow && eMinCol === minCol && eMaxCol === maxCol) {
542
+ return range;
543
+ }
544
+
545
+ // 保持 begin/end 的原始方向
546
+ const { begin, end } = range.index;
547
+ const newBeginRow = begin.row < end.row || begin.row === end.row ? eMinRow : eMaxRow;
548
+ const newEndRow = begin.row < end.row || begin.row === end.row ? eMaxRow : eMinRow;
549
+ const newBeginCol = begin.col <= end.col ? eMinCol : eMaxCol;
550
+ const newEndCol = begin.col <= end.col ? eMaxCol : eMinCol;
551
+ return makeRange(newBeginRow, newBeginCol, newEndRow, newEndCol);
552
+ }
553
+
554
+ /** 获取列的左边距和宽度
555
+ * @param colIndex 列的绝对索引
556
+ * @returns [left, width]
557
+ */
558
+ function getColPosition(colIndex: number): [number, number] {
559
+ let left = 0;
560
+ const cols = tableHeaderLast.value;
561
+ for (let i = 0; i < cols.length; i++) {
562
+ const colWidth = getCalculatedColWidth(cols[i]);
563
+ if (i === colIndex) return [left, colWidth];
564
+ left += colWidth;
565
+ }
566
+ return [left, 0];
567
+ }
568
+
569
+ /** 根据按键计算移动方向 */
570
+ function getMovementDelta(key: string, shiftKey: boolean): [number, number] {
571
+ let rowDelta = 0;
572
+ let colDelta = 0;
573
+
574
+ switch (key) {
575
+ case KEY_ARROW_UP:
576
+ rowDelta = -1;
577
+ break;
578
+ case KEY_ARROW_DOWN:
579
+ rowDelta = 1;
580
+ break;
581
+ case KEY_ARROW_LEFT:
582
+ colDelta = -1;
583
+ break;
584
+ case KEY_ARROW_RIGHT:
585
+ colDelta = 1;
586
+ break;
587
+ case KEY_TAB:
588
+ // Tab: right; Shift+Tab: left
589
+ colDelta = shiftKey ? -1 : 1;
590
+ break;
591
+ }
592
+
593
+ return [rowDelta, colDelta];
594
+ }
595
+
596
+ /** 钳制值到指定范围内 */
597
+ function clamp(value: number, min: number, max: number): number {
598
+ return Math.max(min, Math.min(value, max));
599
+ }
600
+
601
+ /** 处理Tab键的换行逻辑 */
602
+ function handleTabWrap(row: number, col: number, rawCol: number, rowCount: number, colCount: number): [number, number] {
603
+ if (rawCol >= colCount) return [Math.min(row + 1, rowCount - 1), 0];
604
+ if (rawCol < 0) return [Math.max(row - 1, 0), colCount - 1];
605
+ return [row, col];
606
+ }
607
+
608
+ /** 计算自动滚动的增量 */
609
+ function calculateAutoScrollDelta(mouseX: number, mouseY: number, rect: DOMRect): { deltaX: number; deltaY: number } {
610
+ const { top, bottom, left, right } = rect;
611
+ let deltaX = 0;
612
+ let deltaY = 0;
613
+
614
+ // Y方向
615
+ if (mouseY < top + EDGE_ZONE) {
616
+ const dist = Math.max(0, top + EDGE_ZONE - mouseY);
617
+ deltaY = -Math.ceil((dist / EDGE_ZONE) * SCROLL_SPEED_MAX);
618
+ } else if (mouseY > bottom - EDGE_ZONE) {
619
+ const dist = Math.max(0, mouseY - (bottom - EDGE_ZONE));
620
+ deltaY = Math.ceil((dist / EDGE_ZONE) * SCROLL_SPEED_MAX);
621
+ }
622
+
623
+ // X方向
624
+ if (mouseX < left + EDGE_ZONE) {
625
+ const dist = Math.max(0, left + EDGE_ZONE - mouseX);
626
+ deltaX = -Math.ceil((dist / EDGE_ZONE) * SCROLL_SPEED_MAX);
627
+ } else if (mouseX > right - EDGE_ZONE) {
628
+ const dist = Math.max(0, mouseX - (right - EDGE_ZONE));
629
+ deltaX = Math.ceil((dist / EDGE_ZONE) * SCROLL_SPEED_MAX);
630
+ }
631
+
632
+ return { deltaX, deltaY };
633
+ }
634
+
635
+ /** mousedown 处理:设置锚点,开始拖选 */
636
+ function onSelectionMouseDown(e: MouseEvent) {
637
+ if (!config.value.enabled || e.button !== 0) return;
638
+
639
+ const rowIndex = getClosestTrIndex(e.target as HTMLElement);
640
+ const colKey = getClosestColKey(e.target as HTMLElement);
641
+ const colIndex = getColIndexByKey(colKey);
642
+
643
+ if (rowIndex < 0 || colIndex < 0) return;
644
+
645
+ const ctrlKey = e.ctrlKey || e.metaKey;
646
+
647
+ // 立即扩展以完整覆盖合并单元格(修复 mousedown 合并单元格时边框显示不全)
648
+ const range: AreaSelectionRange = expandRangeToCoverMergedCells(makeRange(rowIndex, colIndex, rowIndex, colIndex));
649
+ // Shift 扩选:从锚点扩展到当前位置,更新最后一个区域
650
+ if (e.shiftKey && anchorCell && shiftEnabled.value) {
651
+ const ranges = selectionRanges.value.slice();
652
+ const shiftRange: AreaSelectionRange = expandRangeToCoverMergedCells(
653
+ makeRange(anchorCell.rowIndex, anchorCell.colIndex, rowIndex, colIndex),
654
+ );
655
+ if (ranges.length) {
656
+ ranges[ranges.length - 1] = shiftRange;
657
+ } else {
658
+ ranges.push(shiftRange);
659
+ }
660
+ selectionRanges.value = ranges;
661
+ } else {
662
+ anchorCell = { rowIndex, colIndex };
663
+ if (ctrlKey && ctrlEnabled.value) {
664
+ // Ctrl multiple
665
+ selectionRanges.value = selectionRanges.value.concat([range]);
666
+ } else {
667
+ // normal click
668
+ selectionRanges.value = [range];
669
+ }
670
+ }
671
+
672
+ isSelecting.value = true;
673
+ lastMouseClientX = e.clientX;
674
+ lastMouseClientY = e.clientY;
675
+
676
+ // 防止拖选时选中文字
677
+ // en: Prevent text selection during drag
678
+ document.addEventListener('mousemove', onDocumentMouseMove);
679
+ document.addEventListener('mouseup', onDocumentMouseUp);
680
+ }
681
+
682
+ /** document mousemove 处理:更新选区终点 + 检测边界自动滚动 */
683
+ function onDocumentMouseMove(e: MouseEvent) {
684
+ if (!isSelecting.value) return;
685
+
686
+ lastMouseClientX = e.clientX;
687
+ lastMouseClientY = e.clientY;
688
+
689
+ // 尝试从当前鼠标位置更新选区
690
+ updateSelectionFromEvent(e);
691
+
692
+ // 检测是否需要边界自动滚动
693
+ checkAutoScroll();
694
+ }
695
+
696
+ /** 从 MouseEvent 目标元素更新选区 */
697
+ function updateSelectionFromEvent(e: MouseEvent) {
698
+ const target = e.target as HTMLElement;
699
+ if (!target) return;
700
+
701
+ const rowIndex = getClosestTrIndex(target);
702
+ if (Number.isNaN(rowIndex) || rowIndex < 0) return;
703
+
704
+ const colKey = getClosestColKey(target);
705
+ const colIndex = getColIndexByKey(colKey);
706
+ if (colIndex < 0) return;
707
+
708
+ updateSelectionEnd(rowIndex, colIndex);
709
+ }
710
+
711
+ /** 更新最后一个选区的终点(拖拽过程中) */
712
+ function updateSelectionEnd(endRowIndex: number, endColIndex: number) {
713
+ if (!anchorCell) return;
714
+ // 拖拽过程中即扩展,使选区实时覆盖合并单元格的完整边界
715
+ const newRange: AreaSelectionRange = expandRangeToCoverMergedCells(
716
+ makeRange(anchorCell.rowIndex, anchorCell.colIndex, endRowIndex, endColIndex),
717
+ );
718
+ const ranges = [...selectionRanges.value];
719
+ if (ranges.length > 0) {
720
+ ranges[ranges.length - 1] = newRange;
721
+ } else {
722
+ ranges.push(newRange);
723
+ }
724
+ selectionRanges.value = ranges;
725
+ }
726
+
727
+ // ---- 边界自动滚动 ----
728
+
729
+ /** 检查鼠标是否在容器边缘附近,启动或停止自动滚动 */
730
+ function checkAutoScroll() {
731
+ const container = tableContainerRef.value;
732
+ if (!container) return;
733
+
734
+ const rect = container.getBoundingClientRect();
735
+ const { top, bottom, left, right } = rect;
736
+
737
+ const nearEdge =
738
+ lastMouseClientY < top + EDGE_ZONE ||
739
+ lastMouseClientY > bottom - EDGE_ZONE ||
740
+ lastMouseClientX < left + EDGE_ZONE ||
741
+ lastMouseClientX > right - EDGE_ZONE;
742
+
743
+ if (nearEdge && !autoScrollRafId) {
744
+ autoScrollLoop();
745
+ } else if (!nearEdge && autoScrollRafId) {
746
+ stopAutoScroll();
747
+ }
748
+ }
749
+
750
+ /** rAF 循环:边界自动滚动 + 更新选区 */
751
+ function autoScrollLoop() {
752
+ const container = tableContainerRef.value;
753
+ if (!container || !isSelecting.value) {
754
+ stopAutoScroll();
755
+ return;
756
+ }
757
+
758
+ const rect = container.getBoundingClientRect();
759
+ const { deltaX, deltaY } = calculateAutoScrollDelta(lastMouseClientX, lastMouseClientY, rect);
760
+
761
+ if (deltaX !== 0 || deltaY !== 0) {
762
+ container.scrollTop += deltaY;
763
+ container.scrollLeft += deltaX;
764
+
765
+ // 滚动后,在容器内边缘处用 elementFromPoint 找到当前单元格更新选区
766
+ updateSelectionFromPoint(container, rect);
767
+ }
768
+
769
+ // 如果还在拖选且仍需滚动,继续循环
770
+ if (isSelecting.value && (deltaX !== 0 || deltaY !== 0)) {
771
+ autoScrollRafId = requestAnimationFrame(autoScrollLoop);
772
+ } else {
773
+ autoScrollRafId = 0;
774
+ }
775
+ }
776
+
777
+ /** 将鼠标位置钳制到容器内部,用 elementFromPoint 找到边缘单元格并更新选区 */
778
+ function updateSelectionFromPoint(container: HTMLElement, containerRect: DOMRect) {
779
+ // 获取表头高度,钳制 Y 时跳过表头区域
780
+ const thead = container.querySelector('thead');
781
+ const { top, bottom, left, right } = containerRect;
782
+
783
+ const headerBottom = thead ? top + thead.offsetHeight : top;
784
+
785
+ const x = Math.max(left + POINT_EDGE_OFFSET, Math.min(lastMouseClientX, right - POINT_EDGE_OFFSET));
786
+ const y = Math.max(headerBottom + POINT_EDGE_OFFSET, Math.min(lastMouseClientY, bottom - POINT_EDGE_OFFSET));
787
+
788
+ const el = document.elementFromPoint(x, y);
789
+ if (!el) return;
790
+
791
+ const td = getClosestTd(el as HTMLElement);
792
+ const tr = getClosestTr(el as HTMLElement);
793
+ if (!td || !tr) return;
794
+
795
+ const rowIndex = getClosestTrIndex(tr);
796
+ const colKey = getClosestColKey(td);
797
+ const colIndex = getColIndexByKey(colKey);
798
+
799
+ if (Number.isNaN(rowIndex) || rowIndex < 0 || colIndex < 0) return;
800
+
801
+ updateSelectionEnd(rowIndex, colIndex);
802
+ }
803
+
804
+ /** 停止自动滚动 */
805
+ function stopAutoScroll() {
806
+ if (autoScrollRafId) {
807
+ cancelAnimationFrame(autoScrollRafId);
808
+ autoScrollRafId = 0;
809
+ }
810
+ }
811
+
812
+ /** document mouseup 处理:结束拖选 */
813
+ function onDocumentMouseUp() {
814
+ if (!isSelecting.value) return;
815
+ isSelecting.value = false;
816
+ stopAutoScroll();
817
+
818
+ document.removeEventListener('mousemove', onDocumentMouseMove);
819
+ document.removeEventListener('mouseup', onDocumentMouseUp);
820
+
821
+ // 扩展最后一个选区,使其完整覆盖合并单元格
822
+ const ranges = selectionRanges.value;
823
+ if (ranges.length) {
824
+ const expanded = expandRangeToCoverMergedCells(ranges[ranges.length - 1]);
825
+ if (expanded !== ranges[ranges.length - 1]) {
826
+ const newRanges = [...ranges];
827
+ newRanges[newRanges.length - 1] = expanded;
828
+ selectionRanges.value = newRanges;
829
+ }
830
+ }
831
+
832
+ // 发出事件
833
+ emitSelectionChange();
834
+ }
835
+
836
+ function emitSelectionChange() {
837
+ emits('area-selection-change', selectionRanges.value);
838
+ }
839
+
840
+ /** 获取 areaSelection 配置中的格式化回调 */
841
+ function getFormatCellFn() {
842
+ const cfg = config.value;
843
+ return typeof cfg.formatCellForClipboard === 'function' ? cfg.formatCellForClipboard : null;
844
+ }
845
+
846
+ /**
847
+ * 复制选区内容到剪贴板,只复制最后一个选区
848
+ * en: Copy selected area content to clipboard, only copy the last selected area
849
+ * @returns {string} text
850
+ */
851
+ function copySelectedArea(): string {
852
+ const ranges = selectionRanges.value;
853
+ if (!ranges.length) return '';
854
+
855
+ const range = ranges[ranges.length - 1];
856
+ const { minRow, maxRow, minCol, maxCol } = normalizeRange(range);
857
+ const data = dataSourceCopy.value;
858
+ const cols = tableHeaderLast.value;
859
+ const formatCell = getFormatCellFn();
860
+
861
+ const lines: string[] = [];
862
+ for (let r = minRow; r <= maxRow; r++) {
863
+ const row = data[r];
864
+ if (!row) continue;
865
+
866
+ const cells: string[] = [];
867
+ for (let c = minCol; c <= maxCol; c++) {
868
+ const col = cols[c];
869
+ if (!col) {
870
+ cells.push('');
871
+ continue;
872
+ }
873
+ const rawValue = row[col.dataIndex];
874
+ cells.push(formatCell ? formatCell(row, col, rawValue) : !rawValue ? '' : String(rawValue));
875
+ }
876
+ lines.push(cells.join('\t'));
877
+ }
878
+ const text = lines.join('\n');
879
+
880
+ navigator.clipboard.writeText(text).catch(() => {
881
+ console.warn('Failed to copy to clipboard');
882
+ });
883
+
884
+ return text;
885
+ }
886
+
887
+ function blurCellElement() {
888
+ // 防止虚拟滚动移除 DOM 时焦点被动丢失,导致后续 keydown 无法冒泡到容器
889
+ // en: Prevent focus loss when virtual scroll removes DOM, causing subsequent keydown events to not bubble to the container
890
+ const container = tableContainerRef.value;
891
+ const activeEl = document.activeElement as HTMLElement | null;
892
+ if (container && activeEl && container.contains(activeEl) && activeEl !== container) {
893
+ container.focus({ preventScroll: true });
894
+ }
895
+ }
896
+
897
+ /**
898
+ * Ctrl+C / Cmd+C copy
899
+ * Esc :cancel
900
+ * Arrow keys / Tab move (when keyboard=true)
901
+ **/
902
+ function onKeydown(e: KeyboardEvent) {
903
+ if (!config.value.enabled) return;
904
+
905
+ const key = e.key;
906
+
907
+ // Esc :cancel
908
+ if (key === KEY_ESCAPE || key === KEY_ESC) {
909
+ blurCellElement();
910
+ if (selectionRanges.value.length) {
911
+ // clearSelectedArea();
912
+ // emitSelectionChange();
913
+ e.preventDefault();
914
+ }
915
+ return;
916
+ }
917
+
918
+ // Ctrl/Cmd+C copy
919
+ if ((e.ctrlKey || e.metaKey) && key === KEY_C && selectionRanges.value.length) {
920
+ copySelectedArea();
921
+ e.preventDefault();
922
+ return;
923
+ }
924
+
925
+ if (!keyboardEnabled.value) return;
926
+
927
+ const isArrowKey = [KEY_ARROW_UP, KEY_ARROW_DOWN, KEY_ARROW_LEFT, KEY_ARROW_RIGHT].includes(key);
928
+ const isTabKey = key === KEY_TAB;
929
+ const isNavigationKey = isArrowKey || isTabKey;
930
+
931
+ if (!isNavigationKey) return;
932
+
933
+ e.preventDefault();
934
+
935
+ const rowCount = dataSourceCopy.value.length;
936
+ const colCount = tableHeaderLast.value.length;
937
+ if (rowCount === 0 || colCount === 0) return;
938
+
939
+ // 如果没有选区,默认从第一个单元格开始
940
+ // en: If no selection, start from the first cell
941
+ if (!selectionRanges.value.length) {
942
+ anchorCell = { rowIndex: 0, colIndex: 0 };
943
+ selectionRanges.value = [makeRange(0, 0, 0, 0)];
944
+ emitSelectionChange();
945
+ scrollToCell(0, 0);
946
+ return;
947
+ }
948
+
949
+ // 计算移动方向
950
+ const [rowDelta, colDelta] = getMovementDelta(key, e.shiftKey);
951
+
952
+ // Shift 扩展选区,否则移动单格选区
953
+ if (e.shiftKey && isArrowKey && shiftEnabled.value) {
954
+ blurCellElement();
955
+ // 扩展选区:保留 begin,更新最后一个区域的 end
956
+ const ranges = [...selectionRanges.value];
957
+ const range = ranges.length > 0 ? ranges[ranges.length - 1] : null;
958
+ if (!range) return;
959
+ const { begin, end } = range.index;
960
+ let newEndRow = end.row + rowDelta;
961
+ let newEndCol = end.col + colDelta;
962
+
963
+ // 边界检查
964
+ newEndRow = clamp(newEndRow, 0, rowCount - 1);
965
+ newEndCol = clamp(newEndCol, 0, colCount - 1);
966
+
967
+ ranges[ranges.length - 1] = makeRange(begin.row, begin.col, newEndRow, newEndCol);
968
+ selectionRanges.value = ranges;
969
+
970
+ scrollToCell(newEndRow, newEndCol);
971
+ } else {
972
+ blurCellElement();
973
+ // 移动单格选区
974
+ // 取最后一个区域的 end 位置作为基础,清空旧选区重建
975
+ const ranges = selectionRanges.value;
976
+ const range = ranges.length > 0 ? ranges[ranges.length - 1] : null;
977
+ const baseRow = range ? normalizeRange(range).minRow : 0;
978
+ const baseCol = range ? normalizeRange(range).minCol : 0;
979
+ let newRow = baseRow + rowDelta;
980
+ let newCol = baseCol + colDelta;
981
+
982
+ // 边界检查(先检查,避免越界)
983
+ newRow = clamp(newRow, 0, rowCount - 1);
984
+ newCol = clamp(newCol, 0, colCount - 1);
985
+
986
+ // Tab 换行逻辑:如果到达行尾/行首,换行
987
+ if (isTabKey) {
988
+ // 计算原始未 clamp 的值
989
+ const rawCol = baseCol + colDelta;
990
+ const [tabRow, tabCol] = handleTabWrap(baseRow, newCol, rawCol, rowCount, colCount);
991
+ newRow = tabRow;
992
+ newCol = tabCol;
993
+ }
994
+
995
+ // 更新锚点和选区(移动单格时清空其他区域,仅保留新位置)
996
+ anchorCell = { rowIndex: newRow, colIndex: newCol };
997
+ selectionRanges.value = [makeRange(newRow, newCol, newRow, newCol)];
998
+
999
+ scrollToCell(newRow, newCol);
1000
+ }
1001
+
1002
+ emitSelectionChange();
1003
+ }
1004
+
1005
+ /**
1006
+ * 滚动到指定单元格,确保其在可视区域内
1007
+ * @param rowIndex 行索引
1008
+ * @param colIndex 列索引
1009
+ */
1010
+ function scrollToCell(rowIndex: number, colIndex: number) {
1011
+ const container = tableContainerRef.value;
1012
+ if (!container) return;
1013
+
1014
+ const row = dataSourceCopy.value[rowIndex];
1015
+ const col = tableHeaderLast.value[colIndex];
1016
+ if (!row || !col) return;
1017
+
1018
+ const thead = container.querySelector('thead');
1019
+ const headerHeight = thead ? thead.offsetHeight : 0;
1020
+ const tfoot = container.querySelector('tfoot');
1021
+ const footerHeight = tfoot ? tfoot.offsetHeight : 0;
1022
+
1023
+ const vs = virtualScroll.value;
1024
+ const vsx = virtualScrollX.value;
1025
+
1026
+ // 是否开启按行滚动模式(experimental.scrollY 模式)
1027
+ const isScrollRowByRow = props.scrollRowByRow;
1028
+
1029
+ // 计算目标行的位置(基于虚拟滚动数据)
1030
+ // vs.rowHeight 由 initVirtualScrollY 随 props.rowHeight 同步,动态改行高后此处即新值
1031
+ const rowHeight = vs.rowHeight;
1032
+ const targetRowTop = rowIndex * rowHeight;
1033
+ const targetRowBottom = targetRowTop + rowHeight;
1034
+
1035
+ // 计算可视区域
1036
+ // experimental.scrollY 模式下,容器 scrollTop 始终为 0,需要使用 virtualScroll.scrollTop
1037
+ const visibleTop = isScrollRowByRow ? vs.scrollTop : container.scrollTop;
1038
+ const visibleBottom = visibleTop + vs.containerHeight - headerHeight - footerHeight;
1039
+
1040
+ // 计算需要的垂直滚动位置
1041
+ let newScrollTop: number | null = null;
1042
+ if (targetRowTop < visibleTop) {
1043
+ // 目标行在可视区域上方,滚动到使目标行位于顶部
1044
+ newScrollTop = targetRowTop;
1045
+ } else if (targetRowBottom > visibleBottom) {
1046
+ // 目标行在可视区域下方
1047
+ newScrollTop = targetRowBottom - (vs.containerHeight - headerHeight - footerHeight);
1048
+ }
1049
+
1050
+ // 计算目标列的位置
1051
+ const [targetColLeft, targetColWidth] = getColPosition(colIndex);
1052
+ const targetColRight = targetColLeft + targetColWidth;
1053
+
1054
+ // 计算可视区域(水平)
1055
+ const visibleLeft = container.scrollLeft;
1056
+ const visibleRight = visibleLeft + vsx.containerWidth;
1057
+
1058
+ // 计算固定列的宽度(用于检测遮挡)
1059
+ const [leftFixedWidth, rightFixedWidth] = getFixedColWidths.value(colIndex);
1060
+ let newScrollLeft: number | null = null;
1061
+ if (targetColLeft < visibleLeft + leftFixedWidth) {
1062
+ // 目标列在左侧固定列遮挡区域内,需要向左滚动
1063
+ newScrollLeft = targetColLeft - leftFixedWidth;
1064
+ } else if (targetColRight > visibleRight - rightFixedWidth) {
1065
+ // 目标列在右侧固定列遮挡区域内,需要向右滚动
1066
+ newScrollLeft = targetColRight - vsx.containerWidth + rightFixedWidth;
1067
+ }
1068
+
1069
+ if (newScrollTop !== null || newScrollLeft !== null) {
1070
+ scrollTo(newScrollTop, newScrollLeft);
1071
+ }
1072
+ }
1073
+
1074
+ /**
1075
+ * 判断一个单元格的选区样式类名
1076
+ * @param cellKey 单元格唯一键
1077
+ * @param absoluteRowIndex 行在 dataSourceCopy 中的绝对索引
1078
+ * @param colKey 列唯一键
1079
+ * @returns 样式类名数组
1080
+ */
1081
+ // function getAreaSelectionClasses(cellKey: string, absoluteRowIndex: number, colKey: UniqKey): string[] {
1082
+ // if (!highlightCellEnabled.value) return [];
1083
+ // if (!selectedCellKeys.has(cellKey)) return [];
1084
+
1085
+ // const colIndex = colKeyToIndexMap.value.get(colKey);
1086
+ // if (colIndex === void 0 || colIndex < 0) return [];
1087
+
1088
+ // const classes: string[] = [CELL_RANGE_SELECTED];
1089
+ // const ranges = selectionRanges.value;
1090
+ // if (!ranges.length) return classes;
1091
+
1092
+ // const lastRange = ranges[ranges.length - 1];
1093
+ // const { minRow, maxRow, minCol, maxCol } = normalizeRange(lastRange);
1094
+
1095
+ // // 判断当前单元格是否在最后一个区域内
1096
+ // const isInLastRange = absoluteRowIndex >= minRow && absoluteRowIndex <= maxRow && colIndex >= minCol && colIndex <= maxCol;
1097
+
1098
+ // if (isInLastRange) {
1099
+ // if (absoluteRowIndex === minRow) classes.push(CELL_RANGE_TOP);
1100
+ // if (absoluteRowIndex === maxRow) classes.push(CELL_RANGE_BOTTOM);
1101
+ // if (colIndex === minCol) classes.push(CELL_RANGE_LEFT);
1102
+ // if (colIndex === maxCol) classes.push(CELL_RANGE_RIGHT);
1103
+ // }
1104
+
1105
+ // return classes;
1106
+ // }
1107
+
1108
+ /**
1109
+ * 判断一行的选区样式类名(行高亮)
1110
+ * @param absoluteRowIndex 行在 dataSourceCopy 中的绝对索引
1111
+ * @returns 样式类名数组
1112
+ */
1113
+ // function getAreaSelectionRowClasses(absoluteRowIndex: number): string[] {
1114
+ // if (!highlightRowEnabled.value) return [];
1115
+
1116
+ // const ranges = selectionRanges.value;
1117
+ // if (!ranges.length) return [];
1118
+
1119
+ // // 检查该行是否在任何选区内
1120
+ // for (const range of ranges) {
1121
+ // const {
1122
+ // begin: { row: r1 },
1123
+ // end: { row: r2 },
1124
+ // } = range.index;
1125
+ // const [minRow, maxRow] = r1 < r2 ? [r1, r2] : [r2, r1];
1126
+ // if (absoluteRowIndex >= minRow && absoluteRowIndex <= maxRow) {
1127
+ // return ['row-range-selected'];
1128
+ // }
1129
+ // }
1130
+
1131
+ // return [];
1132
+ // }
1133
+
1134
+ // expose function
1135
+
1136
+ /** 获取选中的单元格信息 */
1137
+ function getSelectedArea() {
1138
+ const ranges = selectionRanges.value;
1139
+ if (!ranges.length) return { rows: [] as DT[], cols: [] as StkTableColumn<DT>[], ranges: [] as AreaSelectionRange[] };
1140
+ const data = dataSourceCopy.value;
1141
+ const cols = tableHeaderLast.value;
1142
+ // 收集所有区域的行和列
1143
+ const rowSet = new Set<number>();
1144
+ const colSet = new Set<number>();
1145
+ for (const range of ranges) {
1146
+ const { minRow, maxRow, minCol, maxCol } = normalizeRange(range);
1147
+ for (let r = minRow; r <= maxRow; r++) rowSet.add(r);
1148
+ for (let c = minCol; c <= maxCol; c++) colSet.add(c);
1149
+ }
1150
+ const sortedRows = [...rowSet].sort((a, b) => a - b);
1151
+ const sortedCols = [...colSet].sort((a, b) => a - b);
1152
+ return {
1153
+ rows: sortedRows.map(i => data[i]).filter(Boolean),
1154
+ cols: sortedCols.map(i => cols[i]).filter(Boolean),
1155
+ ranges: ranges.map(r => ({ ...r })),
1156
+ };
1157
+ }
1158
+
1159
+ function clearSelectedArea() {
1160
+ selectionRanges.value = [];
1161
+ isSelecting.value = false;
1162
+ }
1163
+
1164
+ function setAreaSelection(ranges?: AreaSelectionSetterRange<DT>, option: AreaSelectionSetterOption = {}): AreaSelectionRange[] {
1165
+ if (!config.value.enabled) return selectionRanges.value;
1166
+
1167
+ const { silent = false, scrollToView = false } = option;
1168
+ const rowCount = dataSourceCopy.value.length;
1169
+ const colCount = tableHeaderLast.value.length;
1170
+
1171
+ if (rowCount <= 0 || colCount <= 0) {
1172
+ clearSelectedArea();
1173
+ if (!silent) emitSelectionChange();
1174
+ return selectionRanges.value;
1175
+ }
1176
+
1177
+ const maxRow = rowCount - 1;
1178
+ const maxCol = colCount - 1;
1179
+
1180
+ let beginRow = 0;
1181
+ let endRow = maxRow;
1182
+ let beginCol = 0;
1183
+ let endCol = maxCol;
1184
+
1185
+ if (ranges) {
1186
+ const begin = ranges.begin;
1187
+ const end = ranges.end ?? begin;
1188
+
1189
+ beginRow = typeof begin.row === 'number' ? begin.row : getRowIndex(begin.row);
1190
+ endRow = typeof end.row === 'number' ? end.row : getRowIndex(end.row);
1191
+
1192
+ const beginColInput = typeof begin.col === 'number' ? begin.col : begin.col ? getColumnIndex(begin.col) : void 0;
1193
+ const endColInput = typeof end.col === 'number' ? end.col : end.col ? getColumnIndex(end.col) : void 0;
1194
+
1195
+ if (beginColInput !== void 0) {
1196
+ beginCol = beginColInput;
1197
+ endCol = endColInput !== void 0 ? endColInput : beginColInput;
1198
+ } else if (endColInput !== void 0) {
1199
+ beginCol = 0;
1200
+ endCol = endColInput;
1201
+ }
1202
+ }
1203
+
1204
+ beginRow = clamp(beginRow, 0, maxRow);
1205
+ endRow = clamp(endRow, 0, maxRow);
1206
+ beginCol = clamp(beginCol, 0, maxCol);
1207
+ endCol = clamp(endCol, 0, maxCol);
1208
+
1209
+ selectionRanges.value = [makeRange(beginRow, beginCol, endRow, endCol)];
1210
+ anchorCell = { rowIndex: beginRow, colIndex: beginCol };
1211
+ isSelecting.value = false;
1212
+
1213
+ if (scrollToView) {
1214
+ scrollToCell(endRow, endCol);
1215
+ }
1216
+
1217
+ if (!silent) emitSelectionChange();
1218
+ return selectionRanges.value;
1219
+ }
1220
+
1221
+ return {
1222
+ config,
1223
+ isSelecting,
1224
+ // getClass: getAreaSelectionClasses,
1225
+ // getRowClass: getAreaSelectionRowClasses,
1226
+ get: getSelectedArea,
1227
+ set: setAreaSelection,
1228
+ clear: clearSelectedArea,
1229
+ copy: copySelectedArea,
1230
+ onMD: onSelectionMouseDown,
1231
+ };
1232
+ }
1233
+ export const useAreaSelectionName = 'useAreaSelection';
1234
+
1235
+ // 挂载特性名供 registerFeature 识别。
1236
+ // 顶层属性赋值会阻止消费方 tree-shaking,改用 @__PURE__ 包装并绑定到导出:
1237
+ // 本库构建时导出存活故保留;消费方未使用该导出时可整体摇掉。
1238
+ export const useAreaSelection: typeof useAreaSelectionImpl = /* @__PURE__ */ Object.assign(useAreaSelectionImpl, {
1239
+ [MY_FN_NAME]: useAreaSelectionName,
1240
+ });