// 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'; export interface ItemTableListColumn { id: string; label: string; width: number; } interface CellProps { columns: ItemTableListColumn[]; data: unknown[]; } interface ItemTableListProps { CellComponent: JSXElementConstructor>; columnCount: number; columns: ItemTableListColumn[]; 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, 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; 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, columnCount, columns, columnWidth, data, 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 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, 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); 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 (sticky) { sticky.removeEventListener('pointermove', setActiveElement); sticky.removeEventListener('wheel', setActiveElementFromWheel); sticky.removeEventListener('scroll', syncScroll); } resizeObserver.disconnect(); }; } return undefined; }, []); const cellProps = { columns, data, }; 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) => { return ( { // onItemClick?.(cellProps.data[cellProps.rowIndex], cellProps.rowIndex, e); // }} 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 && ( )}
); };