import { Ref, ShallowRef, computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue';
import {
AreaSelectionConfig,
AreaSelectionRange,
CellKeyGen,
ColKeyGen,
StkTableColumn,
UniqKey,
AreaSelectionSetterRange,
AreaSelectionSetterOption,
} from '../types';
import { VirtualScrollStore, VirtualScrollXStore } from '../useVirtualScroll';
import { getClosestColKey, getClosestTd, getClosestTr, getClosestTrIndex } from '../utils';
import { getCalculatedColWidth } from '../utils/constRefUtils';
import { MY_FN_NAME } from './const';
/**
* 单元格区域选择功能
* 支持鼠标拖拽选择、键盘导航、复制粘贴等功能
* en: Cell area selection feature with mouse drag, keyboard navigation, copy-paste, etc.
*/
function useAreaSelectionImpl
>(
props: any,
emits: any,
tableContainerRef: Ref,
dataSourceCopy: ShallowRef,
tableHeaderLast: ShallowRef[]>,
colKeyGen: ColKeyGen,
cellKeyGen: CellKeyGen,
scrollTo: (top: number | null, left: number | null) => void,
virtualScroll: Ref,
virtualScrollX: Ref,
getRowIndex: (row: DT) => number,
getColumnIndex: (col: StkTableColumn) => number,
) {
/**
* 自动滚动:鼠标距容器边缘多少px开始触发
* en: Mouse distance from container edge to start auto scroll
*/
const EDGE_ZONE = 40;
/**
* 自动滚动:每帧最大滚动像素
* en: Maximum scroll pixels per frame
*/
const SCROLL_SPEED_MAX = 15;
const POINT_EDGE_OFFSET = 2;
const KEY_ARROW_UP = 'ArrowUp';
const KEY_ARROW_DOWN = 'ArrowDown';
const KEY_ARROW_LEFT = 'ArrowLeft';
const KEY_ARROW_RIGHT = 'ArrowRight';
const KEY_TAB = 'Tab';
const KEY_ESCAPE = 'Escape';
const KEY_ESC = 'Esc';
const KEY_C = 'c';
// 选区样式通过 data-* 属性(而非 classList)应用。
// 原因:合并单元格的 td 存在响应式 class 绑定(cell-hover/cell-active),
// hover 时 Vue 的 patchClass 会用 `el.className = value` 整体覆盖 className,
// 从而抹掉命令式添加的选区 class,导致选区高亮/边框在鼠标经过合并单元格时消失。
// data-* 属性不在 vnode props 中,Vue 不会清除它们,可稳定保留选区样式。
// en: Apply selection styles via data-* attributes instead of classList, so Vue's
// reactive class patching on merged cells (cell-hover/cell-active) won't wipe them.
// const CELL_RANGE_SELECTED = 'cell-range-selected';
// const CELL_RANGE_TOP = 'cell-range-t';
// const CELL_RANGE_BOTTOM = 'cell-range-b';
// const CELL_RANGE_LEFT = 'cell-range-l';
// const CELL_RANGE_RIGHT = 'cell-range-r';
// const ROW_RANGE_SELECTED = 'row-range-selected';
const ATTR_CELL_SELECTED = 'data-cs-s';
const ATTR_CELL_TOP = 'data-cs-t';
const ATTR_CELL_BOTTOM = 'data-cs-b';
const ATTR_CELL_LEFT = 'data-cs-l';
const ATTR_CELL_RIGHT = 'data-cs-r';
const ATTR_ROW_SELECTED = 'data-rs-s';
const selectionRanges = ref([]);
const isSelecting = ref(false);
/** start cell */
let anchorCell: { rowIndex: number; colIndex: number } | null = null;
/** auto scroll rAF id */
let autoScrollRafId = 0;
/**
* 最后一次鼠标位置(用于边界自动滚动计算)
* en: Last mouse position (for boundary auto scroll calculation)
*/
let lastMouseClientX = 0;
let lastMouseClientY = 0;
const config = computed(() => {
if (typeof props.areaSelection === 'boolean') {
const b = props.areaSelection;
return { enabled: b, keyboard: b, ctrl: b, shift: b, highlight: { cell: b, row: false } };
}
const { highlight: userHighlight, ...restConfig } = props.areaSelection || {};
return {
enabled: true,
ctrl: true,
shift: true,
highlight: {
cell: true,
row: false,
...userHighlight,
},
...restConfig,
};
});
/** 是否启用键盘控制选区移动 */
const keyboardEnabled = computed(() => config.value.keyboard);
/** 是否启用 Ctrl 多选 */
const ctrlEnabled = computed(() => config.value.ctrl);
/** 是否启用 Shift 扩选 */
const shiftEnabled = computed(() => config.value.shift);
/** 是否启用单元格高亮 */
const highlightCellEnabled = computed(() => config.value.highlight?.cell);
/** 是否启用行高亮 */
const highlightRowEnabled = computed(() => config.value.highlight?.row);
/** colKey → absolute index 映射 */
const colKeyToIndexMap = computed(() => {
const headers = tableHeaderLast.value;
const map = new Map();
for (let i = 0; i < headers.length; i++) {
map.set(colKeyGen.value(headers[i]), i);
}
return map;
});
/**
* 获取固定列宽度的函数
* 缓存每个固定列位置的累计宽度,查询时直接返回
* @param colIndex 目标列索引
* @returns [leftFixedWidth, rightFixedWidth]
*/
const getFixedColWidths = computed(() => {
const cols = tableHeaderLast.value;
// 预计算每个列索引位置对应的左右固定列累计宽度
// 左固定列:从左向右累加,非固定列继承前一个值
const leftWidths: number[] = new Array(cols.length + 1).fill(0);
// 右固定列:从右向左累加,非固定列继承后一个值
const rightWidths: number[] = new Array(cols.length + 1).fill(0);
let leftSum = 0;
for (let i = 0; i < cols.length; i++) {
leftWidths[i] = leftSum;
if (cols[i]?.fixed === 'left') {
leftSum += getCalculatedColWidth(cols[i]);
}
}
leftWidths[cols.length] = leftSum;
let rightSum = 0;
for (let i = cols.length - 1; i >= 0; i--) {
rightWidths[i] = rightSum;
if (cols[i]?.fixed === 'right') {
rightSum += getCalculatedColWidth(cols[i]);
}
}
return (colIndex: number) => {
return [leftWidths[colIndex] ?? 0, rightWidths[colIndex + 1] ?? 0] as const;
};
});
/** 根据 selectionRanges 计算所有选区内 cellKey 的并集(非响应式,用于 DOM 操作) */
let selectedCellKeys: Set = new Set();
/** 重新计算 selectedCellKeys */
function recomputeSelectedCellKeys() {
const ranges = selectionRanges.value;
if (!ranges.length) {
selectedCellKeys = new Set();
return;
}
const keys = new Set();
const cols = tableHeaderLast.value;
const data = dataSourceCopy.value;
for (const range of ranges) {
const {
begin: { row: r1, col: c1 },
end: { row: r2, col: c2 },
} = range.index;
const [rStart, rEnd] = r1 < r2 ? [r1, r2] : [r2, r1];
const [cStart, cEnd] = c1 < c2 ? [c1, c2] : [c2, c1];
for (let r = rStart; r <= rEnd; r++) {
const row = data[r];
if (!row) continue;
for (let c = cStart; c <= cEnd; c++) {
const col = cols[c];
if (col) keys.add(cellKeyGen(row, col));
}
}
}
selectedCellKeys = keys;
}
/**
* 直接操作 DOM 更新选区样式(不触发 Vue re-render)
* 用于替代在渲染函数中读取响应式选区数据,避免拖选时频繁触发整个表格重渲染
*/
function updateSelectionDOM() {
const container = tableContainerRef.value;
if (!container) return;
const cellHighlight = highlightCellEnabled.value;
const rowHighlight = highlightRowEnabled.value;
// 1. 清除所有旧的选区属性
const oldSelectedCells = container.querySelectorAll(`[${ATTR_CELL_SELECTED}]`);
for (let i = 0; i < oldSelectedCells.length; i++) {
const el = oldSelectedCells[i] as HTMLElement;
el.removeAttribute(ATTR_CELL_SELECTED);
el.removeAttribute(ATTR_CELL_TOP);
el.removeAttribute(ATTR_CELL_BOTTOM);
el.removeAttribute(ATTR_CELL_LEFT);
el.removeAttribute(ATTR_CELL_RIGHT);
}
const oldSelectedRows = container.querySelectorAll(`[${ATTR_ROW_SELECTED}]`);
for (let i = 0; i < oldSelectedRows.length; i++) {
(oldSelectedRows[i] as HTMLElement).removeAttribute(ATTR_ROW_SELECTED);
}
// 2. 重算 selectedCellKeys
recomputeSelectedCellKeys();
const ranges = selectionRanges.value;
if (!ranges.length) return;
const tbody = container.querySelector('.stk-tbody-main');
if (!tbody) return;
// 3. 应用行高亮 class
if (rowHighlight) {
for (const range of ranges) {
const { minRow, maxRow } = normalizeRange(range);
for (let r = minRow; r <= maxRow; r++) {
const tr = tbody.querySelector(`tr[data-row-i="${r}"]`) as HTMLElement | null;
if (tr) tr.setAttribute(ATTR_ROW_SELECTED, '');
}
}
}
// 4. 应用单元格高亮 class
if (cellHighlight) {
const lastRange = ranges[ranges.length - 1];
const { minRow: lrMinRow, maxRow: lrMaxRow, minCol: lrMinCol, maxCol: lrMaxCol } = normalizeRange(lastRange);
// 遍历所有可见行
const trs = tbody.querySelectorAll('tr[data-row-i]');
for (let t = 0; t < trs.length; t++) {
const tr = trs[t] as HTMLElement;
const rowIndex = parseInt(tr.getAttribute('data-row-i')!, 10);
// 检查此行是否在任何选区内
let inAnyRange = false;
for (const range of ranges) {
const { minRow, maxRow } = normalizeRange(range);
if (rowIndex >= minRow && rowIndex <= maxRow) {
inAnyRange = true;
break;
}
}
if (!inAnyRange) continue;
// 遍历此行中的单元格
const tds = tr.querySelectorAll('td[data-col-key]');
for (let d = 0; d < tds.length; d++) {
const td = tds[d] as HTMLElement;
const colKey = td.getAttribute('data-col-key')!;
const colIndex = colKeyToIndexMap.value.get(colKey);
if (colIndex === void 0 || colIndex < 0) continue;
// 生成 cellKey 检查是否在选区内
const data = dataSourceCopy.value;
const row = data[rowIndex];
const cols = tableHeaderLast.value;
if (!row || !cols[colIndex]) continue;
const ck = cellKeyGen(row, cols[colIndex]);
if (!selectedCellKeys.has(ck)) continue;
td.setAttribute(ATTR_CELL_SELECTED, '');
// 判断是否在最后一个区域的边界(考虑合并单元格的 rowspan/colspan)
const isInLastRange = rowIndex >= lrMinRow && rowIndex <= lrMaxRow && colIndex >= lrMinCol && colIndex <= lrMaxCol;
if (isInLastRange) {
// 合并单元格的实际结束行/列
const effEndRow = rowIndex + (parseInt(td.getAttribute('rowspan') || '1', 10) || 1) - 1;
const effEndCol = colIndex + (parseInt(td.getAttribute('colspan') || '1', 10) || 1) - 1;
if (rowIndex === lrMinRow) td.setAttribute(ATTR_CELL_TOP, '');
if (effEndRow === lrMaxRow) td.setAttribute(ATTR_CELL_BOTTOM, '');
if (colIndex === lrMinCol) td.setAttribute(ATTR_CELL_LEFT, '');
if (effEndCol === lrMaxCol) td.setAttribute(ATTR_CELL_RIGHT, '');
}
}
}
}
}
/**
* 监听 selectionRanges 变化,在 DOM 更新后直接操作选区 class
* 同时在虚拟滚动等导致 DOM 重建时也能重新应用选区样式
*/
watch(
() => {
// 组合依赖:选区变化 或 虚拟滚动可见范围变化 都触发
const ranges = selectionRanges.value;
const vs = virtualScroll.value;
const vsx = virtualScrollX.value;
return [
ranges.length,
ranges.length > 0 ? JSON.stringify(ranges.map(r => r.index)) : '',
vsx.scrollLeft,
vs.startIndex,
vs.endIndex,
vsx.startIndex,
vsx.endIndex,
dataSourceCopy.value.length,
tableHeaderLast.value.length,
];
},
() => {
// flush: 'post' 等效 — watch callback 在 DOM patch 后执行
nextTick(updateSelectionDOM);
},
{ flush: 'post' },
);
onMounted(() => {
addListener();
});
onBeforeUnmount(() => {
removeListener();
});
/**
* 监听数据行数/列数变化,当行列变少时钳制选区与锚点,避免越界
* en: Watch row/col count changes, clamp selection ranges and anchor to avoid out-of-bounds
*/
watch([() => dataSourceCopy.value.length, () => tableHeaderLast.value.length], ([rowCount, colCount]) => {
if (!config.value.enabled) return;
// 钳制锚点
if (anchorCell) {
if (rowCount === 0 || colCount === 0) {
anchorCell = null;
} else {
anchorCell.rowIndex = clamp(anchorCell.rowIndex, 0, rowCount - 1);
anchorCell.colIndex = clamp(anchorCell.colIndex, 0, colCount - 1);
}
}
if (!selectionRanges.value.length) return;
// 行或列为 0 时清空选区
if (rowCount === 0 || colCount === 0) {
clearSelectedArea();
emitSelectionChange();
return;
}
const maxRow = rowCount - 1;
const maxCol = colCount - 1;
let changed = false;
const newRanges: AreaSelectionRange[] = [];
for (const range of selectionRanges.value) {
const { begin, end } = range.index;
const nbRow = clamp(begin.row, 0, maxRow);
const nbCol = clamp(begin.col, 0, maxCol);
const neRow = clamp(end.row, 0, maxRow);
const neCol = clamp(end.col, 0, maxCol);
if (nbRow !== begin.row || nbCol !== begin.col || neRow !== end.row || neCol !== end.col) {
changed = true;
newRanges.push(makeRange(nbRow, nbCol, neRow, neCol));
} else {
newRanges.push(range);
}
}
if (changed) {
selectionRanges.value = newRanges;
emitSelectionChange();
}
});
function addListener() {
removeListener();
tableContainerRef.value?.addEventListener('keydown', onKeydown);
}
function removeListener() {
tableContainerRef.value?.removeEventListener('keydown', onKeydown);
document.removeEventListener('mousemove', onDocumentMouseMove);
document.removeEventListener('mouseup', onDocumentMouseUp);
stopAutoScroll();
}
/** 获取归一化(min/max)后的选区范围 */
function normalizeRange(range: AreaSelectionRange) {
const { begin, end } = range.index;
return {
minRow: Math.min(begin.row, end.row),
maxRow: Math.max(begin.row, end.row),
minCol: Math.min(begin.col, end.col),
maxCol: Math.max(begin.col, end.col),
};
}
/**
* 构造选区范围。begin = 拖拽起点,end = 拖拽终点。
* 同时填充已废弃的 x/y 字段以保证向后兼容。
*/
function makeRange(beginRow: number, beginCol: number, endRow: number, endCol: number): AreaSelectionRange {
return {
index: {
x: [beginCol, endCol],
y: [beginRow, endRow],
begin: { row: beginRow, col: beginCol },
end: { row: endRow, col: endCol },
},
};
}
/** 根据colKey获取列的绝对索引 */
function getColIndexByKey(colKey: string | undefined): number {
if (!colKey) return -1;
return colKeyToIndexMap.value.get(colKey) ?? -1;
}
// ---- 合并单元格支持 ----
/** 获取指定单元格的合并信息 [rowspan, colspan],无合并返回 [1, 1] */
function getMergeSpan(rowIndex: number, colIndex: number): [number, number] {
const data = dataSourceCopy.value;
const cols = tableHeaderLast.value;
const row = data[rowIndex];
const col = cols[colIndex];
if (!row || !col || !col.mergeCells) return [1, 1];
const { rowspan = 1, colspan = 1 } = col.mergeCells({ row, col, rowIndex, colIndex }) || {};
return [rowspan || 1, colspan || 1];
}
/**
* 扩展选区范围,使其完整覆盖边界上部分选中的合并单元格
* en: Expand selection range to fully cover merged cells that are partially selected at boundaries
*/
function expandRangeToCoverMergedCells(range: AreaSelectionRange): AreaSelectionRange {
const { minRow, maxRow, minCol, maxCol } = normalizeRange(range);
const data = dataSourceCopy.value;
const cols = tableHeaderLast.value;
const rowCount = data.length;
const colCount = cols.length;
// 预计算含 mergeCells 的列索引
const mergeColIndices: number[] = [];
for (let c = 0; c < colCount; c++) {
if (cols[c]?.mergeCells) mergeColIndices.push(c);
}
if (!mergeColIndices.length) return range;
let [eMinRow, eMaxRow, eMinCol, eMaxCol] = [minRow, maxRow, minCol, maxCol];
// 迭代扩展直到稳定(级联合并需要多轮)
let changed = true;
let guard = 0;
while (changed && guard++ < 100) {
changed = false;
// 下边界:最后一行中的单元格 rowspan 是否超出边界
for (const c of mergeColIndices) {
if (c < eMinCol || c > eMaxCol) continue;
const [rs] = getMergeSpan(eMaxRow, c);
if (rs > 1 && eMaxRow + rs - 1 < rowCount && eMaxRow + rs - 1 > eMaxRow) {
eMaxRow = eMaxRow + rs - 1;
changed = true;
}
}
// 右边界:最后一列中的单元格 colspan 是否超出边界
for (let r = eMinRow; r <= eMaxRow; r++) {
const [, cs] = getMergeSpan(r, eMaxCol);
if (cs > 1 && eMaxCol + cs - 1 < colCount && eMaxCol + cs - 1 > eMaxCol) {
eMaxCol = eMaxCol + cs - 1;
changed = true;
}
}
// 上边界:检查 minRow 上方的单元格是否延伸进选区
for (const c of mergeColIndices) {
if (c < eMinCol || c > eMaxCol) continue;
for (let r = eMinRow - 1; r >= 0 && r > eMinRow - 500; r--) {
const [rs] = getMergeSpan(r, c);
if (rs <= 1) continue;
const endRow = r + rs - 1;
if (endRow >= eMinRow) {
// 合并单元格延伸进选区,扩展上边界
if (r < eMinRow) {
eMinRow = r;
changed = true;
}
} else {
// 该合并单元格未到达当前边界,更上方的也不会到达,停止扫描此列
break;
}
}
}
// 左边界:检查 minCol 左侧的单元格是否延伸进选区
for (let r = eMinRow; r <= eMaxRow; r++) {
for (let c = eMinCol - 1; c >= 0 && c > eMinCol - 500; c--) {
const [, cs] = getMergeSpan(r, c);
if (cs <= 1) continue;
const endCol = c + cs - 1;
if (endCol >= eMinCol) {
if (c < eMinCol) {
eMinCol = c;
changed = true;
}
} else {
break;
}
}
}
}
if (eMinRow === minRow && eMaxRow === maxRow && eMinCol === minCol && eMaxCol === maxCol) {
return range;
}
// 保持 begin/end 的原始方向
const { begin, end } = range.index;
const newBeginRow = begin.row < end.row || begin.row === end.row ? eMinRow : eMaxRow;
const newEndRow = begin.row < end.row || begin.row === end.row ? eMaxRow : eMinRow;
const newBeginCol = begin.col <= end.col ? eMinCol : eMaxCol;
const newEndCol = begin.col <= end.col ? eMaxCol : eMinCol;
return makeRange(newBeginRow, newBeginCol, newEndRow, newEndCol);
}
/** 获取列的左边距和宽度
* @param colIndex 列的绝对索引
* @returns [left, width]
*/
function getColPosition(colIndex: number): [number, number] {
let left = 0;
const cols = tableHeaderLast.value;
for (let i = 0; i < cols.length; i++) {
const colWidth = getCalculatedColWidth(cols[i]);
if (i === colIndex) return [left, colWidth];
left += colWidth;
}
return [left, 0];
}
/** 根据按键计算移动方向 */
function getMovementDelta(key: string, shiftKey: boolean): [number, number] {
let rowDelta = 0;
let colDelta = 0;
switch (key) {
case KEY_ARROW_UP:
rowDelta = -1;
break;
case KEY_ARROW_DOWN:
rowDelta = 1;
break;
case KEY_ARROW_LEFT:
colDelta = -1;
break;
case KEY_ARROW_RIGHT:
colDelta = 1;
break;
case KEY_TAB:
// Tab: right; Shift+Tab: left
colDelta = shiftKey ? -1 : 1;
break;
}
return [rowDelta, colDelta];
}
/** 钳制值到指定范围内 */
function clamp(value: number, min: number, max: number): number {
return Math.max(min, Math.min(value, max));
}
/** 处理Tab键的换行逻辑 */
function handleTabWrap(row: number, col: number, rawCol: number, rowCount: number, colCount: number): [number, number] {
if (rawCol >= colCount) return [Math.min(row + 1, rowCount - 1), 0];
if (rawCol < 0) return [Math.max(row - 1, 0), colCount - 1];
return [row, col];
}
/** 计算自动滚动的增量 */
function calculateAutoScrollDelta(mouseX: number, mouseY: number, rect: DOMRect): { deltaX: number; deltaY: number } {
const { top, bottom, left, right } = rect;
let deltaX = 0;
let deltaY = 0;
// Y方向
if (mouseY < top + EDGE_ZONE) {
const dist = Math.max(0, top + EDGE_ZONE - mouseY);
deltaY = -Math.ceil((dist / EDGE_ZONE) * SCROLL_SPEED_MAX);
} else if (mouseY > bottom - EDGE_ZONE) {
const dist = Math.max(0, mouseY - (bottom - EDGE_ZONE));
deltaY = Math.ceil((dist / EDGE_ZONE) * SCROLL_SPEED_MAX);
}
// X方向
if (mouseX < left + EDGE_ZONE) {
const dist = Math.max(0, left + EDGE_ZONE - mouseX);
deltaX = -Math.ceil((dist / EDGE_ZONE) * SCROLL_SPEED_MAX);
} else if (mouseX > right - EDGE_ZONE) {
const dist = Math.max(0, mouseX - (right - EDGE_ZONE));
deltaX = Math.ceil((dist / EDGE_ZONE) * SCROLL_SPEED_MAX);
}
return { deltaX, deltaY };
}
/** mousedown 处理:设置锚点,开始拖选 */
function onSelectionMouseDown(e: MouseEvent) {
if (!config.value.enabled || e.button !== 0) return;
const rowIndex = getClosestTrIndex(e.target as HTMLElement);
const colKey = getClosestColKey(e.target as HTMLElement);
const colIndex = getColIndexByKey(colKey);
if (rowIndex < 0 || colIndex < 0) return;
const ctrlKey = e.ctrlKey || e.metaKey;
// 立即扩展以完整覆盖合并单元格(修复 mousedown 合并单元格时边框显示不全)
const range: AreaSelectionRange = expandRangeToCoverMergedCells(makeRange(rowIndex, colIndex, rowIndex, colIndex));
// Shift 扩选:从锚点扩展到当前位置,更新最后一个区域
if (e.shiftKey && anchorCell && shiftEnabled.value) {
const ranges = selectionRanges.value.slice();
const shiftRange: AreaSelectionRange = expandRangeToCoverMergedCells(
makeRange(anchorCell.rowIndex, anchorCell.colIndex, rowIndex, colIndex),
);
if (ranges.length) {
ranges[ranges.length - 1] = shiftRange;
} else {
ranges.push(shiftRange);
}
selectionRanges.value = ranges;
} else {
anchorCell = { rowIndex, colIndex };
if (ctrlKey && ctrlEnabled.value) {
// Ctrl multiple
selectionRanges.value = selectionRanges.value.concat([range]);
} else {
// normal click
selectionRanges.value = [range];
}
}
isSelecting.value = true;
lastMouseClientX = e.clientX;
lastMouseClientY = e.clientY;
// 防止拖选时选中文字
// en: Prevent text selection during drag
document.addEventListener('mousemove', onDocumentMouseMove);
document.addEventListener('mouseup', onDocumentMouseUp);
}
/** document mousemove 处理:更新选区终点 + 检测边界自动滚动 */
function onDocumentMouseMove(e: MouseEvent) {
if (!isSelecting.value) return;
lastMouseClientX = e.clientX;
lastMouseClientY = e.clientY;
// 尝试从当前鼠标位置更新选区
updateSelectionFromEvent(e);
// 检测是否需要边界自动滚动
checkAutoScroll();
}
/** 从 MouseEvent 目标元素更新选区 */
function updateSelectionFromEvent(e: MouseEvent) {
const target = e.target as HTMLElement;
if (!target) return;
const rowIndex = getClosestTrIndex(target);
if (Number.isNaN(rowIndex) || rowIndex < 0) return;
const colKey = getClosestColKey(target);
const colIndex = getColIndexByKey(colKey);
if (colIndex < 0) return;
updateSelectionEnd(rowIndex, colIndex);
}
/** 更新最后一个选区的终点(拖拽过程中) */
function updateSelectionEnd(endRowIndex: number, endColIndex: number) {
if (!anchorCell) return;
// 拖拽过程中即扩展,使选区实时覆盖合并单元格的完整边界
const newRange: AreaSelectionRange = expandRangeToCoverMergedCells(
makeRange(anchorCell.rowIndex, anchorCell.colIndex, endRowIndex, endColIndex),
);
const ranges = [...selectionRanges.value];
if (ranges.length > 0) {
ranges[ranges.length - 1] = newRange;
} else {
ranges.push(newRange);
}
selectionRanges.value = ranges;
}
// ---- 边界自动滚动 ----
/** 检查鼠标是否在容器边缘附近,启动或停止自动滚动 */
function checkAutoScroll() {
const container = tableContainerRef.value;
if (!container) return;
const rect = container.getBoundingClientRect();
const { top, bottom, left, right } = rect;
const nearEdge =
lastMouseClientY < top + EDGE_ZONE ||
lastMouseClientY > bottom - EDGE_ZONE ||
lastMouseClientX < left + EDGE_ZONE ||
lastMouseClientX > right - EDGE_ZONE;
if (nearEdge && !autoScrollRafId) {
autoScrollLoop();
} else if (!nearEdge && autoScrollRafId) {
stopAutoScroll();
}
}
/** rAF 循环:边界自动滚动 + 更新选区 */
function autoScrollLoop() {
const container = tableContainerRef.value;
if (!container || !isSelecting.value) {
stopAutoScroll();
return;
}
const rect = container.getBoundingClientRect();
const { deltaX, deltaY } = calculateAutoScrollDelta(lastMouseClientX, lastMouseClientY, rect);
if (deltaX !== 0 || deltaY !== 0) {
container.scrollTop += deltaY;
container.scrollLeft += deltaX;
// 滚动后,在容器内边缘处用 elementFromPoint 找到当前单元格更新选区
updateSelectionFromPoint(container, rect);
}
// 如果还在拖选且仍需滚动,继续循环
if (isSelecting.value && (deltaX !== 0 || deltaY !== 0)) {
autoScrollRafId = requestAnimationFrame(autoScrollLoop);
} else {
autoScrollRafId = 0;
}
}
/** 将鼠标位置钳制到容器内部,用 elementFromPoint 找到边缘单元格并更新选区 */
function updateSelectionFromPoint(container: HTMLElement, containerRect: DOMRect) {
// 获取表头高度,钳制 Y 时跳过表头区域
const thead = container.querySelector('thead');
const { top, bottom, left, right } = containerRect;
const headerBottom = thead ? top + thead.offsetHeight : top;
const x = Math.max(left + POINT_EDGE_OFFSET, Math.min(lastMouseClientX, right - POINT_EDGE_OFFSET));
const y = Math.max(headerBottom + POINT_EDGE_OFFSET, Math.min(lastMouseClientY, bottom - POINT_EDGE_OFFSET));
const el = document.elementFromPoint(x, y);
if (!el) return;
const td = getClosestTd(el as HTMLElement);
const tr = getClosestTr(el as HTMLElement);
if (!td || !tr) return;
const rowIndex = getClosestTrIndex(tr);
const colKey = getClosestColKey(td);
const colIndex = getColIndexByKey(colKey);
if (Number.isNaN(rowIndex) || rowIndex < 0 || colIndex < 0) return;
updateSelectionEnd(rowIndex, colIndex);
}
/** 停止自动滚动 */
function stopAutoScroll() {
if (autoScrollRafId) {
cancelAnimationFrame(autoScrollRafId);
autoScrollRafId = 0;
}
}
/** document mouseup 处理:结束拖选 */
function onDocumentMouseUp() {
if (!isSelecting.value) return;
isSelecting.value = false;
stopAutoScroll();
document.removeEventListener('mousemove', onDocumentMouseMove);
document.removeEventListener('mouseup', onDocumentMouseUp);
// 扩展最后一个选区,使其完整覆盖合并单元格
const ranges = selectionRanges.value;
if (ranges.length) {
const expanded = expandRangeToCoverMergedCells(ranges[ranges.length - 1]);
if (expanded !== ranges[ranges.length - 1]) {
const newRanges = [...ranges];
newRanges[newRanges.length - 1] = expanded;
selectionRanges.value = newRanges;
}
}
// 发出事件
emitSelectionChange();
}
function emitSelectionChange() {
emits('area-selection-change', selectionRanges.value);
}
/** 获取 areaSelection 配置中的格式化回调 */
function getFormatCellFn() {
const cfg = config.value;
return typeof cfg.formatCellForClipboard === 'function' ? cfg.formatCellForClipboard : null;
}
/**
* 复制选区内容到剪贴板,只复制最后一个选区
* en: Copy selected area content to clipboard, only copy the last selected area
* @returns {string} text
*/
function copySelectedArea(): string {
const ranges = selectionRanges.value;
if (!ranges.length) return '';
const range = ranges[ranges.length - 1];
const { minRow, maxRow, minCol, maxCol } = normalizeRange(range);
const data = dataSourceCopy.value;
const cols = tableHeaderLast.value;
const formatCell = getFormatCellFn();
const lines: string[] = [];
for (let r = minRow; r <= maxRow; r++) {
const row = data[r];
if (!row) continue;
const cells: string[] = [];
for (let c = minCol; c <= maxCol; c++) {
const col = cols[c];
if (!col) {
cells.push('');
continue;
}
const rawValue = row[col.dataIndex];
cells.push(formatCell ? formatCell(row, col, rawValue) : !rawValue ? '' : String(rawValue));
}
lines.push(cells.join('\t'));
}
const text = lines.join('\n');
navigator.clipboard.writeText(text).catch(() => {
console.warn('Failed to copy to clipboard');
});
return text;
}
function blurCellElement() {
// 防止虚拟滚动移除 DOM 时焦点被动丢失,导致后续 keydown 无法冒泡到容器
// en: Prevent focus loss when virtual scroll removes DOM, causing subsequent keydown events to not bubble to the container
const container = tableContainerRef.value;
const activeEl = document.activeElement as HTMLElement | null;
if (container && activeEl && container.contains(activeEl) && activeEl !== container) {
container.focus({ preventScroll: true });
}
}
/**
* Ctrl+C / Cmd+C copy
* Esc :cancel
* Arrow keys / Tab move (when keyboard=true)
**/
function onKeydown(e: KeyboardEvent) {
if (!config.value.enabled) return;
const key = e.key;
// Esc :cancel
if (key === KEY_ESCAPE || key === KEY_ESC) {
blurCellElement();
if (selectionRanges.value.length) {
// clearSelectedArea();
// emitSelectionChange();
e.preventDefault();
}
return;
}
// Ctrl/Cmd+C copy
if ((e.ctrlKey || e.metaKey) && key === KEY_C && selectionRanges.value.length) {
copySelectedArea();
e.preventDefault();
return;
}
if (!keyboardEnabled.value) return;
const isArrowKey = [KEY_ARROW_UP, KEY_ARROW_DOWN, KEY_ARROW_LEFT, KEY_ARROW_RIGHT].includes(key);
const isTabKey = key === KEY_TAB;
const isNavigationKey = isArrowKey || isTabKey;
if (!isNavigationKey) return;
e.preventDefault();
const rowCount = dataSourceCopy.value.length;
const colCount = tableHeaderLast.value.length;
if (rowCount === 0 || colCount === 0) return;
// 如果没有选区,默认从第一个单元格开始
// en: If no selection, start from the first cell
if (!selectionRanges.value.length) {
anchorCell = { rowIndex: 0, colIndex: 0 };
selectionRanges.value = [makeRange(0, 0, 0, 0)];
emitSelectionChange();
scrollToCell(0, 0);
return;
}
// 计算移动方向
const [rowDelta, colDelta] = getMovementDelta(key, e.shiftKey);
// Shift 扩展选区,否则移动单格选区
if (e.shiftKey && isArrowKey && shiftEnabled.value) {
blurCellElement();
// 扩展选区:保留 begin,更新最后一个区域的 end
const ranges = [...selectionRanges.value];
const range = ranges.length > 0 ? ranges[ranges.length - 1] : null;
if (!range) return;
const { begin, end } = range.index;
let newEndRow = end.row + rowDelta;
let newEndCol = end.col + colDelta;
// 边界检查
newEndRow = clamp(newEndRow, 0, rowCount - 1);
newEndCol = clamp(newEndCol, 0, colCount - 1);
ranges[ranges.length - 1] = makeRange(begin.row, begin.col, newEndRow, newEndCol);
selectionRanges.value = ranges;
scrollToCell(newEndRow, newEndCol);
} else {
blurCellElement();
// 移动单格选区
// 取最后一个区域的 end 位置作为基础,清空旧选区重建
const ranges = selectionRanges.value;
const range = ranges.length > 0 ? ranges[ranges.length - 1] : null;
const baseRow = range ? normalizeRange(range).minRow : 0;
const baseCol = range ? normalizeRange(range).minCol : 0;
let newRow = baseRow + rowDelta;
let newCol = baseCol + colDelta;
// 边界检查(先检查,避免越界)
newRow = clamp(newRow, 0, rowCount - 1);
newCol = clamp(newCol, 0, colCount - 1);
// Tab 换行逻辑:如果到达行尾/行首,换行
if (isTabKey) {
// 计算原始未 clamp 的值
const rawCol = baseCol + colDelta;
const [tabRow, tabCol] = handleTabWrap(baseRow, newCol, rawCol, rowCount, colCount);
newRow = tabRow;
newCol = tabCol;
}
// 更新锚点和选区(移动单格时清空其他区域,仅保留新位置)
anchorCell = { rowIndex: newRow, colIndex: newCol };
selectionRanges.value = [makeRange(newRow, newCol, newRow, newCol)];
scrollToCell(newRow, newCol);
}
emitSelectionChange();
}
/**
* 滚动到指定单元格,确保其在可视区域内
* @param rowIndex 行索引
* @param colIndex 列索引
*/
function scrollToCell(rowIndex: number, colIndex: number) {
const container = tableContainerRef.value;
if (!container) return;
const row = dataSourceCopy.value[rowIndex];
const col = tableHeaderLast.value[colIndex];
if (!row || !col) return;
const thead = container.querySelector('thead');
const headerHeight = thead ? thead.offsetHeight : 0;
const tfoot = container.querySelector('tfoot');
const footerHeight = tfoot ? tfoot.offsetHeight : 0;
const vs = virtualScroll.value;
const vsx = virtualScrollX.value;
// 是否开启按行滚动模式(experimental.scrollY 模式)
const isScrollRowByRow = props.scrollRowByRow;
// 计算目标行的位置(基于虚拟滚动数据)
const rowHeight = vs.rowHeight;
const targetRowTop = rowIndex * rowHeight;
const targetRowBottom = targetRowTop + rowHeight;
// 计算可视区域
// experimental.scrollY 模式下,容器 scrollTop 始终为 0,需要使用 virtualScroll.scrollTop
const visibleTop = isScrollRowByRow ? vs.scrollTop : container.scrollTop;
const visibleBottom = visibleTop + vs.containerHeight - headerHeight - footerHeight;
// 计算需要的垂直滚动位置
let newScrollTop: number | null = null;
if (targetRowTop < visibleTop) {
// 目标行在可视区域上方,滚动到使目标行位于顶部
newScrollTop = targetRowTop;
} else if (targetRowBottom > visibleBottom) {
// 目标行在可视区域下方
newScrollTop = targetRowBottom - (vs.containerHeight - headerHeight - footerHeight);
}
// 计算目标列的位置
const [targetColLeft, targetColWidth] = getColPosition(colIndex);
const targetColRight = targetColLeft + targetColWidth;
// 计算可视区域(水平)
const visibleLeft = container.scrollLeft;
const visibleRight = visibleLeft + vsx.containerWidth;
// 计算固定列的宽度(用于检测遮挡)
const [leftFixedWidth, rightFixedWidth] = getFixedColWidths.value(colIndex);
let newScrollLeft: number | null = null;
if (targetColLeft < visibleLeft + leftFixedWidth) {
// 目标列在左侧固定列遮挡区域内,需要向左滚动
newScrollLeft = targetColLeft - leftFixedWidth;
} else if (targetColRight > visibleRight - rightFixedWidth) {
// 目标列在右侧固定列遮挡区域内,需要向右滚动
newScrollLeft = targetColRight - vsx.containerWidth + rightFixedWidth;
}
if (newScrollTop !== null || newScrollLeft !== null) {
scrollTo(newScrollTop, newScrollLeft);
}
}
/**
* 判断一个单元格的选区样式类名
* @param cellKey 单元格唯一键
* @param absoluteRowIndex 行在 dataSourceCopy 中的绝对索引
* @param colKey 列唯一键
* @returns 样式类名数组
*/
// function getAreaSelectionClasses(cellKey: string, absoluteRowIndex: number, colKey: UniqKey): string[] {
// if (!highlightCellEnabled.value) return [];
// if (!selectedCellKeys.has(cellKey)) return [];
// const colIndex = colKeyToIndexMap.value.get(colKey);
// if (colIndex === void 0 || colIndex < 0) return [];
// const classes: string[] = [CELL_RANGE_SELECTED];
// const ranges = selectionRanges.value;
// if (!ranges.length) return classes;
// const lastRange = ranges[ranges.length - 1];
// const { minRow, maxRow, minCol, maxCol } = normalizeRange(lastRange);
// // 判断当前单元格是否在最后一个区域内
// const isInLastRange = absoluteRowIndex >= minRow && absoluteRowIndex <= maxRow && colIndex >= minCol && colIndex <= maxCol;
// if (isInLastRange) {
// if (absoluteRowIndex === minRow) classes.push(CELL_RANGE_TOP);
// if (absoluteRowIndex === maxRow) classes.push(CELL_RANGE_BOTTOM);
// if (colIndex === minCol) classes.push(CELL_RANGE_LEFT);
// if (colIndex === maxCol) classes.push(CELL_RANGE_RIGHT);
// }
// return classes;
// }
/**
* 判断一行的选区样式类名(行高亮)
* @param absoluteRowIndex 行在 dataSourceCopy 中的绝对索引
* @returns 样式类名数组
*/
// function getAreaSelectionRowClasses(absoluteRowIndex: number): string[] {
// if (!highlightRowEnabled.value) return [];
// const ranges = selectionRanges.value;
// if (!ranges.length) return [];
// // 检查该行是否在任何选区内
// for (const range of ranges) {
// const {
// begin: { row: r1 },
// end: { row: r2 },
// } = range.index;
// const [minRow, maxRow] = r1 < r2 ? [r1, r2] : [r2, r1];
// if (absoluteRowIndex >= minRow && absoluteRowIndex <= maxRow) {
// return ['row-range-selected'];
// }
// }
// return [];
// }
// expose function
/** 获取选中的单元格信息 */
function getSelectedArea() {
const ranges = selectionRanges.value;
if (!ranges.length) return { rows: [] as DT[], cols: [] as StkTableColumn[], ranges: [] as AreaSelectionRange[] };
const data = dataSourceCopy.value;
const cols = tableHeaderLast.value;
// 收集所有区域的行和列
const rowSet = new Set();
const colSet = new Set();
for (const range of ranges) {
const { minRow, maxRow, minCol, maxCol } = normalizeRange(range);
for (let r = minRow; r <= maxRow; r++) rowSet.add(r);
for (let c = minCol; c <= maxCol; c++) colSet.add(c);
}
const sortedRows = [...rowSet].sort((a, b) => a - b);
const sortedCols = [...colSet].sort((a, b) => a - b);
return {
rows: sortedRows.map(i => data[i]).filter(Boolean),
cols: sortedCols.map(i => cols[i]).filter(Boolean),
ranges: ranges.map(r => ({ ...r })),
};
}
function clearSelectedArea() {
selectionRanges.value = [];
isSelecting.value = false;
}
function setAreaSelection(ranges?: AreaSelectionSetterRange, option: AreaSelectionSetterOption = {}): AreaSelectionRange[] {
if (!config.value.enabled) return selectionRanges.value;
const { silent = false, scrollToView = false } = option;
const rowCount = dataSourceCopy.value.length;
const colCount = tableHeaderLast.value.length;
if (rowCount <= 0 || colCount <= 0) {
clearSelectedArea();
if (!silent) emitSelectionChange();
return selectionRanges.value;
}
const maxRow = rowCount - 1;
const maxCol = colCount - 1;
let beginRow = 0;
let endRow = maxRow;
let beginCol = 0;
let endCol = maxCol;
if (ranges) {
const begin = ranges.begin;
const end = ranges.end ?? begin;
beginRow = typeof begin.row === 'number' ? begin.row : getRowIndex(begin.row);
endRow = typeof end.row === 'number' ? end.row : getRowIndex(end.row);
const beginColInput = typeof begin.col === 'number' ? begin.col : begin.col ? getColumnIndex(begin.col) : void 0;
const endColInput = typeof end.col === 'number' ? end.col : end.col ? getColumnIndex(end.col) : void 0;
if (beginColInput !== void 0) {
beginCol = beginColInput;
endCol = endColInput !== void 0 ? endColInput : beginColInput;
} else if (endColInput !== void 0) {
beginCol = 0;
endCol = endColInput;
}
}
beginRow = clamp(beginRow, 0, maxRow);
endRow = clamp(endRow, 0, maxRow);
beginCol = clamp(beginCol, 0, maxCol);
endCol = clamp(endCol, 0, maxCol);
selectionRanges.value = [makeRange(beginRow, beginCol, endRow, endCol)];
anchorCell = { rowIndex: beginRow, colIndex: beginCol };
isSelecting.value = false;
if (scrollToView) {
scrollToCell(endRow, endCol);
}
if (!silent) emitSelectionChange();
return selectionRanges.value;
}
return {
config,
isSelecting,
// getClass: getAreaSelectionClasses,
// getRowClass: getAreaSelectionRowClasses,
get: getSelectedArea,
set: setAreaSelection,
clear: clearSelectedArea,
copy: copySelectedArea,
onMD: onSelectionMouseDown,
};
}
export const useAreaSelectionName = 'useAreaSelection';
// 挂载特性名供 registerFeature 识别。
// 顶层属性赋值会阻止消费方 tree-shaking,改用 @__PURE__ 包装并绑定到导出:
// 本库构建时导出存活故保留;消费方未使用该导出时可整体摇掉。
export const useAreaSelection: typeof useAreaSelectionImpl = /* @__PURE__ */ Object.assign(useAreaSelectionImpl, {
[MY_FN_NAME]: useAreaSelectionName,
});