From 72423e80a896f7f44366d8041c1b1e889d96082e Mon Sep 17 00:00:00 2001 From: Syngnat Date: Tue, 14 Jul 2026 11:46:25 +0800 Subject: [PATCH] =?UTF-8?q?=F0=9F=90=9B=20fix(datagrid):=20=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D=20macOS=20=E6=A8=AA=E5=90=91=E6=BB=9A=E5=8A=A8?= =?UTF-8?q?=E6=8B=96=E6=8B=BD=E6=8A=96=E5=8A=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 拖动期间延迟虚拟表异步视觉重绘,减少重复样式写入 - 使用滚动空闲窗口保护外部原生滑块,避免偏移回写 - 松开拖拽后统一对齐表头、表体和滑块 - 补充横向滚动同步回归断言 --- .../src/components/DataGrid.layout.test.tsx | 19 ++- frontend/src/components/DataGrid.tsx | 122 +++++++++++++----- 2 files changed, 109 insertions(+), 32 deletions(-) diff --git a/frontend/src/components/DataGrid.layout.test.tsx b/frontend/src/components/DataGrid.layout.test.tsx index 6cf2c0a2..42cf8b6b 100644 --- a/frontend/src/components/DataGrid.layout.test.tsx +++ b/frontend/src/components/DataGrid.layout.test.tsx @@ -2603,6 +2603,11 @@ describe('DataGrid layout', () => { expect(source).toContain('const pendingExternalScrollLeftRef = useRef(null);'); expect(source).toContain('const externalScrollSequenceRef = useRef(0);'); expect(source).toContain('const externalScrollbarDraggingRef = useRef(false);'); + expect(source).toContain('const EXTERNAL_HORIZONTAL_SCROLL_IDLE_SETTLE_MS = 80;'); + expect(source).toContain('const externalScrollInteractionTimerRef = useRef(null);'); + expect(source).toContain('const externalScrollInteractionUntilRef = useRef(0);'); + expect(source).toContain('const isExternalScrollbarInteractionActive = useCallback(() => ('); + expect(source).toContain('const refreshExternalScrollbarInteraction = useCallback(() => {'); expect(source).toContain('const scheduleVirtualHorizontalWheel = useCallback'); expect(source).toContain('pendingTableHorizontalDeltaRef.current += delta;'); expect(source).toContain('tableHorizontalWheelRafRef.current = requestAnimationFrame'); @@ -2611,14 +2616,24 @@ describe('DataGrid layout', () => { expect(source).toContain('applyVirtualHorizontalOffset(tableContainer, nextLeft, { forceInternalScroll: true });'); expect(source).toContain('}, [horizontalScrollVisible, scheduleVirtualHorizontalAlignment, tableRenderData, tableScrollX, virtualEditingCell]);'); expect(source).toContain('tableInstance.scrollTo({ left: clampedOffset, top: holderEl.scrollTop });'); + expect(source).toContain('deferPostScrollVisualReassert?: boolean'); + expect(source).toContain('if (!options?.deferPostScrollVisualReassert) {'); expect(source).toContain('const requestedExternalScrollLeft = pendingExternalScrollLeftRef.current ?? latestExternalScroll.scrollLeft;'); - expect(source).toContain('applyVirtualHorizontalOffset(tableContainer, requestedExternalScrollLeft, { forceInternalScroll: true });'); + expect(source).toContain('deferPostScrollVisualReassert: true'); + expect(source).toContain('if (isExternalScrollbarInteractionActive()) {'); + expect(source).toContain('syncVirtualHorizontalVisualOffset(tableContainer, resolvedScrollLeft);'); expect(source).not.toContain('const synced = syncVirtualHorizontalVisualOffset(tableContainer, externalScroll.scrollLeft);'); expect(source).toContain('const handleExternalHorizontalScrollPointerDown = useCallback'); expect(source).toContain('const handleExternalHorizontalScrollPointerRelease = useCallback'); expect(source).toContain('const finishExternalScrollbarDrag = useCallback'); expect(source).toContain('const handleExternalHorizontalScrollLostPointerCapture = useCallback'); - expect(source).toContain('if (externalScrollbarDraggingRef.current) {'); + expect(source).toContain('externalScrollbarDraggingRef.current\n || Date.now() < externalScrollInteractionUntilRef.current'); + const finishExternalScrollbarDragSource = source.slice( + source.indexOf('const finishExternalScrollbarDrag = useCallback'), + source.indexOf('const handleExternalHorizontalScrollPointerRelease = useCallback'), + ); + expect(finishExternalScrollbarDragSource).toContain('if (isExternalScrollbarInteractionActive()) {'); + expect(finishExternalScrollbarDragSource).not.toContain('clearExternalScrollbarInteraction();'); expect(source).toContain('onPointerDown={handleExternalHorizontalScrollPointerDown}'); expect(source).toContain('onPointerUp={handleExternalHorizontalScrollPointerRelease}'); expect(source).toContain('onPointerCancel={handleExternalHorizontalScrollPointerRelease}'); diff --git a/frontend/src/components/DataGrid.tsx b/frontend/src/components/DataGrid.tsx index e39b41c7..db5ae077 100644 --- a/frontend/src/components/DataGrid.tsx +++ b/frontend/src/components/DataGrid.tsx @@ -283,6 +283,11 @@ export { buildGridFieldSelectOptions, buildDataGridCommitChangeSet, } from './DataGridCore'; + +// Native scroll events can outlive a pointer gesture on macOS. Wait for a brief +// idle window before the virtual table performs its final visual correction. +const EXTERNAL_HORIZONTAL_SCROLL_IDLE_SETTLE_MS = 80; + const DataGrid: React.FC = ({ data, columnNames, loading, tableName, columnPinScope, objectType = 'table', exportScope = 'table', dbName, connectionId, pkColumns = [], editLocator, readOnly = false, resultSql, @@ -817,6 +822,8 @@ const DataGrid: React.FC = ({ const pendingExternalScrollLeftRef = useRef(null); const externalScrollSequenceRef = useRef(0); const externalScrollbarDraggingRef = useRef(false); + const externalScrollInteractionTimerRef = useRef(null); + const externalScrollInteractionUntilRef = useRef(0); const tableTargetSyncRafRef = useRef(null); const tableHorizontalWheelRafRef = useRef(null); const virtualHorizontalAlignmentRafRef = useRef(null); @@ -890,6 +897,11 @@ const DataGrid: React.FC = ({ pendingTableHorizontalDeltaRef.current = 0; pendingExternalScrollLeftRef.current = null; externalScrollbarDraggingRef.current = false; + if (externalScrollInteractionTimerRef.current !== null) { + clearTimeout(externalScrollInteractionTimerRef.current); + externalScrollInteractionTimerRef.current = null; + } + externalScrollInteractionUntilRef.current = 0; horizontalSyncSourceRef.current = ''; pendingTableTargetSyncSourceRef.current = null; }, []); @@ -3548,7 +3560,11 @@ const DataGrid: React.FC = ({ return { holderEl, clampedOffset, currentOffset }; }, [resolveVirtualHorizontalElements, tableScrollX]); - const applyVirtualHorizontalOffset = useCallback((tableContainer: HTMLElement, nextOffset: number, options?: { forceInternalScroll?: boolean }) => { + const applyVirtualHorizontalOffset = useCallback(( + tableContainer: HTMLElement, + nextOffset: number, + options?: { forceInternalScroll?: boolean; deferPostScrollVisualReassert?: boolean }, + ) => { const synced = syncVirtualHorizontalVisualOffset(tableContainer, nextOffset); if (!synced) { return false; @@ -3561,16 +3577,21 @@ const DataGrid: React.FC = ({ const reassertVisual = () => { syncVirtualHorizontalVisualOffset(tableContainer, clampedOffset); }; + const scheduleVisualReassert = () => { + if (!options?.deferPostScrollVisualReassert) { + requestAnimationFrame(() => { + reassertVisual(); + window.setTimeout(reassertVisual, 0); + }); + } + }; const tableInstance = tableRef.current; if (tableInstance && typeof tableInstance.scrollTo === 'function') { // 更新 rc-virtual-list 内部 offsetLeft tableInstance.scrollTo({ left: clampedOffset, top: holderEl.scrollTop }); // antd onInternalScroll 会 forceScroll(header.scrollLeft),需在其后清掉并重申 CSS 变量 - requestAnimationFrame(() => { - reassertVisual(); - window.setTimeout(reassertVisual, 0); - }); + scheduleVisualReassert(); return true; } @@ -3581,10 +3602,7 @@ const DataGrid: React.FC = ({ bubbles: true, cancelable: true, })); - requestAnimationFrame(() => { - reassertVisual(); - window.setTimeout(reassertVisual, 0); - }); + scheduleVisualReassert(); return true; }, [syncVirtualHorizontalVisualOffset]); @@ -3767,12 +3785,25 @@ const DataGrid: React.FC = ({ }, 1600); }, []); + const isExternalScrollbarInteractionActive = useCallback(() => ( + externalScrollbarDraggingRef.current + || Date.now() < externalScrollInteractionUntilRef.current + ), []); + + const clearExternalScrollbarInteraction = useCallback(() => { + if (externalScrollInteractionTimerRef.current !== null) { + clearTimeout(externalScrollInteractionTimerRef.current); + externalScrollInteractionTimerRef.current = null; + } + externalScrollInteractionUntilRef.current = 0; + }, []); + const syncExternalScrollFromTargets = useCallback((targets?: HTMLElement[], source?: HTMLElement | null) => { const externalScroll = externalHorizontalScrollRef.current; if ( !(externalScroll instanceof HTMLDivElement) || horizontalSyncSourceRef.current === 'external' - || externalScrollbarDraggingRef.current + || isExternalScrollbarInteractionActive() ) { return; } @@ -3806,10 +3837,10 @@ const DataGrid: React.FC = ({ externalScroll.scrollLeft = nextScrollLeft; lastExternalScrollLeftRef.current = nextScrollLeft; } - }, [enableVirtual, readVirtualHorizontalOffset]); + }, [enableVirtual, isExternalScrollbarInteractionActive, readVirtualHorizontalOffset]); const scheduleSyncExternalScrollFromTargets = useCallback((source?: HTMLElement | null) => { - if (externalScrollbarDraggingRef.current) { + if (isExternalScrollbarInteractionActive()) { return; } pendingTableTargetSyncSourceRef.current = source ?? null; @@ -3820,14 +3851,14 @@ const DataGrid: React.FC = ({ tableTargetSyncRafRef.current = null; const pendingSource = pendingTableTargetSyncSourceRef.current; pendingTableTargetSyncSourceRef.current = null; - if (horizontalSyncSourceRef.current === 'external' || externalScrollbarDraggingRef.current) { + if (horizontalSyncSourceRef.current === 'external' || isExternalScrollbarInteractionActive()) { return; } horizontalSyncSourceRef.current = 'table'; syncExternalScrollFromTargets(undefined, pendingSource); horizontalSyncSourceRef.current = ''; }); - }, [syncExternalScrollFromTargets]); + }, [isExternalScrollbarInteractionActive, syncExternalScrollFromTargets]); const scheduleExternalHorizontalScrollSettle = useCallback((syncSequence: number) => { if (externalScrollSettleRafRef.current !== null) { @@ -3837,7 +3868,7 @@ const DataGrid: React.FC = ({ externalScrollSettleRafRef.current = null; if (externalScrollSequenceRef.current !== syncSequence) { if ( - !externalScrollbarDraggingRef.current + !isExternalScrollbarInteractionActive() && externalSyncRafRef.current === null && pendingExternalScrollLeftRef.current === null ) { @@ -3853,25 +3884,44 @@ const DataGrid: React.FC = ({ } const tableContainer = tableContainerRef.current; - const resolvedScrollLeft = enableVirtual && tableContainer instanceof HTMLElement - ? readVirtualHorizontalOffset(tableContainer) - : lastTableScrollLeftRef.current; - lastTableScrollLeftRef.current = resolvedScrollLeft; - - // 原生滑块仍在拖动时,rc-virtual-list 的异步滚动结果只能更新表格侧, - // 不能反写到正在由浏览器控制的 thumb。 - if (externalScrollbarDraggingRef.current) { + if (isExternalScrollbarInteractionActive()) { lastExternalScrollLeftRef.current = latestExternalScroll.scrollLeft; return; } + let resolvedScrollLeft = enableVirtual && tableContainer instanceof HTMLElement + ? readVirtualHorizontalOffset(tableContainer) + : lastTableScrollLeftRef.current; + if (enableVirtual && tableContainer instanceof HTMLElement) { + const synced = syncVirtualHorizontalVisualOffset(tableContainer, resolvedScrollLeft); + if (synced) { + resolvedScrollLeft = synced.clampedOffset; + } + } + lastTableScrollLeftRef.current = resolvedScrollLeft; + if (Math.abs(latestExternalScroll.scrollLeft - resolvedScrollLeft) > 1) { latestExternalScroll.scrollLeft = resolvedScrollLeft; } lastExternalScrollLeftRef.current = latestExternalScroll.scrollLeft; horizontalSyncSourceRef.current = ''; }); - }, [enableVirtual, readVirtualHorizontalOffset]); + }, [enableVirtual, isExternalScrollbarInteractionActive, readVirtualHorizontalOffset, syncVirtualHorizontalVisualOffset]); + + const refreshExternalScrollbarInteraction = useCallback(() => { + externalScrollInteractionUntilRef.current = Date.now() + EXTERNAL_HORIZONTAL_SCROLL_IDLE_SETTLE_MS; + if (externalScrollInteractionTimerRef.current !== null) { + clearTimeout(externalScrollInteractionTimerRef.current); + } + externalScrollInteractionTimerRef.current = window.setTimeout(() => { + externalScrollInteractionTimerRef.current = null; + if (Date.now() < externalScrollInteractionUntilRef.current || externalScrollbarDraggingRef.current) { + return; + } + externalScrollInteractionUntilRef.current = 0; + scheduleExternalHorizontalScrollSettle(externalScrollSequenceRef.current); + }, EXTERNAL_HORIZONTAL_SCROLL_IDLE_SETTLE_MS); + }, [scheduleExternalHorizontalScrollSettle]); const applyExternalScrollToTableTargets = useCallback(() => { const externalScroll = externalHorizontalScrollRef.current; @@ -3890,6 +3940,7 @@ const DataGrid: React.FC = ({ pendingExternalScrollLeftRef.current = nextExternalScrollLeft; lastExternalScrollLeftRef.current = nextExternalScrollLeft; externalScrollSequenceRef.current += 1; + refreshExternalScrollbarInteraction(); if (externalSyncRafRef.current !== null) { return; } @@ -3908,9 +3959,12 @@ const DataGrid: React.FC = ({ const requestedExternalScrollLeft = pendingExternalScrollLeftRef.current ?? latestExternalScroll.scrollLeft; pendingExternalScrollLeftRef.current = null; const tableContainer = tableContainerRef.current; - // 每一帧只消费最后一个原生拖拽位置,避免频繁布局写入压垮 Windows WebView2。 + // 外部滑块每帧只更新一次;视觉补偿在本轮拖拽结束后再统一校正。 if (enableVirtual && tableContainer instanceof HTMLElement) { - const applied = applyVirtualHorizontalOffset(tableContainer, requestedExternalScrollLeft, { forceInternalScroll: true }); + const applied = applyVirtualHorizontalOffset(tableContainer, requestedExternalScrollLeft, { + forceInternalScroll: true, + deferPostScrollVisualReassert: true, + }); if (applied) { scheduleExternalHorizontalScrollSettle(syncSequence); return; @@ -3943,13 +3997,14 @@ const DataGrid: React.FC = ({ lastTableScrollLeftRef.current = requestedExternalScrollLeft; scheduleExternalHorizontalScrollSettle(syncSequence); }); - }, [applyVirtualHorizontalOffset, enableVirtual, scheduleExternalHorizontalScrollSettle]); + }, [applyVirtualHorizontalOffset, enableVirtual, refreshExternalScrollbarInteraction, scheduleExternalHorizontalScrollSettle]); const handleExternalHorizontalScrollPointerDown = useCallback((event: React.PointerEvent) => { + clearExternalScrollbarInteraction(); externalScrollbarDraggingRef.current = true; horizontalSyncSourceRef.current = 'external'; event.currentTarget.setPointerCapture?.(event.pointerId); - }, []); + }, [clearExternalScrollbarInteraction]); const finishExternalScrollbarDrag = useCallback(() => { if (!externalScrollbarDraggingRef.current) { @@ -3957,6 +4012,12 @@ const DataGrid: React.FC = ({ } externalScrollbarDraggingRef.current = false; + // 保留最后一个 native scroll 建立的空闲窗口,避免 pointerup 早于最后一帧 + // rc-virtual-list 更新时又把正在停止的 thumb 拉回旧位置。 + if (isExternalScrollbarInteractionActive()) { + return; + } + // 正在排队的外部同步会自行结算;没有待处理任务时才立即安排最终对齐。 if ( externalSyncRafRef.current === null @@ -3965,7 +4026,7 @@ const DataGrid: React.FC = ({ ) { scheduleExternalHorizontalScrollSettle(externalScrollSequenceRef.current); } - }, [scheduleExternalHorizontalScrollSettle]); + }, [isExternalScrollbarInteractionActive, scheduleExternalHorizontalScrollSettle]); const handleExternalHorizontalScrollPointerRelease = useCallback((event: React.PointerEvent) => { if (event.currentTarget.hasPointerCapture?.(event.pointerId)) { @@ -4114,9 +4175,10 @@ const DataGrid: React.FC = ({ } pendingExternalScrollLeftRef.current = null; externalScrollbarDraggingRef.current = false; + clearExternalScrollbarInteraction(); horizontalSyncSourceRef.current = ''; }; - }, [finishExternalScrollbarDrag, horizontalScrollVisible]); + }, [clearExternalScrollbarInteraction, finishExternalScrollbarDrag, horizontalScrollVisible]); // 支持在数据区直接使用触摸板/Shift+滚轮进行横向滚动。 // 虚拟表格与普通表格统一走外部横向滚动条,避免内部轨道覆盖最后一行。