fix item table scroll sync when pinned columns change

This commit is contained in:
jeffvli
2026-07-11 13:33:42 -07:00
parent 641e938f86
commit 58ae496e05
2 changed files with 281 additions and 173 deletions
@@ -7,6 +7,26 @@ import { useEffect } from 'react';
import { ItemListStateActions } from '/@/renderer/components/item-list/helpers/item-list-state'; import { ItemListStateActions } from '/@/renderer/components/item-list/helpers/item-list-state';
const getPaneScrollViewport = (ref: React.RefObject<HTMLDivElement | null>) =>
ref.current?.firstElementChild as HTMLDivElement | undefined;
const getPaneElements = ({
pinnedLeftColumnRef,
pinnedRightColumnRef,
pinnedRowRef,
rowRef,
}: {
pinnedLeftColumnRef: React.RefObject<HTMLDivElement | null>;
pinnedRightColumnRef: React.RefObject<HTMLDivElement | null>;
pinnedRowRef: React.RefObject<HTMLDivElement | null>;
rowRef: React.RefObject<HTMLDivElement | null>;
}) => ({
header: getPaneScrollViewport(pinnedRowRef),
pinnedLeft: getPaneScrollViewport(pinnedLeftColumnRef),
pinnedRight: getPaneScrollViewport(pinnedRightColumnRef),
row: getPaneScrollViewport(rowRef),
});
export const useTablePaneSync = ({ export const useTablePaneSync = ({
enableDrag, enableDrag,
enableDragScroll, enableDragScroll,
@@ -21,6 +41,7 @@ export const useTablePaneSync = ({
rowRef, rowRef,
scrollContainerRef, scrollContainerRef,
scrollShadowStore, scrollShadowStore,
scrollSyncKey,
}: { }: {
enableDrag: boolean | undefined; enableDrag: boolean | undefined;
enableDragScroll: boolean | undefined; enableDragScroll: boolean | undefined;
@@ -37,6 +58,7 @@ export const useTablePaneSync = ({
rowRef: React.RefObject<HTMLDivElement | null>; rowRef: React.RefObject<HTMLDivElement | null>;
scrollContainerRef: React.RefObject<HTMLDivElement | null>; scrollContainerRef: React.RefObject<HTMLDivElement | null>;
scrollShadowStore: TableScrollShadowStore; scrollShadowStore: TableScrollShadowStore;
scrollSyncKey: string;
}) => { }) => {
// Main grid overlayscrollbars - only handle X-axis if right-pinned columns exist // Main grid overlayscrollbars - only handle X-axis if right-pinned columns exist
const [initialize, osInstance] = useOverlayScrollbars({ const [initialize, osInstance] = useOverlayScrollbars({
@@ -144,6 +166,7 @@ export const useTablePaneSync = ({
osInstance, osInstance,
pinnedRightColumnCount, pinnedRightColumnCount,
scrollContainerRef, scrollContainerRef,
scrollSyncKey,
]); ]);
useEffect(() => { useEffect(() => {
@@ -235,222 +258,294 @@ export const useTablePaneSync = ({
osInstanceRightPinned, osInstanceRightPinned,
pinnedRightColumnCount, pinnedRightColumnCount,
pinnedRightColumnRef, pinnedRightColumnRef,
scrollSyncKey,
]); ]);
useEffect(() => { useEffect(() => {
const header = pinnedRowRef.current?.childNodes[0] as HTMLDivElement; let disposed = false;
const row = rowRef.current?.childNodes[0] as HTMLDivElement; let cleanup: (() => void) | undefined;
const pinnedLeft = pinnedLeftColumnRef.current?.childNodes[0] as HTMLDivElement; let setupFrameId = 0;
const pinnedRight = pinnedRightColumnRef.current?.childNodes[0] as HTMLDivElement;
if (!row) return; const resolvePaneRefs = () =>
getPaneElements({
pinnedLeftColumnRef,
pinnedRightColumnRef,
pinnedRowRef,
rowRef,
});
// Ensure all containers have the same height const isVerticalScrollHostReady = (
const syncHeights = () => { row: HTMLDivElement,
const rowHeight = row.scrollHeight; pinnedRight: HTMLDivElement | undefined,
let targetHeight = rowHeight; ) => {
const verticalScrollHost = pinnedRightColumnCount > 0 ? pinnedRight : row;
if (pinnedLeft) { if (!verticalScrollHost) {
const pinnedLeftHeight = pinnedLeft.scrollHeight; return false;
targetHeight = Math.max(targetHeight, pinnedLeftHeight);
} }
if (pinnedRight) { if (pinnedRightColumnCount > 0) {
const pinnedRightHeight = pinnedRight.scrollHeight; return verticalScrollHost.scrollHeight > verticalScrollHost.clientHeight;
targetHeight = Math.max(targetHeight, pinnedRightHeight);
} }
if (pinnedLeft && pinnedLeft.style.height !== `${targetHeight}px`) { return verticalScrollHost.scrollHeight > 0;
pinnedLeft.style.height = `${targetHeight}px`;
}
if (pinnedRight && pinnedRight.style.height !== `${targetHeight}px`) {
pinnedRight.style.height = `${targetHeight}px`;
}
if (row.style.height !== `${targetHeight}px`) {
row.style.height = `${targetHeight}px`;
}
}; };
const timeoutId = setTimeout(syncHeights, 0); const setupScrollSync = () => {
if (disposed) {
const activeElement = { element: null } as { element: HTMLDivElement | null }; return;
const scrollingElements = new Set<HTMLDivElement>();
const scrollTimeouts = new Map<HTMLDivElement, NodeJS.Timeout>();
const setActiveElement = (e: HTMLElementEventMap['pointermove']) => {
activeElement.element = e.currentTarget as HTMLDivElement;
};
const setActiveElementFromWheel = (e: HTMLElementEventMap['wheel']) => {
activeElement.element = e.currentTarget as HTMLDivElement;
};
const markElementAsScrolling = (element: HTMLDivElement) => {
scrollingElements.add(element);
const existingTimeout = scrollTimeouts.get(element);
if (existingTimeout) {
clearTimeout(existingTimeout);
} }
const timeout = setTimeout(() => { cleanup?.();
scrollingElements.delete(element);
const hasRightPinnedColumns = pinnedRightColumnCount > 0; const { header, pinnedLeft, pinnedRight, row } = resolvePaneRefs();
const scrollElement = hasRightPinnedColumns && pinnedRight ? pinnedRight : row;
if (scrollElement && onScrollEndRef.current) { if (!row || !isVerticalScrollHostReady(row, pinnedRight)) {
onScrollEndRef.current( setupFrameId = requestAnimationFrame(setupScrollSync);
scrollElement.scrollTop, return;
(handleRef.current?.internalState ?? }
(undefined as any)) as ItemListStateActions,
); const syncHeights = () => {
const panes = resolvePaneRefs();
const syncRow = panes.row;
const syncPinnedLeft = panes.pinnedLeft;
const syncPinnedRight = panes.pinnedRight;
if (!syncRow) {
return;
} }
scrollTimeouts.delete(element); const rowHeight = syncRow.scrollHeight;
}, 150); let targetHeight = rowHeight;
scrollTimeouts.set(element, timeout); if (syncPinnedLeft) {
}; targetHeight = Math.max(targetHeight, syncPinnedLeft.scrollHeight);
}
const syncScroll = (e: HTMLElementEventMap['scroll']) => { if (syncPinnedRight) {
const currentElement = e.currentTarget as HTMLDivElement; targetHeight = Math.max(targetHeight, syncPinnedRight.scrollHeight);
markElementAsScrolling(currentElement); }
const shouldSync = if (targetHeight <= 0) {
currentElement === activeElement.element || scrollingElements.has(currentElement); return;
if (!shouldSync) return; }
const scrollTop = (e.currentTarget as HTMLDivElement).scrollTop; if (syncPinnedLeft && syncPinnedLeft.style.height !== `${targetHeight}px`) {
const scrollLeft = (e.currentTarget as HTMLDivElement).scrollLeft; syncPinnedLeft.style.height = `${targetHeight}px`;
}
const isScrolling = { if (syncPinnedRight && syncPinnedRight.style.height !== `${targetHeight}px`) {
header: false, syncPinnedRight.style.height = `${targetHeight}px`;
pinnedLeft: false, }
pinnedRight: false, if (rowHeight > 0 && syncRow.style.height !== `${targetHeight}px`) {
row: false, syncRow.style.height = `${targetHeight}px`;
}
}; };
const hasRightPinnedColumns = pinnedRightColumnCount > 0; const timeoutId = setTimeout(syncHeights, 0);
if (header && e.currentTarget === header && !isScrolling.row) { const activeElement = { element: null } as { element: HTMLDivElement | null };
isScrolling.row = true; const scrollingElements = new Set<HTMLDivElement>();
row.scrollTo({ behavior: 'instant', left: scrollLeft }); const scrollTimeouts = new Map<HTMLDivElement, NodeJS.Timeout>();
isScrolling.row = false;
}
if ( const setActiveElement = (e: HTMLElementEventMap['pointermove']) => {
e.currentTarget === row && activeElement.element = e.currentTarget as HTMLDivElement;
!isScrolling.header && };
!isScrolling.pinnedLeft && const setActiveElementFromWheel = (e: HTMLElementEventMap['wheel']) => {
!isScrolling.pinnedRight activeElement.element = e.currentTarget as HTMLDivElement;
) { };
if (header) {
isScrolling.header = true; const markElementAsScrolling = (element: HTMLDivElement) => {
header.scrollTo({ behavior: 'instant', left: scrollLeft }); scrollingElements.add(element);
const existingTimeout = scrollTimeouts.get(element);
if (existingTimeout) {
clearTimeout(existingTimeout);
} }
if (hasRightPinnedColumns && pinnedRight) {
isScrolling.pinnedRight = true;
pinnedRight.scrollTo({ behavior: 'instant', top: scrollTop });
isScrolling.pinnedRight = false;
} else {
if (pinnedLeft) {
isScrolling.pinnedLeft = true;
pinnedLeft.scrollTo({ behavior: 'instant', top: scrollTop });
}
if (pinnedRight) {
isScrolling.pinnedRight = true;
pinnedRight.scrollTo({ behavior: 'instant', top: scrollTop });
}
}
isScrolling.header = false;
isScrolling.pinnedLeft = false;
}
if (pinnedLeft && e.currentTarget === pinnedLeft && !isScrolling.row) { const timeout = setTimeout(() => {
if (hasRightPinnedColumns && pinnedRight) { scrollingElements.delete(element);
isScrolling.pinnedRight = true;
pinnedRight.scrollTo({ behavior: 'instant', top: scrollTop }); const panes = resolvePaneRefs();
isScrolling.pinnedRight = false; const hasRightPinnedColumns = pinnedRightColumnCount > 0;
} else { const scrollElement =
hasRightPinnedColumns && panes.pinnedRight ? panes.pinnedRight : panes.row;
if (scrollElement && onScrollEndRef.current) {
onScrollEndRef.current(
scrollElement.scrollTop,
(handleRef.current?.internalState ??
(undefined as any)) as ItemListStateActions,
);
}
scrollTimeouts.delete(element);
}, 150);
scrollTimeouts.set(element, timeout);
};
const syncScroll = (e: HTMLElementEventMap['scroll']) => {
const currentElement = e.currentTarget as HTMLDivElement;
markElementAsScrolling(currentElement);
const panes = resolvePaneRefs();
const syncHeader = panes.header;
const syncRow = panes.row;
const syncPinnedLeft = panes.pinnedLeft;
const syncPinnedRight = panes.pinnedRight;
if (!syncRow) {
return;
}
const shouldSync =
currentElement === activeElement.element ||
scrollingElements.has(currentElement);
if (!shouldSync) return;
const scrollTop = currentElement.scrollTop;
const scrollLeft = currentElement.scrollLeft;
const isScrolling = {
header: false,
pinnedLeft: false,
pinnedRight: false,
row: false,
};
const hasRightPinnedColumns = pinnedRightColumnCount > 0;
if (syncHeader && currentElement === syncHeader && !isScrolling.row) {
isScrolling.row = true; isScrolling.row = true;
row.scrollTo({ behavior: 'instant', top: scrollTop }); syncRow.scrollTo({ behavior: 'instant', left: scrollLeft });
isScrolling.row = false; isScrolling.row = false;
} }
}
if (pinnedRight && e.currentTarget === pinnedRight && !isScrolling.row) { if (
isScrolling.row = true; currentElement === syncRow &&
row.scrollTo({ behavior: 'instant', top: scrollTop }); !isScrolling.header &&
isScrolling.row = false; !isScrolling.pinnedLeft &&
if (pinnedLeft) { !isScrolling.pinnedRight
isScrolling.pinnedLeft = true; ) {
pinnedLeft.scrollTo({ behavior: 'instant', top: scrollTop }); if (syncHeader) {
isScrolling.header = true;
syncHeader.scrollTo({ behavior: 'instant', left: scrollLeft });
}
if (hasRightPinnedColumns && syncPinnedRight) {
isScrolling.pinnedRight = true;
syncPinnedRight.scrollTo({ behavior: 'instant', top: scrollTop });
isScrolling.pinnedRight = false;
} else {
if (syncPinnedLeft) {
isScrolling.pinnedLeft = true;
syncPinnedLeft.scrollTo({ behavior: 'instant', top: scrollTop });
}
if (syncPinnedRight) {
isScrolling.pinnedRight = true;
syncPinnedRight.scrollTo({ behavior: 'instant', top: scrollTop });
}
}
isScrolling.header = false;
isScrolling.pinnedLeft = false; isScrolling.pinnedLeft = false;
} }
}
};
if (header) { if (syncPinnedLeft && currentElement === syncPinnedLeft && !isScrolling.row) {
header.addEventListener('pointermove', setActiveElement); if (hasRightPinnedColumns && syncPinnedRight) {
header.addEventListener('wheel', setActiveElementFromWheel); isScrolling.pinnedRight = true;
header.addEventListener('scroll', syncScroll); syncPinnedRight.scrollTo({ behavior: 'instant', top: scrollTop });
} isScrolling.pinnedRight = false;
row.addEventListener('pointermove', setActiveElement); } else {
row.addEventListener('wheel', setActiveElementFromWheel); isScrolling.row = true;
row.addEventListener('scroll', syncScroll); syncRow.scrollTo({ behavior: 'instant', top: scrollTop });
if (pinnedLeft) { isScrolling.row = false;
pinnedLeft.addEventListener('pointermove', setActiveElement); }
pinnedLeft.addEventListener('wheel', setActiveElementFromWheel); }
pinnedLeft.addEventListener('scroll', syncScroll);
}
if (pinnedRight) {
pinnedRight.addEventListener('pointermove', setActiveElement);
pinnedRight.addEventListener('wheel', setActiveElementFromWheel);
pinnedRight.addEventListener('scroll', syncScroll);
}
let heightSyncDebounceTimeout: NodeJS.Timeout | null = null; if (syncPinnedRight && currentElement === syncPinnedRight && !isScrolling.row) {
const resizeObserver = new ResizeObserver(() => { isScrolling.row = true;
if (heightSyncDebounceTimeout) { syncRow.scrollTo({ behavior: 'instant', top: scrollTop });
clearTimeout(heightSyncDebounceTimeout); isScrolling.row = false;
} if (syncPinnedLeft) {
heightSyncDebounceTimeout = setTimeout(() => { isScrolling.pinnedLeft = true;
syncHeights(); syncPinnedLeft.scrollTo({ behavior: 'instant', top: scrollTop });
}, 100); isScrolling.pinnedLeft = false;
}); }
}
resizeObserver.observe(row); };
if (pinnedLeft) resizeObserver.observe(pinnedLeft);
if (pinnedRight) resizeObserver.observe(pinnedRight);
return () => {
clearTimeout(timeoutId);
scrollTimeouts.forEach((timeout) => clearTimeout(timeout));
scrollTimeouts.clear();
scrollingElements.clear();
if (header) { if (header) {
header.removeEventListener('pointermove', setActiveElement); header.addEventListener('pointermove', setActiveElement);
header.removeEventListener('wheel', setActiveElementFromWheel); header.addEventListener('wheel', setActiveElementFromWheel);
header.removeEventListener('scroll', syncScroll); header.addEventListener('scroll', syncScroll);
} }
row.removeEventListener('pointermove', setActiveElement); row.addEventListener('pointermove', setActiveElement);
row.removeEventListener('wheel', setActiveElementFromWheel); row.addEventListener('wheel', setActiveElementFromWheel);
row.removeEventListener('scroll', syncScroll); row.addEventListener('scroll', syncScroll);
if (pinnedLeft) { if (pinnedLeft) {
pinnedLeft.removeEventListener('pointermove', setActiveElement); pinnedLeft.addEventListener('pointermove', setActiveElement);
pinnedLeft.removeEventListener('wheel', setActiveElementFromWheel); pinnedLeft.addEventListener('wheel', setActiveElementFromWheel);
pinnedLeft.removeEventListener('scroll', syncScroll); pinnedLeft.addEventListener('scroll', syncScroll);
} }
if (pinnedRight) { if (pinnedRight) {
pinnedRight.removeEventListener('pointermove', setActiveElement); pinnedRight.addEventListener('pointermove', setActiveElement);
pinnedRight.removeEventListener('wheel', setActiveElementFromWheel); pinnedRight.addEventListener('wheel', setActiveElementFromWheel);
pinnedRight.removeEventListener('scroll', syncScroll); pinnedRight.addEventListener('scroll', syncScroll);
} }
if (heightSyncDebounceTimeout) {
clearTimeout(heightSyncDebounceTimeout); let heightSyncDebounceTimeout: NodeJS.Timeout | null = null;
} const resizeObserver = new ResizeObserver(() => {
resizeObserver.disconnect(); if (heightSyncDebounceTimeout) {
clearTimeout(heightSyncDebounceTimeout);
}
heightSyncDebounceTimeout = setTimeout(() => {
syncHeights();
}, 100);
});
resizeObserver.observe(row);
if (pinnedLeft) resizeObserver.observe(pinnedLeft);
if (pinnedRight) resizeObserver.observe(pinnedRight);
cleanup = () => {
clearTimeout(timeoutId);
scrollTimeouts.forEach((timeout) => clearTimeout(timeout));
scrollTimeouts.clear();
scrollingElements.clear();
if (header) {
header.removeEventListener('pointermove', setActiveElement);
header.removeEventListener('wheel', setActiveElementFromWheel);
header.removeEventListener('scroll', syncScroll);
}
row.removeEventListener('pointermove', setActiveElement);
row.removeEventListener('wheel', setActiveElementFromWheel);
row.removeEventListener('scroll', syncScroll);
if (pinnedLeft) {
pinnedLeft.removeEventListener('pointermove', setActiveElement);
pinnedLeft.removeEventListener('wheel', setActiveElementFromWheel);
pinnedLeft.removeEventListener('scroll', syncScroll);
}
if (pinnedRight) {
pinnedRight.removeEventListener('pointermove', setActiveElement);
pinnedRight.removeEventListener('wheel', setActiveElementFromWheel);
pinnedRight.removeEventListener('scroll', syncScroll);
}
if (heightSyncDebounceTimeout) {
clearTimeout(heightSyncDebounceTimeout);
}
resizeObserver.disconnect();
};
};
setupFrameId = requestAnimationFrame(() => {
setupFrameId = requestAnimationFrame(setupScrollSync);
});
return () => {
disposed = true;
cancelAnimationFrame(setupFrameId);
cleanup?.();
}; };
}, [ }, [
handleRef, handleRef,
@@ -461,6 +556,7 @@ export const useTablePaneSync = ({
pinnedRightColumnRef, pinnedRightColumnRef,
pinnedRowRef, pinnedRowRef,
rowRef, rowRef,
scrollSyncKey,
]); ]);
// Handle left and right shadow visibility based on horizontal scroll // Handle left and right shadow visibility based on horizontal scroll
@@ -1557,6 +1557,17 @@ const BaseItemTableList = ({
scrollCellProps, scrollCellProps,
}); });
const scrollSyncKey = useMemo(
() =>
parsedColumns
.map(
(col) =>
`${col.id}:${col.pinned ?? 'none'}:${col.width}:${col.isEnabled !== false}`,
)
.join('|'),
[parsedColumns],
);
useTablePaneSync({ useTablePaneSync({
enableDrag, enableDrag,
enableDragScroll, enableDragScroll,
@@ -1571,6 +1582,7 @@ const BaseItemTableList = ({
rowRef, rowRef,
scrollContainerRef, scrollContainerRef,
scrollShadowStore, scrollShadowStore,
scrollSyncKey,
}); });
// Create a wrapper for getRowHeight that doesn't require cellProps (for sticky group rows hook) // Create a wrapper for getRowHeight that doesn't require cellProps (for sticky group rows hook)