// 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, useState, } 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'; import { TableColumn } from '/@/shared/types/types'; export interface CellProps { columns: ItemTableListColumnConfig[]; data: unknown[]; enableHeader?: boolean; handleExpand: (e: MouseEvent, item: unknown, itemType: LibraryItem) => void; itemType: LibraryItem; size?: 'compact' | 'default'; } export interface ItemTableListColumnConfig { align: 'center' | 'end' | 'start'; id: TableColumn; pinned: 'left' | 'right' | null; width: number; } interface ItemTableListProps { CellComponent: JSXElementConstructor>; columns: ItemTableListColumnConfig[]; data: unknown[]; enableExpansion?: boolean; enableHeader?: boolean; enableSelection?: boolean; headerHeight?: number; 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, event: MouseEvent) => void; onItemContextMenu?: (item: unknown, index: number, event: MouseEvent) => void; onItemDoubleClick?: (item: unknown, index: number, event: MouseEvent) => void; onRangeChanged?: (range: { endIndex: number; startIndex: number }) => void; onScroll?: (event: UIEvent) => void; onScrollEnd?: () => void; onStartReached?: (index: number) => void; ref?: Ref; rowHeight: ((index: number, cellProps: CellProps) => number) | number; size?: 'compact' | 'default'; totalItemCount: number; } const expandedAnimationVariants: Variants = { hidden: { height: 0, minHeight: 0, }, show: { minHeight: '300px', transition: { duration: 0.3, ease: 'easeInOut', }, }, }; export const ItemTableList = ({ CellComponent, columns, data, enableHeader = true, headerHeight = 40, initialTopMostItemIndex, itemType, onCellsRendered, onEndReached, onItemClick, onItemContextMenu, onItemDoubleClick, onRangeChanged, onScroll, onScrollEnd, onStartReached, ref, rowHeight, size = 'default', totalItemCount, }: ItemTableListProps) => { const columnCount = columns.length; // eslint-disable-next-line @typescript-eslint/no-unused-vars const columnWidth = (index: number, _cellProps: CellProps) => columns[index].width; const pinnedLeftColumnCount = columns.filter((col) => col.pinned === 'left').length; const pinnedRightColumnCount = columns.filter((col) => col.pinned === 'right').length; const pinnedRowCount = enableHeader ? 1 : 0; const totalRowCount = totalItemCount - pinnedRowCount; const totalColumnCount = columnCount - pinnedLeftColumnCount - pinnedRightColumnCount; const pinnedRowRef = useRef(null); const rowRef = useRef(null); const pinnedLeftColumnRef = useRef(null); const pinnedRightColumnRef = useRef(null); const scrollContainerRef = useRef(null); const mergedRowRef = useMergedRef(rowRef, scrollContainerRef); const [showLeftShadow, setShowLeftShadow] = useState(false); const [showRightShadow, setShowRightShadow] = useState(false); 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 = pinnedRowRef.current?.childNodes[0] as HTMLDivElement; const row = rowRef.current?.childNodes[0] as HTMLDivElement; const pinnedLeft = pinnedLeftColumnRef.current?.childNodes[0] as HTMLDivElement; const pinnedRight = pinnedRightColumnRef.current?.childNodes[0] as HTMLDivElement; if (row) { // Ensure all containers have the same height const syncHeights = () => { const rowHeight = row.scrollHeight; let targetHeight = rowHeight; if (pinnedLeft) { const pinnedLeftHeight = pinnedLeft.scrollHeight; targetHeight = Math.max(targetHeight, pinnedLeftHeight); } if (pinnedRight) { const pinnedRightHeight = pinnedRight.scrollHeight; targetHeight = Math.max(targetHeight, pinnedRightHeight); } // Set consistent heights for all elements if (pinnedLeft && pinnedLeft.style.height !== `${targetHeight}px`) { 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 activeElement = { element: null } as { element: HTMLDivElement | null }; const scrollingElements = new Set(); const scrollTimeouts = new Map(); const setActiveElement = (e: HTMLElementEventMap['pointermove']) => { activeElement.element = e.currentTarget as HTMLDivElement; }; const setActiveElementFromWheel = (e: HTMLElementEventMap['wheel']) => { activeElement.element = e.currentTarget as HTMLDivElement; }; // Track which elements are actively scrolling const markElementAsScrolling = (element: HTMLDivElement) => { scrollingElements.add(element); // Clear existing timeout for this element const existingTimeout = scrollTimeouts.get(element); if (existingTimeout) { clearTimeout(existingTimeout); } // Set a timeout to remove the element from scrolling set const timeout = setTimeout(() => { scrollingElements.delete(element); scrollTimeouts.delete(element); }, 150); scrollTimeouts.set(element, timeout); }; const syncScroll = (e: HTMLElementEventMap['scroll']) => { const currentElement = e.currentTarget as HTMLDivElement; markElementAsScrolling(currentElement); // Allow sync if: // 1. Current element is the active element (normal case) // 2. Current element is actively scrolling (handles autoscroll and other continuous scrolling) const shouldSync = currentElement === activeElement.element || scrollingElements.has(currentElement); if (!shouldSync) { return; } const scrollTop = (e.currentTarget as HTMLDivElement).scrollTop; const scrollLeft = (e.currentTarget as HTMLDivElement).scrollLeft; // Prevent recursive scroll events const isScrolling = { header: false, pinnedLeft: false, pinnedRight: false, row: 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 columns if ( e.currentTarget === row && !isScrolling.header && !isScrolling.pinnedLeft && !isScrolling.pinnedRight ) { if (header) { isScrolling.header = true; header.scrollTo({ behavior: 'instant', left: scrollLeft, }); } if (pinnedLeft) { isScrolling.pinnedLeft = true; pinnedLeft.scrollTo({ behavior: 'instant', top: scrollTop, }); } if (pinnedRight) { isScrolling.pinnedRight = true; pinnedRight.scrollTo({ behavior: 'instant', top: scrollTop, }); } setTimeout(() => { isScrolling.header = false; isScrolling.pinnedLeft = false; isScrolling.pinnedRight = false; }, 0); } // Sync vertical scroll between left pinned column and main content (only if pinnedLeft exists) if (pinnedLeft && e.currentTarget === pinnedLeft && !isScrolling.row) { isScrolling.row = true; row.scrollTo({ behavior: 'instant', top: scrollTop, }); setTimeout(() => { isScrolling.row = false; }, 0); } // Sync vertical scroll between right pinned column and main content (only if pinnedRight exists) if (pinnedRight && e.currentTarget === pinnedRight && !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 (pinnedLeft) { 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); } // Add resize observer to maintain height sync const resizeObserver = new ResizeObserver(() => { syncHeights(); }); 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) { 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); } resizeObserver.disconnect(); }; } return undefined; }, []); // Handle left and right shadow visibility based on horizontal scroll useEffect(() => { const row = rowRef.current?.childNodes[0] as HTMLDivElement; if (!row) { setShowLeftShadow(false); setShowRightShadow(false); return; } const checkScrollPosition = () => { const scrollLeft = row.scrollLeft; const maxScrollLeft = row.scrollWidth - row.clientWidth; setShowLeftShadow(pinnedLeftColumnCount > 0 && scrollLeft > 0); setShowRightShadow(pinnedRightColumnCount > 0 && scrollLeft < maxScrollLeft); }; checkScrollPosition(); row.addEventListener('scroll', checkScrollPosition); return () => { row.removeEventListener('scroll', checkScrollPosition); }; }, [pinnedLeftColumnCount, pinnedRightColumnCount]); const getRowHeight = useCallback( (index: number, cellProps: CellProps) => { const baseHeight = typeof rowHeight === 'number' ? rowHeight : rowHeight(index, cellProps); // If enableHeader is true and this is the first sticky row, use fixed header height if (enableHeader && index === 0 && pinnedRowCount > 0) { return headerHeight; } return baseHeight; }, [enableHeader, headerHeight, rowHeight, pinnedRowCount], ); 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; }) => { onRangeChanged?.({ endIndex: cells.rowStopIndex, startIndex: cells.rowStartIndex, }); return onCellsRendered ? ({ columnStartIndex, columnStopIndex, rowStartIndex, rowStopIndex }) => { return onCellsRendered!( { columnStartIndex: columnStartIndex + pinnedLeftColumnCount, columnStopIndex: columnStopIndex + pinnedLeftColumnCount, rowStartIndex: rowStartIndex + pinnedRowCount, rowStopIndex: rowStopIndex + pinnedRowCount, }, cells, ); } : undefined; }, [onCellsRendered, onRangeChanged, pinnedLeftColumnCount, pinnedRowCount], ); const PinnedRowCell = useCallback( (cellProps: CellComponentProps & CellProps) => { return ( ); }, [pinnedLeftColumnCount, CellComponent], ); const PinnedColumnCell = useCallback( (cellProps: CellComponentProps & CellProps) => { return ; }, [pinnedRowCount, CellComponent], ); const PinnedRightColumnCell = useCallback( (cellProps: CellComponentProps & CellProps) => { return ( ); }, [pinnedLeftColumnCount, pinnedRowCount, totalColumnCount, CellComponent], ); const PinnedRightIntersectionCell = useCallback( (cellProps: CellComponentProps & CellProps) => { return ( ); }, [pinnedLeftColumnCount, totalColumnCount, CellComponent], ); const RowCell = useCallback( (cellProps: CellComponentProps) => { return ( { // onItemClick?.(cellProps.data[cellProps.rowIndex], cellProps.rowIndex, e); // }} rowIndex={cellProps.rowIndex + pinnedRowCount} /> ); }, [pinnedLeftColumnCount, pinnedRowCount, CellComponent], ); const cellProps = { columns, data, enableHeader, handleExpand, itemType, size, }; return (
0).reduce( (a, _, i) => a + columnWidth(i, cellProps), 0, )}px`, }} > {!!(pinnedLeftColumnCount || pinnedRowCount) && (
0, ).reduce((a, _, i) => a + getRowHeight(i, cellProps), 0)}px`, }} > {enableHeader &&
}
)} {!!pinnedLeftColumnCount && (
{ return getRowHeight(index + pinnedRowCount, cellProps); }} />
)}
{!!pinnedRowCount && (
0, ).reduce((a, _, i) => a + getRowHeight(i, cellProps), 0)}px`, } as React.CSSProperties } > { return columnWidth(index + pinnedLeftColumnCount, cellProps); }} rowCount={Array.from({ length: pinnedRowCount }, () => 0).length} rowHeight={getRowHeight} /> {enableHeader &&
}
)}
{ return columnWidth(index + pinnedLeftColumnCount, cellProps); }} onCellsRendered={handleOnCellsRendered} rowCount={totalRowCount} rowHeight={(index, cellProps) => { return getRowHeight(index + pinnedRowCount, cellProps); }} /> {pinnedLeftColumnCount > 0 && showLeftShadow && (
)} {pinnedRightColumnCount > 0 && showRightShadow && (
)}
{!!pinnedRightColumnCount && (
0, ).reduce( (a, _, i) => a + columnWidth( i + pinnedLeftColumnCount + totalColumnCount, cellProps, ), 0, )}px`, }} > {!!(pinnedRightColumnCount || pinnedRowCount) && (
0, ).reduce((a, _, i) => a + getRowHeight(i, cellProps), 0)}px`, }} > { return columnWidth( index + pinnedLeftColumnCount + totalColumnCount, cellProps, ); }} rowCount={pinnedRowCount} rowHeight={getRowHeight} /> {enableHeader && (
)}
)}
{ return columnWidth( index + pinnedLeftColumnCount + totalColumnCount, cellProps, ); }} rowCount={totalRowCount} rowHeight={(index, cellProps) => { return getRowHeight(index + pinnedRowCount, cellProps); }} />
)}
{hasExpanded && ( )} ); };