feat(datagrid): 支持列宽随拖动实时调整 (#799)

## 背景

Issue #789 中,表数据与查询结果的列宽只在拖动结束后生效,拖动过程中表格内容不会随分隔线同步调整,操作反馈存在明显延迟。

## 变更点

- 在 requestAnimationFrame 内提交最新列宽,使表头与完整表体随拖动实时重排
- 合并高频鼠标移动事件并跳过重复像素宽度,降低无效状态更新
- 保留松手时的最终宽度提交,并增加拖动期间实时更新的交互回归测试

## 影响范围

- 影响共用 DataGrid 的表数据与查询结果列宽拖动交互
- 不涉及后端、数据库结构、连接配置或数据读写逻辑

## 验证

- npm run test -- src/components/DataGrid.layout.test.tsx
src/components/useDataGridColumnResize.interaction.test.tsx(38 passed)
- npx tsc --noEmit --pretty false
- npm run build
- 手工验证表数据与查询结果均可随拖动实时调整

Closes #789
This commit is contained in:
Syngnat
2026-07-31 16:31:47 +08:00
committed by GitHub
2 changed files with 61 additions and 21 deletions

View File

@@ -88,6 +88,12 @@ describe('useDataGridColumnResize interaction cleanup', () => {
expect(update({ name: 120 })).toEqual({ name: width });
};
const flushAnimationFrames = () => {
const callbacks = [...scheduledFrames.values()];
scheduledFrames.clear();
callbacks.forEach((callback) => callback(0));
};
beforeEach(() => {
vi.useFakeTimers();
scheduledFrames = new Map();
@@ -164,6 +170,17 @@ describe('useDataGridColumnResize interaction cleanup', () => {
expect(resize?.isResizingRef.current).toBe(false);
});
it('updates the complete table width on the animation frame while dragging', () => {
beginResize();
act(() => fakeDocument.dispatch('mousemove', { buttons: 1, clientX: 230 }));
expect(setColumnWidths).not.toHaveBeenCalled();
act(() => flushAnimationFrames());
expectLastWidthUpdate(150);
expect(ghost.style.transform).toBe('translateX(190px)');
});
it('self-heals when movement reports no pressed button', () => {
beginResize();

View File

@@ -11,12 +11,26 @@ const ROW_NUMBER_MIN_WIDTH = 28;
const ROW_NUMBER_MAX_WIDTH = 120;
type UseDataGridColumnResizeContext = Record<string, any>;
type ColumnResizeDragState = {
startX: number;
startWidth: number;
key: string;
containerLeft: number;
};
type ColumnResizeListeners = {
blur: () => void;
move: (event: MouseEvent) => void;
up: (event: MouseEvent) => void;
};
const resolveColumnResizeWidth = (dragState: ColumnResizeDragState, clientX: number): number => {
const deltaX = clientX - dragState.startX;
const isRowNumberColumn = dragState.key === GONAVI_ROW_NUMBER_COLUMN_KEY;
const minWidth = isRowNumberColumn ? ROW_NUMBER_MIN_WIDTH : MIN_DATA_TABLE_COLUMN_WIDTH;
const maxWidth = isRowNumberColumn ? ROW_NUMBER_MAX_WIDTH : Number.POSITIVE_INFINITY;
return Math.min(maxWidth, Math.max(minWidth, dragState.startWidth + deltaX));
};
export const useDataGridColumnResize = (ctx: UseDataGridColumnResizeContext) => {
const {
columnMetaMap,
@@ -33,12 +47,7 @@ export const useDataGridColumnResize = (ctx: UseDataGridColumnResizeContext) =>
showColumnType,
} = ctx;
const draggingRef = useRef<{
startX: number;
startWidth: number;
key: string;
containerLeft: number;
} | null>(null);
const draggingRef = useRef<ColumnResizeDragState | null>(null);
const ghostRef = useRef<HTMLDivElement>(null);
const resizeRafRef = useRef<number | null>(null);
const latestClientXRef = useRef<number | null>(null);
@@ -47,17 +56,34 @@ export const useDataGridColumnResize = (ctx: UseDataGridColumnResizeContext) =>
const resizeBodyStyleRef = useRef<{ cursor: string; userSelect: string } | null>(null);
const resizeListenersRef = useRef<ColumnResizeListeners | null>(null);
const setColumnWidthsRef = useRef(setColumnWidths);
const lastAppliedResizeWidthRef = useRef<number | null>(null);
const autoFitCanvasRef = useRef<HTMLCanvasElement | null>(null);
setColumnWidthsRef.current = setColumnWidths;
const flushGhostPosition = useCallback(() => {
resizeRafRef.current = null;
if (!draggingRef.current || !ghostRef.current) return;
if (latestClientXRef.current === null) return;
const relativeLeft = latestClientXRef.current - draggingRef.current.containerLeft;
ghostRef.current.style.transform = `translateX(${relativeLeft}px)`;
const applyResizeWidth = useCallback((dragState: ColumnResizeDragState, clientX: number, force = false) => {
const newWidth = resolveColumnResizeWidth(dragState, clientX);
if (!force && lastAppliedResizeWidthRef.current === newWidth) return;
lastAppliedResizeWidthRef.current = newWidth;
setColumnWidthsRef.current((prev: Record<string, number>) => (
prev[dragState.key] === newWidth
? prev
: { ...prev, [dragState.key]: newWidth }
));
}, []);
const flushResizeFrame = useCallback(() => {
resizeRafRef.current = null;
if (!draggingRef.current) return;
if (latestClientXRef.current === null) return;
const dragState = draggingRef.current;
const clientX = latestClientXRef.current;
if (ghostRef.current) {
const relativeLeft = clientX - dragState.containerLeft;
ghostRef.current.style.transform = `translateX(${relativeLeft}px)`;
}
applyResizeWidth(dragState, clientX);
}, [applyResizeWidth]);
const detachResizeListeners = useCallback(() => {
const listeners = resizeListenersRef.current;
if (!listeners) return;
@@ -110,14 +136,10 @@ export const useDataGridColumnResize = (ctx: UseDataGridColumnResizeContext) =>
if (commit && dragState) {
const finalClientX = Number.isFinite(clientX) ? clientX as number : latestClientX ?? dragState.startX;
const deltaX = finalClientX - dragState.startX;
const isRowNumberColumn = dragState.key === GONAVI_ROW_NUMBER_COLUMN_KEY;
const minWidth = isRowNumberColumn ? ROW_NUMBER_MIN_WIDTH : MIN_DATA_TABLE_COLUMN_WIDTH;
const maxWidth = isRowNumberColumn ? ROW_NUMBER_MAX_WIDTH : Number.POSITIVE_INFINITY;
const newWidth = Math.min(maxWidth, Math.max(minWidth, dragState.startWidth + deltaX));
setColumnWidthsRef.current((prev: Record<string, number>) => ({ ...prev, [dragState.key]: newWidth }));
applyResizeWidth(dragState, finalClientX, true);
}
}, [detachResizeListeners, restoreResizeBodyStyles]);
lastAppliedResizeWidthRef.current = null;
}, [applyResizeWidth, detachResizeListeners, restoreResizeBodyStyles]);
const handleResizeStart = useCallback((key: string) => (e: React.MouseEvent) => {
e.preventDefault();
@@ -137,6 +159,7 @@ export const useDataGridColumnResize = (ctx: UseDataGridColumnResizeContext) =>
});
const containerLeft = containerRef.current?.getBoundingClientRect().left ?? 0;
draggingRef.current = { startX, startWidth: currentWidth, key, containerLeft };
lastAppliedResizeWidthRef.current = currentWidth;
latestClientXRef.current = startX;
if (ghostRef.current && containerRef.current) {
@@ -153,7 +176,7 @@ export const useDataGridColumnResize = (ctx: UseDataGridColumnResizeContext) =>
return;
}
if (resizeRafRef.current !== null) return;
resizeRafRef.current = requestAnimationFrame(flushGhostPosition);
resizeRafRef.current = requestAnimationFrame(flushResizeFrame);
};
const handleUp = (event: MouseEvent) => finishResize(event.clientX);
const handleBlur = () => finishResize();
@@ -172,7 +195,7 @@ export const useDataGridColumnResize = (ctx: UseDataGridColumnResizeContext) =>
};
document.body.style.cursor = 'col-resize';
document.body.style.userSelect = 'none';
}, [columnWidths, containerRef, dataTableDensity, finishResize, flushGhostPosition]);
}, [columnWidths, containerRef, dataTableDensity, finishResize, flushResizeFrame]);
useEffect(() => () => {
finishResize(undefined, false, false);