From a5fa022eb6a8987d3d77bb731e8ff643215b5a37 Mon Sep 17 00:00:00 2001 From: jeffvli Date: Fri, 16 Jan 2026 12:09:59 -0800 Subject: [PATCH] add context to item list table --- .../columns/row-index-column.tsx | 5 +- .../item-table-list/columns/title-column.tsx | 5 +- .../columns/title-combined-column.tsx | 5 +- .../item-table-list-context.tsx | 125 +++++++++++++ .../item-table-list/item-table-list.tsx | 170 +++++++++++------- 5 files changed, 232 insertions(+), 78 deletions(-) create mode 100644 src/renderer/components/item-list/item-table-list/item-table-list-context.tsx diff --git a/src/renderer/components/item-list/item-table-list/columns/row-index-column.tsx b/src/renderer/components/item-list/item-table-list/columns/row-index-column.tsx index 0fd335b84..c364a6a8a 100644 --- a/src/renderer/components/item-list/item-table-list/columns/row-index-column.tsx +++ b/src/renderer/components/item-list/item-table-list/columns/row-index-column.tsx @@ -8,6 +8,7 @@ import { TableColumnContainer, TableColumnTextContainer, } from '/@/renderer/components/item-list/item-table-list/item-table-list-column'; +import { useIsActiveRow } from '/@/renderer/components/item-list/item-table-list/item-table-list-context'; import { ItemListItem } from '/@/renderer/components/item-list/types'; import { usePlayerStatus } from '/@/renderer/store'; import { ActionIcon } from '/@/shared/components/action-icon/action-icon'; @@ -87,9 +88,7 @@ const DefaultRowIndexColumn = (props: ItemTableListInnerColumn) => { const QueueSongRowIndexColumn = (props: ItemTableListInnerColumn) => { const status = usePlayerStatus(); const song = props.data[props.rowIndex] as QueueSong; - const isActive = - !!props.activeRowId && - (props.activeRowId === song?.id || props.activeRowId === song?._uniqueId); + const isActive = useIsActiveRow(song?.id, song?._uniqueId); const isActiveAndPlaying = isActive && status === PlayerStatus.PLAYING; diff --git a/src/renderer/components/item-list/item-table-list/columns/title-column.tsx b/src/renderer/components/item-list/item-table-list/columns/title-column.tsx index f3dc22120..63082918b 100644 --- a/src/renderer/components/item-list/item-table-list/columns/title-column.tsx +++ b/src/renderer/components/item-list/item-table-list/columns/title-column.tsx @@ -10,6 +10,7 @@ import { ItemTableListInnerColumn, TableColumnContainer, } from '/@/renderer/components/item-list/item-table-list/item-table-list-column'; +import { useIsActiveRow } from '/@/renderer/components/item-list/item-table-list/item-table-list-context'; import { Text } from '/@/shared/components/text/text'; import { LibraryItem, QueueSong } from '/@/shared/types/domain-types'; @@ -75,9 +76,7 @@ function QueueSongTitleColumn(props: ItemTableListInnerColumn) { ]; const song = props.data[props.rowIndex] as QueueSong; - const isActive = - !!props.activeRowId && - (props.activeRowId === song?.id || props.activeRowId === song?._uniqueId); + const isActive = useIsActiveRow(song?.id, song?._uniqueId); if (typeof row === 'string') { const path = getTitlePath(props.itemType, (props.data[props.rowIndex] as any).id as string); diff --git a/src/renderer/components/item-list/item-table-list/columns/title-combined-column.tsx b/src/renderer/components/item-list/item-table-list/columns/title-combined-column.tsx index a323841dd..4dfb8164c 100644 --- a/src/renderer/components/item-list/item-table-list/columns/title-combined-column.tsx +++ b/src/renderer/components/item-list/item-table-list/columns/title-combined-column.tsx @@ -12,6 +12,7 @@ import { ItemTableListInnerColumn, TableColumnContainer, } from '/@/renderer/components/item-list/item-table-list/item-table-list-column'; +import { useIsActiveRow } from '/@/renderer/components/item-list/item-table-list/item-table-list-context'; import { JoinedArtists } from '/@/renderer/features/albums/components/joined-artists'; import { PlayButton } from '/@/renderer/features/shared/components/play-button'; import { @@ -162,9 +163,7 @@ export const QueueSongTitleCombinedColumn = (props: ItemTableListInnerColumn) => const internalState = (props as any).internalState; const playButtonBehavior = usePlayButtonBehavior(); const [isHovered, setIsHovered] = useState(false); - const isActive = - !!props.activeRowId && - (props.activeRowId === song?.id || props.activeRowId === song?._uniqueId); + const isActive = useIsActiveRow(song?.id, song?._uniqueId); const handlePlay = (playType: Play, event: React.MouseEvent) => { if (!item) { diff --git a/src/renderer/components/item-list/item-table-list/item-table-list-context.tsx b/src/renderer/components/item-list/item-table-list/item-table-list-context.tsx new file mode 100644 index 000000000..4cf4db435 --- /dev/null +++ b/src/renderer/components/item-list/item-table-list/item-table-list-context.tsx @@ -0,0 +1,125 @@ +import React, { createContext, useContext, useEffect, useMemo, useRef } from 'react'; +import { useSyncExternalStore } from 'react'; + +import { ItemListStateActions } from '/@/renderer/components/item-list/helpers/item-list-state'; +import { ItemControls, ItemTableListColumnConfig } from '/@/renderer/components/item-list/types'; +import { PlayerContext } from '/@/renderer/features/player/context/player-context'; +import { LibraryItem } from '/@/shared/types/domain-types'; + +/** + * Stage A/B: Provide table-scoped config + external stores so churny values can update + * without forcing `cellProps` identity changes (and therefore without rerendering every visible cell). + */ + +export type ItemTableListConfig = { + cellPadding: 'lg' | 'md' | 'sm' | 'xl' | 'xs'; + columns: ItemTableListColumnConfig[]; + controls: ItemControls; + enableHeader: boolean; + enableRowHoverHighlight: boolean; + enableSelection: boolean; + internalState: ItemListStateActions; + itemType: LibraryItem; + playerContext: PlayerContext; + size: 'compact' | 'default' | 'large'; + startRowIndex?: number; + tableId: string; +}; + +const ItemTableListConfigContext = createContext(null); + +export const ItemTableListConfigProvider = ({ + children, + value, +}: { + children: React.ReactNode; + value: ItemTableListConfig; +}) => { + // Keep reference stable when the input reference is stable. + const memoValue = useMemo(() => value, [value]); + return ( + + {children} + + ); +}; + +export const useItemTableListConfig = (): ItemTableListConfig | null => { + return useContext(ItemTableListConfigContext); +}; + +type ItemTableListStoreContextValue = { + activeRowStore: ActiveRowStore; +}; + +class ActiveRowStore { + private activeRowId: null | string = null; + private listeners = new Set<() => void>(); + + getActiveRowId(): null | string { + return this.activeRowId; + } + + setActiveRowId(next: null | string | undefined): void { + const normalized = next ?? null; + if (this.activeRowId === normalized) return; + this.activeRowId = normalized; + this.listeners.forEach((l) => l()); + } + + subscribe(listener: () => void): () => void { + this.listeners.add(listener); + return () => { + this.listeners.delete(listener); + }; + } +} + +const ItemTableListStoreContext = createContext(null); + +export const ItemTableListStoreProvider = ({ + activeRowId, + children, +}: { + activeRowId?: string; + children: React.ReactNode; +}) => { + const storeRef = useRef(null); + if (!storeRef.current) { + storeRef.current = new ActiveRowStore(); + } + const store = storeRef.current; + + useEffect(() => { + store.setActiveRowId(activeRowId); + }, [activeRowId, store]); + + const value = useMemo( + () => ({ activeRowStore: store }), + [store], + ); + + return ( + + {children} + + ); +}; + +export const useItemTableListStore = (): ItemTableListStoreContextValue | null => { + return useContext(ItemTableListStoreContext); +}; + +export const useActiveRowSubscription = (selector: (activeRowId: null | string) => T): T => { + const store = useItemTableListStore()?.activeRowStore ?? null; + + return useSyncExternalStore(store?.subscribe.bind(store) || (() => () => {}), () => + selector(store?.getActiveRowId() ?? null), + ); +}; + +export const useIsActiveRow = (...rowIds: Array): boolean => { + return useActiveRowSubscription((activeRowId) => + rowIds.some((id) => !!id && id === activeRowId), + ); +}; 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 eca6eacd2..2818c1f07 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 @@ -36,6 +36,10 @@ import { parseTableColumns } from '/@/renderer/components/item-list/helpers/pars import { useListHotkeys } from '/@/renderer/components/item-list/helpers/use-list-hotkeys'; import { useStickyTableGroupRows } from '/@/renderer/components/item-list/item-table-list/hooks/use-sticky-table-group-rows'; import { useStickyTableHeader } from '/@/renderer/components/item-list/item-table-list/hooks/use-sticky-table-header'; +import { + ItemTableListConfigProvider, + ItemTableListStoreProvider, +} from '/@/renderer/components/item-list/item-table-list/item-table-list-context'; import { ItemControls, ItemListHandle, @@ -88,7 +92,6 @@ enum TableItemSize { } interface VirtualizedTableGridProps { - activeRowId?: string; calculatedColumnWidths: number[]; CellComponent: JSXElementConstructor>; cellPadding: 'lg' | 'md' | 'sm' | 'xl' | 'xs'; @@ -132,7 +135,6 @@ interface VirtualizedTableGridProps { } const VirtualizedTableGrid = ({ - activeRowId, calculatedColumnWidths, CellComponent, cellPadding, @@ -442,7 +444,6 @@ const VirtualizedTableGrid = ({ const dynamicDataProps = useMemo( () => ({ - activeRowId, calculatedColumnWidths, data: dataWithGroups, getAdjustedRowIndex, @@ -455,7 +456,6 @@ const VirtualizedTableGrid = ({ startRowIndex, }), [ - activeRowId, calculatedColumnWidths, dataWithGroups, getAdjustedRowIndex, @@ -779,7 +779,6 @@ VirtualizedTableGrid.displayName = 'VirtualizedTableGrid'; const MemoizedVirtualizedTableGrid = memo(VirtualizedTableGrid, (prevProps, nextProps) => { return ( - prevProps.activeRowId === nextProps.activeRowId && prevProps.calculatedColumnWidths === nextProps.calculatedColumnWidths && prevProps.cellPadding === nextProps.cellPadding && prevProps.controls === nextProps.controls && @@ -837,7 +836,6 @@ export interface TableGroupHeader { } export interface TableItemProps { - activeRowId?: string; adjustedRowIndexMap?: Map; calculatedColumnWidths?: number[]; cellPadding?: ItemTableListProps['cellPadding']; @@ -2531,70 +2529,104 @@ const BaseItemTableList = ({ itemType, }); + const tableConfigValue = useMemo( + () => ({ + cellPadding, + columns: parsedColumns, + controls, + enableHeader, + enableRowHoverHighlight, + enableSelection, + internalState, + itemType, + playerContext, + size, + startRowIndex, + tableId, + }), + [ + cellPadding, + parsedColumns, + controls, + enableHeader, + enableRowHoverHighlight, + enableSelection, + internalState, + itemType, + playerContext, + size, + startRowIndex, + tableId, + ], + ); + return ( - { - const element = e.currentTarget as HTMLDivElement; - // Focus without scrolling into view - if (element.focus) { - element.focus({ preventScroll: true }); - } - }} - ref={mergedContainerRef} - tabIndex={0} - {...animationProps.fadeIn} - transition={{ duration: enableEntranceAnimation ? 1 : 0, ease: 'anticipate' }} - > - {StickyHeader} - {StickyGroupRow} - - - {/* {enableSelectionDialog && } */} - + + + { + const element = e.currentTarget as HTMLDivElement; + // Focus without scrolling into view + if (element.focus) { + element.focus({ preventScroll: true }); + } + }} + ref={mergedContainerRef} + tabIndex={0} + {...animationProps.fadeIn} + transition={{ duration: enableEntranceAnimation ? 1 : 0, ease: 'anticipate' }} + > + {StickyHeader} + {StickyGroupRow} + + + {/* {enableSelectionDialog && } */} + + + ); };