From eca560b4e5f0635d79a8061618da384e2b841e31 Mon Sep 17 00:00:00 2001 From: Syngnat Date: Fri, 27 Feb 2026 10:57:05 +0800 Subject: [PATCH] =?UTF-8?q?=F0=9F=90=9B=20fix(data-grid):=20=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D=E5=8D=95=E5=85=83=E6=A0=BC=E7=BC=96=E8=BE=91=E5=99=A8?= =?UTF-8?q?=E6=8B=96=E6=8B=BD=E8=B6=8A=E7=95=8C=E4=B8=8D=E8=87=AA=E5=8A=A8?= =?UTF-8?q?=E6=BB=9A=E5=8A=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 在 DataGrid 拖拽选区流程新增边缘自动滚动能力(横向+纵向) - 拖拽过程中增加鼠标位置跟踪并通过 RAF 循环驱动滚动 - 通过 elementFromPoint 兜底命中单元格,保证越界拖拽时选区持续更新 - 在 mouseup、模式切换和退出编辑器时统一清理 RAF 与拖拽状态 - refs #127 --- frontend/src/components/DataGrid.tsx | 192 ++++++++++++++++++++++----- 1 file changed, 158 insertions(+), 34 deletions(-) diff --git a/frontend/src/components/DataGrid.tsx b/frontend/src/components/DataGrid.tsx index 7dee7a7..6cfec4f 100644 --- a/frontend/src/components/DataGrid.tsx +++ b/frontend/src/components/DataGrid.tsx @@ -619,6 +619,8 @@ const DataGrid: React.FC = ({ // 使用 ref 来优化拖拽性能,完全避免状态更新 const cellSelectionRafRef = useRef(null); const cellSelectionScrollRafRef = useRef(null); + const cellSelectionAutoScrollRafRef = useRef(null); + const cellSelectionPointerRef = useRef<{ x: number; y: number } | null>(null); const isDraggingRef = useRef(false); // 导入预览 Modal 状态 @@ -1102,6 +1104,11 @@ const DataGrid: React.FC = ({ currentSelectionRef.current = new Set(); selectionStartRef.current = null; isDraggingRef.current = false; + cellSelectionPointerRef.current = null; + if (cellSelectionAutoScrollRafRef.current !== null) { + cancelAnimationFrame(cellSelectionAutoScrollRafRef.current); + cellSelectionAutoScrollRafRef.current = null; + } updateCellSelection(new Set()); }, [batchEditValue, batchEditSetNull, addedRows, modifiedRows, rowKeyStr, updateCellSelection]); @@ -1111,8 +1118,12 @@ const DataGrid: React.FC = ({ const container = containerRef.current; if (!container) return; + const EDGE_THRESHOLD_PX = 28; + const MIN_SCROLL_STEP = 8; + const MAX_SCROLL_STEP = 24; - const getCellInfo = (target: HTMLElement): { rowKey: string; colName: string } | null => { + const getCellInfo = (target: HTMLElement | null): { rowKey: string; colName: string } | null => { + if (!target) return null; const td = target.closest('td[data-row-key][data-col-name]') as HTMLElement; if (!td) return null; const rowKey = td.getAttribute('data-row-key'); @@ -1121,35 +1132,12 @@ const DataGrid: React.FC = ({ return { rowKey, colName }; }; - const onMouseDown = (e: MouseEvent) => { - const cellInfo = getCellInfo(e.target as HTMLElement); - if (!cellInfo) return; - - e.preventDefault(); - isDraggingRef.current = true; - const currentData = displayDataRef.current; - const nextRowIndexMap = new Map(); - currentData.forEach((r, idx) => { - const k = r?.[GONAVI_ROW_KEY]; - if (k === undefined) return; - nextRowIndexMap.set(String(k), idx); - }); - rowIndexMapRef.current = nextRowIndexMap; - - const startRowIndex = nextRowIndexMap.get(cellInfo.rowKey) ?? -1; - const startColIndex = columnIndexMap.get(cellInfo.colName) ?? -1; - selectionStartRef.current = { rowKey: cellInfo.rowKey, colName: cellInfo.colName, rowIndex: startRowIndex, colIndex: startColIndex }; - currentSelectionRef.current = new Set([makeCellKey(cellInfo.rowKey, cellInfo.colName)]); - updateCellSelection(currentSelectionRef.current); + const getCellInfoFromPoint = (x: number, y: number): { rowKey: string; colName: string } | null => { + const target = document.elementFromPoint(x, y) as HTMLElement | null; + return getCellInfo(target); }; - const onMouseMove = (e: MouseEvent) => { - if (!isDraggingRef.current || !selectionStartRef.current) return; - - const cellInfo = getCellInfo(e.target as HTMLElement); - if (!cellInfo) return; - - // 使用 RAF 节流 + const scheduleSelectionUpdate = (cellInfo: { rowKey: string; colName: string }) => { if (cellSelectionRafRef.current !== null) { cancelAnimationFrame(cellSelectionRafRef.current); } @@ -1188,9 +1176,124 @@ const DataGrid: React.FC = ({ }); }; + const stopAutoScroll = () => { + if (cellSelectionAutoScrollRafRef.current !== null) { + cancelAnimationFrame(cellSelectionAutoScrollRafRef.current); + cellSelectionAutoScrollRafRef.current = null; + } + }; + + const getScrollStep = (distanceToEdge: number): number => { + const ratio = Math.min(1, Math.max(0, distanceToEdge / EDGE_THRESHOLD_PX)); + return Math.round(MIN_SCROLL_STEP + (MAX_SCROLL_STEP - MIN_SCROLL_STEP) * ratio); + }; + + const autoScrollTick = () => { + if (!isDraggingRef.current || !selectionStartRef.current) { + stopAutoScroll(); + return; + } + + const pointer = cellSelectionPointerRef.current; + const tableBody = container.querySelector('.ant-table-body') as HTMLElement | null; + if (!pointer || !tableBody) { + cellSelectionAutoScrollRafRef.current = requestAnimationFrame(autoScrollTick); + return; + } + + const rect = tableBody.getBoundingClientRect(); + const maxScrollTop = Math.max(0, tableBody.scrollHeight - tableBody.clientHeight); + const maxScrollLeft = Math.max(0, tableBody.scrollWidth - tableBody.clientWidth); + let deltaY = 0; + let deltaX = 0; + + if (pointer.y < rect.top + EDGE_THRESHOLD_PX && tableBody.scrollTop > 0) { + const distance = rect.top + EDGE_THRESHOLD_PX - pointer.y; + deltaY = -getScrollStep(distance); + } else if (pointer.y > rect.bottom - EDGE_THRESHOLD_PX && tableBody.scrollTop < maxScrollTop) { + const distance = pointer.y - (rect.bottom - EDGE_THRESHOLD_PX); + deltaY = getScrollStep(distance); + } + + if (pointer.x < rect.left + EDGE_THRESHOLD_PX && tableBody.scrollLeft > 0) { + const distance = rect.left + EDGE_THRESHOLD_PX - pointer.x; + deltaX = -getScrollStep(distance); + } else if (pointer.x > rect.right - EDGE_THRESHOLD_PX && tableBody.scrollLeft < maxScrollLeft) { + const distance = pointer.x - (rect.right - EDGE_THRESHOLD_PX); + deltaX = getScrollStep(distance); + } + + let didScroll = false; + if (deltaY !== 0) { + const nextTop = Math.max(0, Math.min(maxScrollTop, tableBody.scrollTop + deltaY)); + if (nextTop !== tableBody.scrollTop) { + tableBody.scrollTop = nextTop; + didScroll = true; + } + } + + if (deltaX !== 0) { + const nextLeft = Math.max(0, Math.min(maxScrollLeft, tableBody.scrollLeft + deltaX)); + if (nextLeft !== tableBody.scrollLeft) { + tableBody.scrollLeft = nextLeft; + didScroll = true; + } + } + + if (didScroll) { + const cellInfo = getCellInfoFromPoint(pointer.x, pointer.y); + if (cellInfo) scheduleSelectionUpdate(cellInfo); + } + + cellSelectionAutoScrollRafRef.current = requestAnimationFrame(autoScrollTick); + }; + + const ensureAutoScroll = () => { + if (cellSelectionAutoScrollRafRef.current !== null) return; + cellSelectionAutoScrollRafRef.current = requestAnimationFrame(autoScrollTick); + }; + + const onMouseDown = (e: MouseEvent) => { + const target = e.target instanceof HTMLElement ? e.target : null; + const cellInfo = getCellInfo(target); + if (!cellInfo) return; + + e.preventDefault(); + isDraggingRef.current = true; + cellSelectionPointerRef.current = { x: e.clientX, y: e.clientY }; + const currentData = displayDataRef.current; + const nextRowIndexMap = new Map(); + currentData.forEach((r, idx) => { + const k = r?.[GONAVI_ROW_KEY]; + if (k === undefined) return; + nextRowIndexMap.set(String(k), idx); + }); + rowIndexMapRef.current = nextRowIndexMap; + + const startRowIndex = nextRowIndexMap.get(cellInfo.rowKey) ?? -1; + const startColIndex = columnIndexMap.get(cellInfo.colName) ?? -1; + selectionStartRef.current = { rowKey: cellInfo.rowKey, colName: cellInfo.colName, rowIndex: startRowIndex, colIndex: startColIndex }; + currentSelectionRef.current = new Set([makeCellKey(cellInfo.rowKey, cellInfo.colName)]); + updateCellSelection(currentSelectionRef.current); + ensureAutoScroll(); + }; + + const onMouseMove = (e: MouseEvent) => { + if (!isDraggingRef.current || !selectionStartRef.current) return; + cellSelectionPointerRef.current = { x: e.clientX, y: e.clientY }; + ensureAutoScroll(); + + const target = e.target instanceof HTMLElement ? e.target : null; + const cellInfo = getCellInfo(target) || getCellInfoFromPoint(e.clientX, e.clientY); + if (!cellInfo) return; + scheduleSelectionUpdate(cellInfo); + }; + const onMouseUp = () => { if (!isDraggingRef.current) return; isDraggingRef.current = false; + cellSelectionPointerRef.current = null; + stopAutoScroll(); if (cellSelectionRafRef.current !== null) { cancelAnimationFrame(cellSelectionRafRef.current); @@ -1231,6 +1334,8 @@ const DataGrid: React.FC = ({ cancelAnimationFrame(cellSelectionScrollRafRef.current); cellSelectionScrollRafRef.current = null; } + stopAutoScroll(); + cellSelectionPointerRef.current = null; isDraggingRef.current = false; }; }, [cellEditMode, columnNames, columnIndexMap, updateCellSelection]); @@ -2332,6 +2437,7 @@ const DataGrid: React.FC = ({ currentSelectionRef.current = new Set(); selectionStartRef.current = null; isDraggingRef.current = false; + cellSelectionPointerRef.current = null; if (cellSelectionRafRef.current !== null) { cancelAnimationFrame(cellSelectionRafRef.current); cellSelectionRafRef.current = null; @@ -2340,6 +2446,10 @@ const DataGrid: React.FC = ({ cancelAnimationFrame(cellSelectionScrollRafRef.current); cellSelectionScrollRafRef.current = null; } + if (cellSelectionAutoScrollRafRef.current !== null) { + cancelAnimationFrame(cellSelectionAutoScrollRafRef.current); + cellSelectionAutoScrollRafRef.current = null; + } updateCellSelection(new Set()); if (!next) setBatchEditModalOpen(false); message.info(next ? '已进入单元格编辑模式,可拖拽选择多个单元格' : '已退出单元格编辑模式'); @@ -2403,12 +2513,26 @@ const DataGrid: React.FC = ({ onChange={(val) => { const nextMode = String(val) as GridViewMode; if (nextMode === 'json' && cellEditMode) { - setCellEditMode(false); - setSelectedCells(new Set()); - currentSelectionRef.current = new Set(); - selectionStartRef.current = null; - updateCellSelection(new Set()); - } + setCellEditMode(false); + setSelectedCells(new Set()); + currentSelectionRef.current = new Set(); + selectionStartRef.current = null; + isDraggingRef.current = false; + cellSelectionPointerRef.current = null; + if (cellSelectionRafRef.current !== null) { + cancelAnimationFrame(cellSelectionRafRef.current); + cellSelectionRafRef.current = null; + } + if (cellSelectionScrollRafRef.current !== null) { + cancelAnimationFrame(cellSelectionScrollRafRef.current); + cellSelectionScrollRafRef.current = null; + } + if (cellSelectionAutoScrollRafRef.current !== null) { + cancelAnimationFrame(cellSelectionAutoScrollRafRef.current); + cellSelectionAutoScrollRafRef.current = null; + } + updateCellSelection(new Set()); + } if (nextMode === 'text') { const selectedKey = selectedRowKeys[0]; if (selectedKey !== undefined) {