From c4f94495a84debeaddaa7dd2f14b8661c00afd91 Mon Sep 17 00:00:00 2001 From: jeffvli Date: Thu, 20 Nov 2025 14:40:09 -0800 Subject: [PATCH] support scroll sync on sticky table header and groups --- .../hooks/use-sticky-table-header.tsx | 154 +++++++++++++++- .../item-table-list.module.css | 10 +- .../item-table-list/item-table-list.tsx | 169 ++++++++++++------ .../components/album-detail-content.tsx | 2 +- 4 files changed, 271 insertions(+), 64 deletions(-) diff --git a/src/renderer/components/item-list/item-table-list/hooks/use-sticky-table-header.tsx b/src/renderer/components/item-list/item-table-list/hooks/use-sticky-table-header.tsx index 33ee7c70f..d9073a292 100644 --- a/src/renderer/components/item-list/item-table-list/hooks/use-sticky-table-header.tsx +++ b/src/renderer/components/item-list/item-table-list/hooks/use-sticky-table-header.tsx @@ -1,5 +1,5 @@ import { useInView } from 'motion/react'; -import { RefObject, useMemo } from 'react'; +import { RefObject, useEffect, useMemo, useRef } from 'react'; import { useWindowSettings } from '/@/renderer/store/settings.store'; import { Platform } from '/@/shared/types/types'; @@ -8,12 +8,26 @@ export const useStickyTableHeader = ({ containerRef, enabled, headerRef, + mainGridRef, + pinnedLeftColumnRef, + pinnedRightColumnRef, + stickyHeaderMainRef, }: { containerRef: RefObject; enabled: boolean; headerRef: RefObject; + mainGridRef?: RefObject; + pinnedLeftColumnRef?: RefObject; + pinnedRightColumnRef?: RefObject; + stickyHeaderMainRef?: RefObject; }) => { const { windowBarStyle } = useWindowSettings(); + const isScrollingRef = useRef({ + main: false, + pinnedLeft: false, + pinnedRight: false, + stickyHeader: false, + }); const topMargin = windowBarStyle === Platform.WINDOWS || windowBarStyle === Platform.MACOS @@ -36,6 +50,144 @@ export const useStickyTableHeader = ({ return windowBarStyle === Platform.WINDOWS || windowBarStyle === Platform.MACOS ? 95 : 65; }, [windowBarStyle]); + // Sync scroll between sticky header and main grid/pinned columns + useEffect(() => { + if (!shouldShowStickyHeader || !stickyHeaderMainRef?.current || !mainGridRef?.current) { + return; + } + + const stickyMainSection = stickyHeaderMainRef.current; + const mainGrid = mainGridRef.current.childNodes[0] as HTMLDivElement; + const pinnedLeft = pinnedLeftColumnRef?.current?.childNodes[0] as HTMLDivElement | null; + const pinnedRight = pinnedRightColumnRef?.current?.childNodes[0] as HTMLDivElement | null; + + if (!mainGrid) { + return; + } + + // Sync initial scroll position when sticky header becomes visible + const syncInitialScroll = () => { + const scrollLeft = mainGrid.scrollLeft; + const scrollTop = mainGrid.scrollTop; + + // Sync horizontal scroll position + stickyMainSection.scrollTo({ + behavior: 'instant', + left: scrollLeft, + }); + + // Sync vertical scroll position with pinned columns + if (pinnedLeft) { + pinnedLeft.scrollTo({ + behavior: 'instant', + top: scrollTop, + }); + } + if (pinnedRight) { + pinnedRight.scrollTo({ + behavior: 'instant', + top: scrollTop, + }); + } + }; + + // Sync initial position after a frame to ensure elements are ready + requestAnimationFrame(() => { + requestAnimationFrame(syncInitialScroll); + }); + + const syncScroll = (e: Event) => { + const target = e.currentTarget as HTMLDivElement; + const scrollLeft = target.scrollLeft; + const scrollTop = target.scrollTop; + + // Sync horizontal scroll from main grid to sticky header main section + if (target === mainGrid && !isScrollingRef.current.stickyHeader) { + isScrollingRef.current.stickyHeader = true; + stickyMainSection.scrollTo({ + behavior: 'instant', + left: scrollLeft, + }); + isScrollingRef.current.stickyHeader = false; + } + + // Sync horizontal scroll from sticky header to main grid + if (target === stickyMainSection && !isScrollingRef.current.main) { + isScrollingRef.current.main = true; + mainGrid.scrollTo({ + behavior: 'instant', + left: scrollLeft, + }); + isScrollingRef.current.main = false; + } + + // Sync vertical scroll from main grid to pinned columns + if (target === mainGrid) { + if (pinnedLeft && !isScrollingRef.current.pinnedLeft) { + isScrollingRef.current.pinnedLeft = true; + pinnedLeft.scrollTo({ + behavior: 'instant', + top: scrollTop, + }); + isScrollingRef.current.pinnedLeft = false; + } + if (pinnedRight && !isScrollingRef.current.pinnedRight) { + isScrollingRef.current.pinnedRight = true; + pinnedRight.scrollTo({ + behavior: 'instant', + top: scrollTop, + }); + isScrollingRef.current.pinnedRight = false; + } + } + + // Sync vertical scroll from pinned columns to main grid + if (pinnedLeft && target === pinnedLeft && !isScrollingRef.current.main) { + isScrollingRef.current.main = true; + mainGrid.scrollTo({ + behavior: 'instant', + top: scrollTop, + }); + isScrollingRef.current.main = false; + } + + if (pinnedRight && target === pinnedRight && !isScrollingRef.current.main) { + isScrollingRef.current.main = true; + mainGrid.scrollTo({ + behavior: 'instant', + top: scrollTop, + }); + isScrollingRef.current.main = false; + } + }; + + mainGrid.addEventListener('scroll', syncScroll); + stickyMainSection.addEventListener('scroll', syncScroll); + if (pinnedLeft) { + pinnedLeft.addEventListener('scroll', syncScroll); + } + if (pinnedRight) { + pinnedRight.addEventListener('scroll', syncScroll); + } + + return () => { + mainGrid.removeEventListener('scroll', syncScroll); + stickyMainSection.removeEventListener('scroll', syncScroll); + if (pinnedLeft) { + pinnedLeft.removeEventListener('scroll', syncScroll); + } + if (pinnedRight) { + pinnedRight.removeEventListener('scroll', syncScroll); + } + }; + }, [ + shouldShowStickyHeader, + mainGridRef, + pinnedLeftColumnRef, + pinnedRightColumnRef, + stickyHeaderMainRef, + ]); + return { shouldShowStickyHeader, stickyTop, diff --git a/src/renderer/components/item-list/item-table-list/item-table-list.module.css b/src/renderer/components/item-list/item-table-list/item-table-list.module.css index 56f0816b4..ef2e17c82 100644 --- a/src/renderer/components/item-list/item-table-list/item-table-list.module.css +++ b/src/renderer/components/item-list/item-table-list/item-table-list.module.css @@ -67,12 +67,10 @@ z-index: 15; display: flex; flex-direction: row; - padding: 0 2rem; - margin: 0 -2rem; + overflow: hidden; pointer-events: none; background-color: var(--theme-colors-background); border-bottom: 1px solid var(--theme-colors-border); - box-shadow: 0 -1px 0 0 var(--theme-colors-border); } .sticky-header-row { @@ -93,8 +91,8 @@ z-index: 15; display: flex; flex-direction: row; - padding: 0 2rem; - margin: 0 -2rem; + padding: 0; + margin: 0; pointer-events: none; background-color: var(--theme-colors-background); border-bottom: 1px solid var(--theme-colors-border); @@ -104,7 +102,6 @@ display: flex; flex-direction: row; width: 100%; - background-color: var(--theme-colors-background); } .sticky-group-row-section { @@ -112,7 +109,6 @@ flex-direction: row; overflow: hidden; pointer-events: auto; - background-color: var(--theme-colors-background); } .item-table-pinned-rows-grid-container.with-header::after { diff --git a/src/renderer/components/item-list/item-table-list/item-table-list.tsx b/src/renderer/components/item-list/item-table-list/item-table-list.tsx index c2bb87446..4a917d053 100644 --- a/src/renderer/components/item-list/item-table-list/item-table-list.tsx +++ b/src/renderer/components/item-list/item-table-list/item-table-list.tsx @@ -845,57 +845,45 @@ export const ItemTableList = ({ const handleRef = useRef(null); const containerFocusRef = useRef(null); + const stickyHeaderRef = useRef(null); + const stickyGroupRowRef = useRef(null); + const stickyHeaderLeftRef = useRef(null); + const stickyHeaderMainRef = useRef(null); + const stickyHeaderRightRef = useRef(null); + const { shouldShowStickyHeader, stickyTop } = useStickyTableHeader({ containerRef: containerFocusRef, enabled: enableHeader && enableStickyHeader, headerRef: pinnedRowRef, + mainGridRef: rowRef, + pinnedLeftColumnRef, + pinnedRightColumnRef, + stickyHeaderMainRef, }); - const stickyHeaderRef = useRef(null); - const stickyGroupRowRef = useRef(null); - - // Sync scroll position and update position of sticky header + // Update position and width of sticky header (scroll sync is handled in the hook) useEffect(() => { - if ( - !shouldShowStickyHeader || - !stickyHeaderRef.current || - !rowRef.current || - !containerFocusRef.current - ) { + if (!shouldShowStickyHeader || !stickyHeaderRef.current || !containerFocusRef.current) { return; } const stickyHeader = stickyHeaderRef.current; - const stickyMainSection = stickyHeader.querySelector( - `.${styles.stickyHeaderSection}`, - ) as HTMLDivElement; - const mainGrid = rowRef.current.childNodes[0] as HTMLDivElement; const container = containerFocusRef.current; - if (!stickyMainSection || !mainGrid || !container) { - return; - } - const updatePosition = () => { const containerRect = container.getBoundingClientRect(); stickyHeader.style.left = `${containerRect.left}px`; - }; - - const syncScroll = () => { - stickyMainSection.scrollLeft = mainGrid.scrollLeft; + stickyHeader.style.width = `${containerRect.width}px`; }; updatePosition(); - syncScroll(); window.addEventListener('resize', updatePosition); window.addEventListener('scroll', updatePosition, true); - mainGrid.addEventListener('scroll', syncScroll); return () => { window.removeEventListener('resize', updatePosition); window.removeEventListener('scroll', updatePosition, true); - mainGrid.removeEventListener('scroll', syncScroll); }; }, [shouldShowStickyHeader]); @@ -1516,6 +1504,36 @@ export const ItemTableList = ({ // Show sticky group row whenever it should be shown const shouldRenderStickyGroupRow = shouldShowStickyGroupRow; + // Update position and width of sticky group row + useEffect(() => { + if ( + !shouldRenderStickyGroupRow || + !stickyGroupRowRef.current || + !containerFocusRef.current + ) { + return; + } + + const stickyGroupRow = stickyGroupRowRef.current; + const container = containerFocusRef.current; + + const updatePosition = () => { + const containerRect = container.getBoundingClientRect(); + stickyGroupRow.style.left = `${containerRect.left}px`; + stickyGroupRow.style.width = `${containerRect.width}px`; + }; + + updatePosition(); + + window.addEventListener('resize', updatePosition); + window.addEventListener('scroll', updatePosition, true); + + return () => { + window.removeEventListener('resize', updatePosition); + window.removeEventListener('scroll', updatePosition, true); + }; + }, [shouldRenderStickyGroupRow]); + const getDataFn = useCallback(() => { const result: (null | unknown)[] = enableHeader ? [null] : []; @@ -1829,8 +1847,16 @@ export const ItemTableList = ({
{pinnedLeftColumnCount > 0 && (
{parsedColumns .filter((col) => col.pinned === 'left') @@ -1855,33 +1881,62 @@ export const ItemTableList = ({ })}
)} -
- {parsedColumns - .filter((col) => col.pinned === null) - .map((col) => { - const columnIndex = parsedColumns.findIndex((c) => c === col); - return ( - - ); - })} +
+
+ {parsedColumns + .filter((col) => col.pinned === null) + .map((col) => { + const columnIndex = parsedColumns.findIndex((c) => c === col); + return ( + + ); + })} +
{pinnedRightColumnCount > 0 && (
{parsedColumns .filter((col) => col.pinned === 'right') @@ -2003,7 +2058,13 @@ export const ItemTableList = ({ )}
0 ? 0 : '-2rem', + marginRight: '-2rem', + paddingLeft: pinnedLeftColumnCount > 0 ? 0 : '2rem', + paddingRight: '2rem', + width: `${mainWidth}px`, + }} >
- {groupContent} -
+ />
)}
diff --git a/src/renderer/features/albums/components/album-detail-content.tsx b/src/renderer/features/albums/components/album-detail-content.tsx index 4a821355c..4f54d50fc 100644 --- a/src/renderer/features/albums/components/album-detail-content.tsx +++ b/src/renderer/features/albums/components/album-detail-content.tsx @@ -552,7 +552,7 @@ const AlbumDetailSongsTable = ({ songs }: AlbumDetailSongsTableProps) => { id={`disc-${discGroup.discNumber}`} indeterminate={isSomeSelected} label={ - + {t('common.disc', { postProcess: 'sentenceCase' })}{' '} {discGroup.discNumber} {discGroup.discSubtitle && ` - ${discGroup.discSubtitle}`}