diff --git a/frontend/src/components/DataGrid.tsx b/frontend/src/components/DataGrid.tsx index e60c74a..dadf20d 100644 --- a/frontend/src/components/DataGrid.tsx +++ b/frontend/src/components/DataGrid.tsx @@ -150,6 +150,7 @@ export const GONAVI_ROW_KEY = '__gonavi_row_key__'; // Cell key helpers for batch selection/fill. // Use a control character separator to avoid collisions with rowKey/columnName contents (e.g. `new-123`). const CELL_KEY_SEP = '\u0001'; +const CELL_SELECTION_DRAG_THRESHOLD_PX = 4; const DATE_TIME_CACHE_LIMIT = 2000; const TABLE_CELL_PREVIEW_MAX_CHARS = 240; const normalizedDateTimeCache = new Map(); @@ -1499,6 +1500,7 @@ const DataGrid: React.FC = ({ dataIndex: '', title: '', }); + const rootRef = useRef(null); const containerRef = useRef(null); const tableContainerRef = useRef(null); const tableScrollTargetsRef = useRef([]); @@ -1525,6 +1527,9 @@ const DataGrid: React.FC = ({ const cellSelectionScrollRafRef = useRef(null); const cellSelectionAutoScrollRafRef = useRef(null); const cellSelectionPointerRef = useRef<{ x: number; y: number } | null>(null); + const pendingCellSelectionStartRef = useRef<{ rowKey: string; colName: string; x: number; y: number } | null>(null); + const suppressCellSelectionClickRef = useRef(false); + const cellEditModeRef = useRef(false); const isDraggingRef = useRef(false); // 导入预览 Modal 状态 @@ -2022,8 +2027,8 @@ const DataGrid: React.FC = ({ .${gridId} .ant-table-tbody .ant-table-row.row-modified:hover > .ant-table-cell { background-color: ${rowModHover} !important; } .${gridId} .ant-table-tbody > tr.row-deleted:hover > td, .${gridId} .ant-table-tbody .ant-table-row.row-deleted:hover > .ant-table-cell { background-color: ${darkMode ? '#2a2a2a' : '#e8e8e8'} !important; } - .${gridId} .ant-table-tbody > tr > td[data-col-name], - .${gridId} .ant-table-tbody .ant-table-row > .ant-table-cell[data-col-name] { user-select: none; -webkit-user-select: none; cursor: crosshair; } + .${gridId}.cell-edit-mode .ant-table-tbody > tr > td[data-col-name], + .${gridId}.cell-edit-mode .ant-table-tbody .ant-table-row > .ant-table-cell[data-col-name] { user-select: none; -webkit-user-select: none; cursor: crosshair; } .${gridId} .ant-table-tbody > tr > td[data-cell-selected="true"], .${gridId} .ant-table-tbody .ant-table-row > .ant-table-cell[data-cell-selected="true"], .${gridId} [data-cell-selected="true"] { @@ -2624,6 +2629,10 @@ const DataGrid: React.FC = ({ const rowKeyStr = useCallback((k: React.Key) => String(k), []); + useEffect(() => { + cellEditModeRef.current = cellEditMode; + }, [cellEditMode]); + const columnIndexMap = useMemo(() => { const map = new Map(); displayColumnNames.forEach((name: string, idx: number) => map.set(name, idx)); @@ -2651,6 +2660,37 @@ const DataGrid: React.FC = ({ }); }, []); + const resetCellSelection = useCallback((clearState: boolean = true) => { + if (clearState) { + setSelectedCells(new Set()); + } + currentSelectionRef.current = new Set(); + selectionStartRef.current = null; + pendingCellSelectionStartRef.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()); + }, [updateCellSelection]); + + const closeCellEditMode = useCallback(() => { + setCellEditMode(false); + cellEditModeRef.current = false; + setBatchEditModalOpen(false); + resetCellSelection(); + }, [resetCellSelection]); + // 批量填充选中的单元格 const handleBatchFillCells = useCallback(() => { const cellsToFill = currentSelectionRef.current; @@ -2752,19 +2792,31 @@ const DataGrid: React.FC = ({ updateCellSelection(new Set()); }, [batchEditValue, batchEditSetNull, addedRows, modifiedRows, rowKeyStr, updateCellSelection]); - // 事件委托:在容器级别处理批量编辑模式的鼠标事件 + // 事件委托:在容器级别处理单元格拖选;未开启模式时,拖拽超过阈值会自动进入单元格编辑模式。 useEffect(() => { - if (!cellEditMode) return; - const container = containerRef.current; + if (!canModifyData || viewMode !== 'table') return; if (!container) return; const EDGE_THRESHOLD_PX = 28; const MIN_SCROLL_STEP = 8; const MAX_SCROLL_STEP = 24; - const getCellInfo = (target: HTMLElement | null): { rowKey: string; colName: string } | null => { + const isInteractiveTarget = (target: HTMLElement | null): boolean => { + if (!target) return false; + return !!target.closest('input, textarea, button, select, [contenteditable="true"], .ant-checkbox, .ant-picker, .ant-select, .ant-dropdown, .ant-modal'); + }; + + const getCellElement = (target: HTMLElement | null): HTMLElement | null => { if (!target) return null; const cell = target.closest('[data-row-key][data-col-name]') as HTMLElement; + if (!cell || !container.contains(cell)) return null; + const colName = cell.getAttribute('data-col-name'); + if (!colName || !isWritableResultColumn(colName, effectiveEditLocator)) return null; + return cell; + }; + + const getCellInfo = (target: HTMLElement | null): { rowKey: string; colName: string } | null => { + const cell = getCellElement(target); if (!cell) return null; const rowKey = cell.getAttribute('data-row-key'); const colName = cell.getAttribute('data-col-name'); @@ -2777,6 +2829,38 @@ const DataGrid: React.FC = ({ return getCellInfo(target); }; + const applySelectionUpdate = (cellInfo: { rowKey: string; colName: string }) => { + const start = selectionStartRef.current; + if (!start) return; + + const currentData = displayDataRef.current; + const rowIndexMap = rowIndexMapRef.current; + const startRowIndex = start.rowIndex; + const endRowIndex = rowIndexMap.get(cellInfo.rowKey) ?? -1; + if (startRowIndex === -1 || endRowIndex === -1) return; + + const startColIndex = start.colIndex; + const endColIndex = columnIndexMap.get(cellInfo.colName) ?? -1; + if (startColIndex === -1 || endColIndex === -1) return; + + const minRowIndex = Math.min(startRowIndex, endRowIndex); + const maxRowIndex = Math.max(startRowIndex, endRowIndex); + const minColIndex = Math.min(startColIndex, endColIndex); + const maxColIndex = Math.max(startColIndex, endColIndex); + + const newSelectedCells = new Set(); + for (let i = minRowIndex; i <= maxRowIndex; i++) { + const row = currentData[i]; + const rKey = String(row?.[GONAVI_ROW_KEY]); + for (let j = minColIndex; j <= maxColIndex; j++) { + newSelectedCells.add(makeCellKey(rKey, displayColumnNames[j])); + } + } + + currentSelectionRef.current = newSelectedCells; + updateCellSelection(newSelectedCells); + }; + const scheduleSelectionUpdate = (cellInfo: { rowKey: string; colName: string }) => { if (cellSelectionRafRef.current !== null) { cancelAnimationFrame(cellSelectionRafRef.current); @@ -2784,35 +2868,7 @@ const DataGrid: React.FC = ({ cellSelectionRafRef.current = requestAnimationFrame(() => { cellSelectionRafRef.current = null; - const start = selectionStartRef.current; - if (!start) return; - - const currentData = displayDataRef.current; - const rowIndexMap = rowIndexMapRef.current; - const startRowIndex = start.rowIndex; - const endRowIndex = rowIndexMap.get(cellInfo.rowKey) ?? -1; - if (startRowIndex === -1 || endRowIndex === -1) return; - - const startColIndex = start.colIndex; - const endColIndex = columnIndexMap.get(cellInfo.colName) ?? -1; - if (startColIndex === -1 || endColIndex === -1) return; - - const minRowIndex = Math.min(startRowIndex, endRowIndex); - const maxRowIndex = Math.max(startRowIndex, endRowIndex); - const minColIndex = Math.min(startColIndex, endColIndex); - const maxColIndex = Math.max(startColIndex, endColIndex); - - const newSelectedCells = new Set(); - for (let i = minRowIndex; i <= maxRowIndex; i++) { - const row = currentData[i]; - const rKey = String(row?.[GONAVI_ROW_KEY]); - for (let j = minColIndex; j <= maxColIndex; j++) { - newSelectedCells.add(makeCellKey(rKey, displayColumnNames[j])); - } - } - - currentSelectionRef.current = newSelectedCells; - updateCellSelection(newSelectedCells); + applySelectionUpdate(cellInfo); }); }; @@ -2893,14 +2949,16 @@ const DataGrid: React.FC = ({ 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(); + const beginCellSelection = (cellInfo: { rowKey: string; colName: string }, x: number, y: number) => { + if (!cellEditModeRef.current) { + cellEditModeRef.current = true; + setCellEditMode(true); + } + suppressCellSelectionClickRef.current = true; + pendingCellSelectionStartRef.current = null; isDraggingRef.current = true; - cellSelectionPointerRef.current = { x: e.clientX, y: e.clientY }; + cellSelectionPointerRef.current = { x, y }; + const currentData = displayDataRef.current; const nextRowIndexMap = new Map(); currentData.forEach((r, idx) => { @@ -2918,8 +2976,39 @@ const DataGrid: React.FC = ({ ensureAutoScroll(); }; + const onMouseDown = (e: MouseEvent) => { + if (e.button !== 0) return; + const target = e.target instanceof HTMLElement ? e.target : null; + if (isInteractiveTarget(target)) return; + const cellInfo = getCellInfo(target); + if (!cellInfo) return; + + if (cellEditModeRef.current) { + e.preventDefault(); + beginCellSelection(cellInfo, e.clientX, e.clientY); + return; + } + + pendingCellSelectionStartRef.current = { ...cellInfo, x: e.clientX, y: e.clientY }; + }; + const onMouseMove = (e: MouseEvent) => { + const pendingStart = pendingCellSelectionStartRef.current; + if (!isDraggingRef.current && pendingStart) { + const dx = e.clientX - pendingStart.x; + const dy = e.clientY - pendingStart.y; + if (Math.hypot(dx, dy) < CELL_SELECTION_DRAG_THRESHOLD_PX) return; + + e.preventDefault(); + beginCellSelection( + { rowKey: pendingStart.rowKey, colName: pendingStart.colName }, + e.clientX, + e.clientY, + ); + } + if (!isDraggingRef.current || !selectionStartRef.current) return; + e.preventDefault(); cellSelectionPointerRef.current = { x: e.clientX, y: e.clientY }; ensureAutoScroll(); @@ -2929,7 +3018,8 @@ const DataGrid: React.FC = ({ scheduleSelectionUpdate(cellInfo); }; - const onMouseUp = () => { + const onMouseUp = (e: MouseEvent) => { + pendingCellSelectionStartRef.current = null; if (!isDraggingRef.current) return; isDraggingRef.current = false; cellSelectionPointerRef.current = null; @@ -2940,11 +3030,22 @@ const DataGrid: React.FC = ({ cellSelectionRafRef.current = null; } + const target = e.target instanceof HTMLElement ? e.target : null; + const cellInfo = getCellInfo(target) || getCellInfoFromPoint(e.clientX, e.clientY); + if (cellInfo) applySelectionUpdate(cellInfo); + if (currentSelectionRef.current.size > 0) { setSelectedCells(new Set(currentSelectionRef.current)); } }; + const onClickCapture = (e: MouseEvent) => { + if (!suppressCellSelectionClickRef.current) return; + suppressCellSelectionClickRef.current = false; + e.preventDefault(); + e.stopPropagation(); + }; + const onScroll = () => { if (currentSelectionRef.current.size === 0) return; if (cellSelectionScrollRafRef.current !== null) { @@ -2958,12 +3059,14 @@ const DataGrid: React.FC = ({ container.addEventListener('mousedown', onMouseDown); container.addEventListener('mousemove', onMouseMove); + container.addEventListener('click', onClickCapture, true); container.addEventListener('scroll', onScroll, true); document.addEventListener('mouseup', onMouseUp); return () => { container.removeEventListener('mousedown', onMouseDown); container.removeEventListener('mousemove', onMouseMove); + container.removeEventListener('click', onClickCapture, true); container.removeEventListener('scroll', onScroll, true); document.removeEventListener('mouseup', onMouseUp); if (cellSelectionRafRef.current !== null) { @@ -2975,10 +3078,11 @@ const DataGrid: React.FC = ({ cellSelectionScrollRafRef.current = null; } stopAutoScroll(); + pendingCellSelectionStartRef.current = null; cellSelectionPointerRef.current = null; isDraggingRef.current = false; }; - }, [cellEditMode, displayColumnNames, columnIndexMap, updateCellSelection]); + }, [canModifyData, viewMode, displayColumnNames, columnIndexMap, effectiveEditLocator, updateCellSelection]); const handleCopySelectedColumnsFromRow = useCallback(() => { const activeSelection = currentSelectionRef.current.size > 0 ? currentSelectionRef.current : selectedCells; @@ -3743,25 +3847,7 @@ const DataGrid: React.FC = ({ const handleViewModeChange = useCallback((nextMode: GridViewMode) => { if (nextMode === 'json' && cellEditMode) { - 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()); + closeCellEditMode(); } if (nextMode === 'text') { @@ -3775,7 +3861,7 @@ const DataGrid: React.FC = ({ } setViewMode(nextMode); - }, [cellEditMode, mergedDisplayData, selectedRowKeys, rowKeyStr, updateCellSelection]); + }, [cellEditMode, mergedDisplayData, selectedRowKeys, rowKeyStr, closeCellEditMode]); const handleOpenContextMenuRowEditor = useCallback(() => { if (!canModifyData) return; @@ -4491,6 +4577,24 @@ const DataGrid: React.FC = ({ window.addEventListener('keydown', onKeyDown); return () => window.removeEventListener('keydown', onKeyDown); }, [cellEditMode, selectedCells, handleCopySelectedCellsToClipboard]); + + useEffect(() => { + if (!cellEditMode) return; + + const onPointerDown = (event: MouseEvent) => { + const root = rootRef.current; + const target = event.target instanceof Node ? event.target : null; + if (!root || !target || root.contains(target)) return; + if (target instanceof HTMLElement + && target.closest('.ant-modal, .ant-dropdown, .ant-select-dropdown, .ant-picker-dropdown, .ant-popover')) { + return; + } + closeCellEditMode(); + }; + + document.addEventListener('mousedown', onPointerDown); + return () => document.removeEventListener('mousedown', onPointerDown); + }, [cellEditMode, closeCellEditMode]); const getTargets = useCallback((clickedRecord: any) => { const selKeys = selectedRowKeysRef.current; @@ -5700,7 +5804,7 @@ const DataGrid: React.FC = ({ }, [pagination, onPageChange]); return ( -
+
{/* Toolbar + Filter Panel */}
@@ -5750,30 +5854,18 @@ const DataGrid: React.FC = ({ {selectedRowKeys.length > 0 && 已选 {selectedRowKeys.length}}