From 689467ec91352f249b3d5254f9a416318feebcf1 Mon Sep 17 00:00:00 2001 From: Syngnat Date: Fri, 10 Jul 2026 18:10:27 +0800 Subject: [PATCH] =?UTF-8?q?=F0=9F=90=9B=20fix(datagrid):=20=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D=E7=BB=93=E6=9E=9C=E6=A8=AA=E5=90=91=E6=BB=9A=E5=8A=A8?= =?UTF-8?q?=E6=9D=A1=E6=8B=96=E6=8B=BD=E5=8D=A1=E9=A1=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 合并同一帧内的外部横向滚动位置,避免每个原生事件触发布局写入 - 拖拽期间禁止虚拟表异步偏移反写外部滑块,松开后统一对齐 - 覆盖丢失指针捕获、窗口失焦、结果集隐藏和卸载时的同步状态清理 - 补充横向滚动同步回归断言 --- .../src/components/DataGrid.layout.test.tsx | 16 +- frontend/src/components/DataGrid.tsx | 218 ++++++++++++------ frontend/src/components/DataGridShell.tsx | 7 + 3 files changed, 175 insertions(+), 66 deletions(-) diff --git a/frontend/src/components/DataGrid.layout.test.tsx b/frontend/src/components/DataGrid.layout.test.tsx index ef2b9927..8ae4360d 100644 --- a/frontend/src/components/DataGrid.layout.test.tsx +++ b/frontend/src/components/DataGrid.layout.test.tsx @@ -2586,6 +2586,9 @@ describe('DataGrid layout', () => { expect(source).toContain('const tableRef = useRef(null);'); expect(source).toContain('resolveDataGridHorizontalWheelDelta({'); expect(source).toContain('const virtualHorizontalAlignmentRafRef = useRef(null);'); + 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 scheduleVirtualHorizontalWheel = useCallback'); expect(source).toContain('pendingTableHorizontalDeltaRef.current += delta;'); expect(source).toContain('tableHorizontalWheelRafRef.current = requestAnimationFrame'); @@ -2594,7 +2597,18 @@ 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('applyVirtualHorizontalOffset(tableContainer, latestExternalScroll.scrollLeft, { forceInternalScroll: true });'); + expect(source).toContain('const requestedExternalScrollLeft = pendingExternalScrollLeftRef.current ?? latestExternalScroll.scrollLeft;'); + expect(source).toContain('applyVirtualHorizontalOffset(tableContainer, requestedExternalScrollLeft, { forceInternalScroll: true });'); + 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('onPointerDown={handleExternalHorizontalScrollPointerDown}'); + expect(source).toContain('onPointerUp={handleExternalHorizontalScrollPointerRelease}'); + expect(source).toContain('onPointerCancel={handleExternalHorizontalScrollPointerRelease}'); + expect(source).toContain('onLostPointerCapture={handleExternalHorizontalScrollLostPointerCapture}'); expect(source).toContain('if (externalSyncRafRef.current !== null)'); expect(source).toContain('externalSyncRafRef.current = requestAnimationFrame'); expect(source).toContain('const scheduleSyncExternalScrollFromTargets = useCallback'); diff --git a/frontend/src/components/DataGrid.tsx b/frontend/src/components/DataGrid.tsx index 2051f189..7dbea4e7 100644 --- a/frontend/src/components/DataGrid.tsx +++ b/frontend/src/components/DataGrid.tsx @@ -812,6 +812,10 @@ const DataGrid: React.FC = ({ const lastTableScrollLeftRef = useRef(0); const lastExternalScrollLeftRef = useRef(0); const externalSyncRafRef = useRef(null); + const externalScrollSettleRafRef = useRef(null); + const pendingExternalScrollLeftRef = useRef(null); + const externalScrollSequenceRef = useRef(0); + const externalScrollbarDraggingRef = useRef(false); const tableTargetSyncRafRef = useRef(null); const tableHorizontalWheelRafRef = useRef(null); const virtualHorizontalAlignmentRafRef = useRef(null); @@ -866,6 +870,10 @@ const DataGrid: React.FC = ({ cancelAnimationFrame(externalSyncRafRef.current); externalSyncRafRef.current = null; } + if (externalScrollSettleRafRef.current !== null) { + cancelAnimationFrame(externalScrollSettleRafRef.current); + externalScrollSettleRafRef.current = null; + } if (tableTargetSyncRafRef.current !== null) { cancelAnimationFrame(tableTargetSyncRafRef.current); tableTargetSyncRafRef.current = null; @@ -879,6 +887,9 @@ const DataGrid: React.FC = ({ scrollSnapshotRafRef.current = null; } pendingTableHorizontalDeltaRef.current = 0; + pendingExternalScrollLeftRef.current = null; + externalScrollbarDraggingRef.current = false; + horizontalSyncSourceRef.current = ''; pendingTableTargetSyncSourceRef.current = null; }, []); @@ -3725,7 +3736,11 @@ const DataGrid: React.FC = ({ const syncExternalScrollFromTargets = useCallback((targets?: HTMLElement[], source?: HTMLElement | null) => { const externalScroll = externalHorizontalScrollRef.current; - if (!(externalScroll instanceof HTMLDivElement) || horizontalSyncSourceRef.current === 'external') { + if ( + !(externalScroll instanceof HTMLDivElement) + || horizontalSyncSourceRef.current === 'external' + || externalScrollbarDraggingRef.current + ) { return; } const tableContainer = tableContainerRef.current; @@ -3761,6 +3776,9 @@ const DataGrid: React.FC = ({ }, [enableVirtual, readVirtualHorizontalOffset]); const scheduleSyncExternalScrollFromTargets = useCallback((source?: HTMLElement | null) => { + if (externalScrollbarDraggingRef.current) { + return; + } pendingTableTargetSyncSourceRef.current = source ?? null; if (tableTargetSyncRafRef.current !== null) { return; @@ -3769,7 +3787,7 @@ const DataGrid: React.FC = ({ tableTargetSyncRafRef.current = null; const pendingSource = pendingTableTargetSyncSourceRef.current; pendingTableTargetSyncSourceRef.current = null; - if (horizontalSyncSourceRef.current === 'external') { + if (horizontalSyncSourceRef.current === 'external' || externalScrollbarDraggingRef.current) { return; } horizontalSyncSourceRef.current = 'table'; @@ -3778,39 +3796,23 @@ const DataGrid: React.FC = ({ }); }, [syncExternalScrollFromTargets]); - const applyExternalScrollToTableTargets = useCallback(() => { - const externalScroll = externalHorizontalScrollRef.current; - if (!(externalScroll instanceof HTMLDivElement)) { - return; + const scheduleExternalHorizontalScrollSettle = useCallback((syncSequence: number) => { + if (externalScrollSettleRafRef.current !== null) { + cancelAnimationFrame(externalScrollSettleRafRef.current); } - if (horizontalSyncSourceRef.current === 'table') { - return; - } - - const tableContainer = tableContainerRef.current; - let nextExternalScrollLeft = externalScroll.scrollLeft; - if (enableVirtual && tableContainer instanceof HTMLElement) { - const synced = syncVirtualHorizontalVisualOffset(tableContainer, externalScroll.scrollLeft); - if (synced) { - nextExternalScrollLeft = synced.clampedOffset; - lastTableScrollLeftRef.current = synced.clampedOffset; - if (Math.abs(externalScroll.scrollLeft - synced.clampedOffset) > 1) { - externalScroll.scrollLeft = synced.clampedOffset; + externalScrollSettleRafRef.current = requestAnimationFrame(() => { + externalScrollSettleRafRef.current = null; + if (externalScrollSequenceRef.current !== syncSequence) { + if ( + !externalScrollbarDraggingRef.current + && externalSyncRafRef.current === null + && pendingExternalScrollLeftRef.current === null + ) { + horizontalSyncSourceRef.current = ''; } + return; } - } - if (Math.abs(lastExternalScrollLeftRef.current - nextExternalScrollLeft) < 1) { - return; - } - lastExternalScrollLeftRef.current = nextExternalScrollLeft; - if (externalSyncRafRef.current !== null) { - return; - } - - horizontalSyncSourceRef.current = 'external'; - externalSyncRafRef.current = requestAnimationFrame(() => { - externalSyncRafRef.current = null; const latestExternalScroll = externalHorizontalScrollRef.current; if (!(latestExternalScroll instanceof HTMLDivElement)) { horizontalSyncSourceRef.current = ''; @@ -3818,56 +3820,130 @@ const DataGrid: React.FC = ({ } const tableContainer = tableContainerRef.current; - // 虚拟表格路径:通过合成 WheelEvent 驱动 rc-virtual-list 内部状态, - // rc-table 自动同步 header scrollLeft。 + const resolvedScrollLeft = enableVirtual && tableContainer instanceof HTMLElement + ? readVirtualHorizontalOffset(tableContainer) + : lastTableScrollLeftRef.current; + lastTableScrollLeftRef.current = resolvedScrollLeft; + + // 原生滑块仍在拖动时,rc-virtual-list 的异步滚动结果只能更新表格侧, + // 不能反写到正在由浏览器控制的 thumb。 + if (externalScrollbarDraggingRef.current) { + lastExternalScrollLeftRef.current = latestExternalScroll.scrollLeft; + return; + } + + if (Math.abs(latestExternalScroll.scrollLeft - resolvedScrollLeft) > 1) { + latestExternalScroll.scrollLeft = resolvedScrollLeft; + } + lastExternalScrollLeftRef.current = latestExternalScroll.scrollLeft; + horizontalSyncSourceRef.current = ''; + }); + }, [enableVirtual, readVirtualHorizontalOffset]); + + const applyExternalScrollToTableTargets = useCallback(() => { + const externalScroll = externalHorizontalScrollRef.current; + if (!(externalScroll instanceof HTMLDivElement) || horizontalSyncSourceRef.current === 'table') { + return; + } + + const nextExternalScrollLeft = Math.max(0, externalScroll.scrollLeft); + if ( + Math.abs(lastExternalScrollLeftRef.current - nextExternalScrollLeft) < 1 + && pendingExternalScrollLeftRef.current === null + ) { + return; + } + + pendingExternalScrollLeftRef.current = nextExternalScrollLeft; + lastExternalScrollLeftRef.current = nextExternalScrollLeft; + externalScrollSequenceRef.current += 1; + if (externalSyncRafRef.current !== null) { + return; + } + + horizontalSyncSourceRef.current = 'external'; + externalSyncRafRef.current = requestAnimationFrame(() => { + externalSyncRafRef.current = null; + const syncSequence = externalScrollSequenceRef.current; + const latestExternalScroll = externalHorizontalScrollRef.current; + if (!(latestExternalScroll instanceof HTMLDivElement)) { + pendingExternalScrollLeftRef.current = null; + horizontalSyncSourceRef.current = ''; + return; + } + + const requestedExternalScrollLeft = pendingExternalScrollLeftRef.current ?? latestExternalScroll.scrollLeft; + pendingExternalScrollLeftRef.current = null; + const tableContainer = tableContainerRef.current; + // 每一帧只消费最后一个原生拖拽位置,避免频繁布局写入压垮 Windows WebView2。 if (enableVirtual && tableContainer instanceof HTMLElement) { - const applied = applyVirtualHorizontalOffset(tableContainer, latestExternalScroll.scrollLeft, { forceInternalScroll: true }); + const applied = applyVirtualHorizontalOffset(tableContainer, requestedExternalScrollLeft, { forceInternalScroll: true }); if (applied) { - // WheelEvent 经 rc-virtual-list 处理后状态异步更新,延迟同步 ref - requestAnimationFrame(() => { - const resolvedScrollLeft = readVirtualHorizontalOffset(tableContainer); - lastTableScrollLeftRef.current = resolvedScrollLeft; - if (Math.abs(latestExternalScroll.scrollLeft - resolvedScrollLeft) > 1) { - latestExternalScroll.scrollLeft = resolvedScrollLeft; - } - lastExternalScrollLeftRef.current = resolvedScrollLeft; - horizontalSyncSourceRef.current = ''; - }); + scheduleExternalHorizontalScrollSettle(syncSequence); return; } - // 空数据回退:virtual-holder 不存在时,直接滚动表头 + + // 空数据回退:virtual-holder 不存在时,直接滚动表头。 const headerEl = tableContainer.querySelector('.ant-table-header') as HTMLElement | null; const contentEl = tableContainer.querySelector('.ant-table-content') as HTMLElement | null; const fallbackTargets = [headerEl, contentEl].filter((el): el is HTMLElement => el instanceof HTMLElement && el.scrollWidth > el.clientWidth + 1); - if (fallbackTargets.length > 0) { - fallbackTargets.forEach((target) => { - target.scrollLeft = latestExternalScroll.scrollLeft; - }); - lastTableScrollLeftRef.current = latestExternalScroll.scrollLeft; - horizontalSyncSourceRef.current = ''; - return; - } - horizontalSyncSourceRef.current = ''; + fallbackTargets.forEach((target) => { + if (Math.abs(target.scrollLeft - requestedExternalScrollLeft) > 1) { + target.scrollLeft = requestedExternalScrollLeft; + } + }); + lastTableScrollLeftRef.current = requestedExternalScrollLeft; + scheduleExternalHorizontalScrollSettle(syncSequence); return; } - // 非虚拟表格路径:依赖 liveTargets 进行 scrollLeft 同步 + + // 非虚拟表格路径:依赖 liveTargets 进行 scrollLeft 同步。 const liveTargets = tableScrollTargetsRef.current; - if (liveTargets.length === 0) { - horizontalSyncSourceRef.current = ''; - return; - } liveTargets.forEach((target) => { if (target.scrollWidth <= target.clientWidth + 1) { return; } - if (Math.abs(target.scrollLeft - latestExternalScroll.scrollLeft) > 1) { - target.scrollLeft = latestExternalScroll.scrollLeft; + if (Math.abs(target.scrollLeft - requestedExternalScrollLeft) > 1) { + target.scrollLeft = requestedExternalScrollLeft; } }); - lastTableScrollLeftRef.current = latestExternalScroll.scrollLeft; - horizontalSyncSourceRef.current = ''; + lastTableScrollLeftRef.current = requestedExternalScrollLeft; + scheduleExternalHorizontalScrollSettle(syncSequence); }); - }, [applyVirtualHorizontalOffset, enableVirtual, readVirtualHorizontalOffset, syncVirtualHorizontalVisualOffset]); + }, [applyVirtualHorizontalOffset, enableVirtual, scheduleExternalHorizontalScrollSettle]); + + const handleExternalHorizontalScrollPointerDown = useCallback((event: React.PointerEvent) => { + externalScrollbarDraggingRef.current = true; + horizontalSyncSourceRef.current = 'external'; + event.currentTarget.setPointerCapture?.(event.pointerId); + }, []); + + const finishExternalScrollbarDrag = useCallback(() => { + if (!externalScrollbarDraggingRef.current) { + return; + } + externalScrollbarDraggingRef.current = false; + + // 正在排队的外部同步会自行结算;没有待处理任务时才立即安排最终对齐。 + if ( + externalSyncRafRef.current === null + && pendingExternalScrollLeftRef.current === null + && externalScrollSettleRafRef.current === null + ) { + scheduleExternalHorizontalScrollSettle(externalScrollSequenceRef.current); + } + }, [scheduleExternalHorizontalScrollSettle]); + + const handleExternalHorizontalScrollPointerRelease = useCallback((event: React.PointerEvent) => { + if (event.currentTarget.hasPointerCapture?.(event.pointerId)) { + event.currentTarget.releasePointerCapture(event.pointerId); + } + finishExternalScrollbarDrag(); + }, [finishExternalScrollbarDrag]); + + const handleExternalHorizontalScrollLostPointerCapture = useCallback(() => { + finishExternalScrollbarDrag(); + }, [finishExternalScrollbarDrag]); const focusColumnQuickFindTarget = useCallback((columnName: string): boolean => { const root = rootRef.current; @@ -3991,14 +4067,23 @@ const DataGrid: React.FC = ({ }; externalScroll.addEventListener('wheel', handleExternalWheel, { passive: false, capture: true }); + window.addEventListener('blur', finishExternalScrollbarDrag); return () => { externalScroll.removeEventListener('wheel', handleExternalWheel, { capture: true } as EventListenerOptions); + window.removeEventListener('blur', finishExternalScrollbarDrag); if (externalSyncRafRef.current !== null) { cancelAnimationFrame(externalSyncRafRef.current); externalSyncRafRef.current = null; } + if (externalScrollSettleRafRef.current !== null) { + cancelAnimationFrame(externalScrollSettleRafRef.current); + externalScrollSettleRafRef.current = null; + } + pendingExternalScrollLeftRef.current = null; + externalScrollbarDraggingRef.current = false; + horizontalSyncSourceRef.current = ''; }; - }, [horizontalScrollVisible]); + }, [finishExternalScrollbarDrag, horizontalScrollVisible]); // 支持在数据区直接使用触摸板/Shift+滚轮进行横向滚动。 // 虚拟表格与普通表格统一走外部横向滚动条,避免内部轨道覆盖最后一行。 @@ -4421,6 +4506,9 @@ const DataGrid: React.FC = ({ applyAllFiltersDisabled, applyAllFiltersEnabled, applyExternalScrollToTableTargets, + handleExternalHorizontalScrollPointerDown, + handleExternalHorizontalScrollPointerRelease, + handleExternalHorizontalScrollLostPointerCapture, applyFilters, applyJsonEditor, applyQuickWhereCondition, diff --git a/frontend/src/components/DataGridShell.tsx b/frontend/src/components/DataGridShell.tsx index eea820bd..b09e8462 100644 --- a/frontend/src/components/DataGridShell.tsx +++ b/frontend/src/components/DataGridShell.tsx @@ -48,6 +48,9 @@ const DataGridShell: React.FC = (props) => { applyAllFiltersDisabled, applyAllFiltersEnabled, applyExternalScrollToTableTargets, + handleExternalHorizontalScrollPointerDown, + handleExternalHorizontalScrollPointerRelease, + handleExternalHorizontalScrollLostPointerCapture, applyFilters, applyJsonEditor, applyQuickWhereCondition, @@ -387,6 +390,10 @@ const renderDataTableView = () => ( className="data-grid-external-horizontal-scroll" aria-hidden={!horizontalScrollVisible} onScroll={applyExternalScrollToTableTargets} + onPointerDown={handleExternalHorizontalScrollPointerDown} + onPointerUp={handleExternalHorizontalScrollPointerRelease} + onPointerCancel={handleExternalHorizontalScrollPointerRelease} + onLostPointerCapture={handleExternalHorizontalScrollLostPointerCapture} style={{ opacity: horizontalScrollVisible ? 1 : 0, pointerEvents: horizontalScrollVisible ? 'auto' : 'none',