From 74473427dfa7a593a7dd430d040b80a60af5688a Mon Sep 17 00:00:00 2001 From: jeffvli Date: Thu, 2 Oct 2025 00:47:03 -0700 Subject: [PATCH] move item table list --- .../item-table-list-sticky.tsx | 411 -------------- .../item-table-list.module.css | 55 ++ .../item-table-list/item-table-list.tsx | 502 ++++++++++++++++++ 3 files changed, 557 insertions(+), 411 deletions(-) delete mode 100644 src/renderer/components/item-list/item-table-list-sticky/item-table-list-sticky.tsx create mode 100644 src/renderer/components/item-list/item-table-list/item-table-list.module.css create mode 100644 src/renderer/components/item-list/item-table-list/item-table-list.tsx diff --git a/src/renderer/components/item-list/item-table-list-sticky/item-table-list-sticky.tsx b/src/renderer/components/item-list/item-table-list-sticky/item-table-list-sticky.tsx deleted file mode 100644 index 2e431acd3..000000000 --- a/src/renderer/components/item-list/item-table-list-sticky/item-table-list-sticky.tsx +++ /dev/null @@ -1,411 +0,0 @@ -import { useMergedRef } from '@mantine/hooks'; -import { useOverlayScrollbars } from 'overlayscrollbars-react'; -import { type JSXElementConstructor, useCallback, useEffect, useRef } from 'react'; -import { type CellComponentProps, Grid, type GridProps } from 'react-window-v2'; - -export function VirtualizedTable(props: { - cell: JSXElementConstructor>; - cellProps: GridProps['cellProps']; - columnCount: number; - columnWidth: ((index: number, cellProps: CellProps) => number) | number; - onCellsRendered: GridProps['onCellsRendered']; - overscanCount: number; - rowCount: number; - rowHeight: ((index: number, cellProps: CellProps) => number) | number; - stickyColumnCount: number; - stickyRowCount: number; -}) { - const rowCount = props.rowCount - (props.stickyRowCount ?? 0); - const columnCount = props.columnCount - (props.stickyColumnCount ?? 0); - const stickyRowRef = useRef(null); - const rowRef = useRef(null); - const stickyColumnRef = useRef(null); - const scrollContainerRef = useRef(null); - const mergedRowRef = useMergedRef(rowRef, scrollContainerRef); - - const [initialize] = useOverlayScrollbars({ - defer: true, - events: { - initialized(osInstance) { - const { viewport } = osInstance.elements(); - viewport.style.overflowX = `var(--os-viewport-overflow-x)`; - viewport.style.overflowY = `var(--os-viewport-overflow-y)`; - }, - }, - options: { - overflow: { x: 'scroll', y: 'scroll' }, - paddingAbsolute: true, - scrollbars: { - autoHide: 'leave', - autoHideDelay: 500, - pointers: ['mouse', 'pen', 'touch'], - theme: 'feishin-os-scrollbar', - visibility: 'visible', - }, - }, - }); - - useEffect(() => { - const { current: root } = scrollContainerRef; - - if (root) { - initialize({ - elements: { viewport: root.firstElementChild as HTMLElement }, - target: root, - }); - } - - return undefined; - }, [initialize]); - - useEffect(() => { - const header = stickyRowRef.current?.childNodes[0] as HTMLDivElement; - const row = rowRef.current?.childNodes[0] as HTMLDivElement; - const sticky = stickyColumnRef.current?.childNodes[0] as HTMLDivElement; - - if (header && row && sticky) { - // Ensure all containers have the same height - const syncHeights = () => { - const rowHeight = row.scrollHeight; - const stickyHeight = sticky.scrollHeight; - - // Set consistent heights - use the larger of the two - const targetHeight = Math.max(rowHeight, stickyHeight); - if (sticky.style.height !== `${targetHeight}px`) { - sticky.style.height = `${targetHeight}px`; - } - if (row.style.height !== `${targetHeight}px`) { - row.style.height = `${targetHeight}px`; - } - }; - - const activeElement = { element: null } as { element: HTMLDivElement | null }; - const setActiveElement = (e: HTMLElementEventMap['pointermove']) => { - activeElement.element = e.currentTarget as HTMLDivElement; - }; - - const syncScroll = (e: HTMLElementEventMap['scroll']) => { - if (e.currentTarget !== activeElement.element) { - return; - } - - const scrollTop = (e.currentTarget as HTMLDivElement).scrollTop; - const scrollLeft = (e.currentTarget as HTMLDivElement).scrollLeft; - - // Prevent recursive scroll events - const isScrolling = { header: false, row: false, sticky: false }; - - // Sync horizontal scroll between header and main content - if (e.currentTarget === header && !isScrolling.row) { - isScrolling.row = true; - row.scrollTo({ - behavior: 'instant', - left: scrollLeft, - }); - setTimeout(() => { - isScrolling.row = false; - }, 0); - } - - if (e.currentTarget === row && !isScrolling.header && !isScrolling.sticky) { - isScrolling.header = true; - isScrolling.sticky = true; - header.scrollTo({ - behavior: 'instant', - left: scrollLeft, - }); - sticky.scrollTo({ - behavior: 'instant', - top: scrollTop, - }); - setTimeout(() => { - isScrolling.header = false; - isScrolling.sticky = false; - }, 0); - } - - // Sync vertical scroll between sticky column and main content - if (e.currentTarget === sticky && !isScrolling.row) { - isScrolling.row = true; - row.scrollTo({ - behavior: 'instant', - top: scrollTop, - }); - setTimeout(() => { - isScrolling.row = false; - }, 0); - } - }; - - // Add event listeners - header.addEventListener('pointermove', setActiveElement); - row.addEventListener('pointermove', setActiveElement); - sticky.addEventListener('pointermove', setActiveElement); - header.addEventListener('scroll', syncScroll); - row.addEventListener('scroll', syncScroll); - sticky.addEventListener('scroll', syncScroll); - - // Add resize observer to maintain height sync - const resizeObserver = new ResizeObserver(() => { - syncHeights(); - }); - - resizeObserver.observe(row); - resizeObserver.observe(sticky); - - return () => { - header.removeEventListener('pointermove', setActiveElement); - row.removeEventListener('pointermove', setActiveElement); - sticky.removeEventListener('pointermove', setActiveElement); - header.removeEventListener('scroll', syncScroll); - row.removeEventListener('scroll', syncScroll); - sticky.removeEventListener('scroll', syncScroll); - resizeObserver.disconnect(); - }; - } - - return undefined; - }, []); - - const StickyRowCell = useCallback( - (cellProps: CellComponentProps & CellProps) => { - return ( - - ); - }, - [props.cell, props.stickyColumnCount], - ); - - const StickyColumnCell = useCallback( - (cellProps: CellComponentProps & CellProps) => { - return ( - - ); - }, - [props.cell, props.stickyRowCount], - ); - - const RowCell = useCallback( - (cellProps: CellComponentProps & CellProps) => { - return ( - - ); - }, - [props.cell, props.stickyColumnCount, props.stickyRowCount], - ); - - const minHeight = 0; - const minWidth = 0; - - return ( -
-
0, - ).reduce( - (a, _, i) => - a + - (typeof props.columnWidth === 'number' - ? props.columnWidth - : props.columnWidth(i, props.cellProps)), - 0, - )}px`, - }} - > - {(props.stickyColumnCount || props.stickyRowCount) && ( -
0, - ).reduce( - (a, _, i) => - a + - (typeof props.rowHeight === 'number' - ? props.rowHeight - : props.rowHeight(i, props.cellProps)), - 0, - )}px`, - minWidth, - }} - > - -
- )} - {props.stickyColumnCount && ( -
- { - return typeof props.rowHeight === 'number' - ? props.rowHeight - : props.rowHeight( - index + (props.stickyRowCount ?? 0), - cellProps, - ); - }} - style={{ - height: '100%', - scrollbarWidth: 'none', - }} - /> -
- )} -
-
- {props.stickyRowCount && ( -
0, - ).reduce( - (a, _, i) => - a + - (typeof props.rowHeight === 'number' - ? props.rowHeight - : props.rowHeight(i, props.cellProps)), - 0, - )}px`, - minWidth, - }} - > - { - return typeof props.columnWidth === 'number' - ? props.columnWidth - : props.columnWidth( - index + (props.stickyColumnCount ?? 0), - cellProps, - ); - }} - overscanCount={props.overscanCount} - rowCount={ - Array.from({ length: props.stickyRowCount ?? 0 }, () => 0).length - } - rowHeight={(index, cellProps) => { - return typeof props.rowHeight === 'number' - ? props.rowHeight - : props.rowHeight(index, cellProps); - }} - style={{ - scrollbarWidth: 'none', - }} - /> -
- )} -
- { - return typeof props.columnWidth === 'number' - ? props.columnWidth - : props.columnWidth( - index + (props.stickyColumnCount ?? 0), - cellProps, - ); - }} - onCellsRendered={ - props.onCellsRendered - ? ({ - columnStartIndex, - columnStopIndex, - rowStartIndex, - rowStopIndex, - }) => { - return props.onCellsRendered!({ - columnStartIndex: - columnStartIndex + (props.stickyColumnCount ?? 0), - columnStopIndex: - columnStopIndex + (props.stickyColumnCount ?? 0), - rowStartIndex: - rowStartIndex + (props.stickyRowCount ?? 0), - rowStopIndex: rowStopIndex + (props.stickyRowCount ?? 0), - }); - } - : undefined - } - overscanCount={props.overscanCount} - rowCount={rowCount} - rowHeight={(index, cellProps) => { - return typeof props.rowHeight === 'number' - ? props.rowHeight - : props.rowHeight(index + (props.stickyRowCount ?? 0), cellProps); - }} - style={{ - height: '100%', - }} - /> -
-
-
- ); -} 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 new file mode 100644 index 000000000..a62cc5170 --- /dev/null +++ b/src/renderer/components/item-list/item-table-list/item-table-list.module.css @@ -0,0 +1,55 @@ +.item-table-container { + display: flex; + flex-direction: row; + width: 100%; + min-width: 0; + height: 100%; + min-height: 0; +} + +.item-table-grid-container { + flex: 1 1 auto; + width: 100%; + height: 100%; + min-height: 0; +} + +.item-table-sticky-rows-container { + display: flex; + flex: 1 1 auto; + flex-direction: column; + min-width: 0; + min-height: 0; +} + +.item-table-sticky-rows-grid-container { + flex: 0 1 auto; + min-width: 0; +} + +.item-table-sticky-columns-grid-container { + display: flex; + flex: 0 1 auto; + flex-direction: column; + min-height: 0; +} + +.item-table-sticky-intersection-grid-container { + flex: 0 1 auto; + min-width: 0; +} + +.item-table-sticky-columns-container { + flex: 1 1 auto; + min-width: 0; + height: 100%; + min-height: 0; +} + +.no-scrollbar { + scrollbar-width: none; +} + +.height-100 { + height: 100%; +} 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 new file mode 100644 index 000000000..dedc3c0dc --- /dev/null +++ b/src/renderer/components/item-list/item-table-list/item-table-list.tsx @@ -0,0 +1,502 @@ +// Component adapted from https://github.com/bvaughn/react-window/issues/826 + +import { useMergedRef } from '@mantine/hooks'; +import clsx from 'clsx'; +import { AnimatePresence, motion, Variants } from 'motion/react'; +import { useOverlayScrollbars } from 'overlayscrollbars-react'; +import { + type JSXElementConstructor, + MouseEvent, + Ref, + UIEvent, + useCallback, + useEffect, + useRef, +} from 'react'; +import { type CellComponentProps, Grid, GridImperativeAPI, type GridProps } from 'react-window-v2'; + +import styles from './item-table-list.module.css'; + +import { ExpandedListItem } from '/@/renderer/components/item-list/expanded-list-item'; +import { useItemListState } from '/@/renderer/components/item-list/helpers/item-list-state'; +import { LibraryItem } from '/@/shared/types/domain-types'; + +interface ItemTableListProps { + CellComponent: JSXElementConstructor>; + cellProps: GridProps['cellProps']; + columnCount: number; + columnWidth: ((index: number, cellProps: CellProps) => number) | number; + data: unknown[]; + enableExpansion?: boolean; + enableSelection?: boolean; + initialTopMostItemIndex?: + | number + | { + align: 'center' | 'end' | 'start'; + behavior: 'auto' | 'smooth'; + index: number; + offset?: number; + }; + itemType: LibraryItem; + onCellsRendered: GridProps['onCellsRendered']; + onEndReached?: (index: number) => void; + onItemClick?: (item: unknown, index: number) => void; + onItemContextMenu?: (item: unknown, index: number) => void; + onItemDoubleClick?: (item: unknown, index: number) => void; + onRangeChanged?: (range: { endIndex: number; startIndex: number }) => void; + onScroll?: (e: UIEvent) => void; + onScrollEnd?: () => void; + onStartReached?: (index: number) => void; + ref?: Ref; + rowHeight: ((index: number, cellProps: CellProps) => number) | number; + stickyColumnCount: number; + stickyRowCount: number; + totalItemCount: number; +} + +const expandedAnimationVariants: Variants = { + hidden: { + height: 0, + minHeight: 0, + }, + show: { + minHeight: '300px', + transition: { + duration: 0.3, + ease: 'easeInOut', + }, + }, +}; + +export const ItemTableList = ({ + CellComponent, + cellProps, + columnCount, + columnWidth, + initialTopMostItemIndex, + itemType, + onCellsRendered, + onEndReached, + onItemClick, + onItemContextMenu, + onItemDoubleClick, + onRangeChanged, + onScroll, + onScrollEnd, + onStartReached, + ref, + rowHeight, + stickyColumnCount, + stickyRowCount, + totalItemCount, +}: ItemTableListProps) => { + const totalRowCount = totalItemCount - (stickyRowCount ?? 0); + const totalColumnCount = columnCount - (stickyColumnCount ?? 0); + const stickyRowRef = useRef(null); + const rowRef = useRef(null); + const stickyColumnRef = useRef(null); + const scrollContainerRef = useRef(null); + const mergedRowRef = useMergedRef(rowRef, scrollContainerRef); + + const [initialize] = useOverlayScrollbars({ + defer: true, + events: { + initialized(osInstance) { + const { viewport } = osInstance.elements(); + viewport.style.overflowX = `var(--os-viewport-overflow-x)`; + viewport.style.overflowY = `var(--os-viewport-overflow-y)`; + }, + }, + options: { + overflow: { x: 'scroll', y: 'scroll' }, + paddingAbsolute: true, + scrollbars: { + autoHide: 'leave', + autoHideDelay: 500, + pointers: ['mouse', 'pen', 'touch'], + theme: 'feishin-os-scrollbar', + visibility: 'visible', + }, + }, + }); + + useEffect(() => { + const { current: root } = scrollContainerRef; + + if (root) { + initialize({ + elements: { viewport: root.firstElementChild as HTMLElement }, + target: root, + }); + } + + return undefined; + }, [initialize]); + + useEffect(() => { + const header = stickyRowRef.current?.childNodes[0] as HTMLDivElement; + const row = rowRef.current?.childNodes[0] as HTMLDivElement; + const sticky = stickyColumnRef.current?.childNodes[0] as HTMLDivElement; + + // At minimum, we need the main row element + if (row) { + // Ensure all containers have the same height + const syncHeights = () => { + if (sticky) { + const rowHeight = row.scrollHeight; + const stickyHeight = sticky.scrollHeight; + + // Set consistent heights - use the larger of the two + const targetHeight = Math.max(rowHeight, stickyHeight); + if (sticky.style.height !== `${targetHeight}px`) { + sticky.style.height = `${targetHeight}px`; + } + if (row.style.height !== `${targetHeight}px`) { + row.style.height = `${targetHeight}px`; + } + } + }; + + const timeoutId = setTimeout(syncHeights, 0); + + const activeElement = { element: null } as { element: HTMLDivElement | null }; + const setActiveElement = (e: HTMLElementEventMap['pointermove']) => { + activeElement.element = e.currentTarget as HTMLDivElement; + }; + const setActiveElementFromWheel = (e: HTMLElementEventMap['wheel']) => { + activeElement.element = e.currentTarget as HTMLDivElement; + }; + + const syncScroll = (e: HTMLElementEventMap['scroll']) => { + if (e.currentTarget !== activeElement.element) { + return; + } + + const scrollTop = (e.currentTarget as HTMLDivElement).scrollTop; + const scrollLeft = (e.currentTarget as HTMLDivElement).scrollLeft; + + // Prevent recursive scroll events + const isScrolling = { header: false, row: false, sticky: false }; + + // Sync horizontal scroll between header and main content (only if header exists) + if (header && e.currentTarget === header && !isScrolling.row) { + isScrolling.row = true; + row.scrollTo({ + behavior: 'instant', + left: scrollLeft, + }); + setTimeout(() => { + isScrolling.row = false; + }, 0); + } + + // Sync from main content to header and sticky column + if (e.currentTarget === row && !isScrolling.header && !isScrolling.sticky) { + if (header) { + isScrolling.header = true; + header.scrollTo({ + behavior: 'instant', + left: scrollLeft, + }); + } + if (sticky) { + isScrolling.sticky = true; + sticky.scrollTo({ + behavior: 'instant', + top: scrollTop, + }); + } + setTimeout(() => { + isScrolling.header = false; + isScrolling.sticky = false; + }, 0); + } + + // Sync vertical scroll between sticky column and main content (only if sticky exists) + if (sticky && e.currentTarget === sticky && !isScrolling.row) { + isScrolling.row = true; + row.scrollTo({ + behavior: 'instant', + top: scrollTop, + }); + setTimeout(() => { + isScrolling.row = false; + }, 0); + } + }; + + // Add event listeners for elements that exist + if (header) { + header.addEventListener('pointermove', setActiveElement); + header.addEventListener('wheel', setActiveElementFromWheel); + header.addEventListener('scroll', syncScroll); + } + row.addEventListener('pointermove', setActiveElement); + row.addEventListener('wheel', setActiveElementFromWheel); + row.addEventListener('scroll', syncScroll); + if (sticky) { + sticky.addEventListener('pointermove', setActiveElement); + sticky.addEventListener('wheel', setActiveElementFromWheel); + sticky.addEventListener('scroll', syncScroll); + } + + // Add resize observer to maintain height sync + const resizeObserver = new ResizeObserver(() => { + syncHeights(); + }); + + resizeObserver.observe(row); + if (sticky) { + resizeObserver.observe(sticky); + } + + return () => { + clearTimeout(timeoutId); + 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 (sticky) { + sticky.removeEventListener('pointermove', setActiveElement); + sticky.removeEventListener('wheel', setActiveElementFromWheel); + sticky.removeEventListener('scroll', syncScroll); + } + resizeObserver.disconnect(); + }; + } + + return undefined; + }, []); + + const internalState = useItemListState(); + + const hasExpanded = internalState.hasExpanded(); + + const handleExpand = useCallback( + (_e: MouseEvent, item: unknown, itemType: LibraryItem) => { + if (item && typeof item === 'object' && 'id' in item && 'serverId' in item) { + internalState.toggleExpanded({ + id: item.id as string, + itemType: itemType, + serverId: item.serverId as string, + }); + } + }, + [internalState], + ); + const handleOnCellsRendered = useCallback( + (cells: { + columnStartIndex: number; + columnStopIndex: number; + rowStartIndex: number; + rowStopIndex: number; + }) => { + return onCellsRendered + ? ({ columnStartIndex, columnStopIndex, rowStartIndex, rowStopIndex }) => { + return onCellsRendered!( + { + columnStartIndex: columnStartIndex + (stickyColumnCount ?? 0), + columnStopIndex: columnStopIndex + (stickyColumnCount ?? 0), + rowStartIndex: rowStartIndex + (stickyRowCount ?? 0), + rowStopIndex: rowStopIndex + (stickyRowCount ?? 0), + }, + cells, + ); + } + : undefined; + }, + [onCellsRendered, stickyColumnCount, stickyRowCount], + ); + + const StickyRowCell = useCallback( + (cellProps: CellComponentProps & CellProps) => { + return ( + + ); + }, + [stickyColumnCount, CellComponent], + ); + + const StickyColumnCell = useCallback( + (cellProps: CellComponentProps & CellProps) => { + return ( + + ); + }, + [stickyRowCount, CellComponent], + ); + + const RowCell = useCallback( + (cellProps: CellComponentProps & CellProps) => { + return ( + { + console.log('click', cellProps.rowIndex); + }} + rowIndex={cellProps.rowIndex + (stickyRowCount ?? 0)} + /> + ); + }, + [stickyColumnCount, stickyRowCount, CellComponent], + ); + + return ( + +
0).reduce( + (a, _, i) => + a + + (typeof columnWidth === 'number' + ? columnWidth + : columnWidth(i, cellProps)), + 0, + )}px`, + }} + > + {!!(stickyColumnCount || stickyRowCount) && ( +
0, + ).reduce( + (a, _, i) => + a + + (typeof rowHeight === 'number' + ? rowHeight + : rowHeight(i, cellProps)), + 0, + )}px`, + }} + > + +
+ )} + {!!stickyColumnCount && ( +
+ { + return typeof rowHeight === 'number' + ? rowHeight + : rowHeight(index + (stickyRowCount ?? 0), cellProps); + }} + /> +
+ )} +
+
+ {!!stickyRowCount && ( +
0, + ).reduce( + (a, _, i) => + a + + (typeof rowHeight === 'number' + ? rowHeight + : rowHeight(i, cellProps)), + 0, + )}px`, + }} + > + { + return typeof columnWidth === 'number' + ? columnWidth + : columnWidth(index + (stickyColumnCount ?? 0), cellProps); + }} + rowCount={Array.from({ length: stickyRowCount ?? 0 }, () => 0).length} + rowHeight={(index, cellProps) => { + return typeof rowHeight === 'number' + ? rowHeight + : rowHeight(index, cellProps); + }} + /> +
+ )} +
+ { + return typeof columnWidth === 'number' + ? columnWidth + : columnWidth(index + (stickyColumnCount ?? 0), cellProps); + }} + onCellsRendered={handleOnCellsRendered} + rowCount={totalRowCount} + rowHeight={(index, cellProps) => { + return typeof rowHeight === 'number' + ? rowHeight + : rowHeight(index + (stickyRowCount ?? 0), cellProps); + }} + /> +
+
+ + {hasExpanded && ( + + + + )} + +
+ ); +};