fix queue scroll follow with album group

This commit is contained in:
jeffvli
2026-07-10 02:13:52 -07:00
parent 67a2be1559
commit 5804eb3215
5 changed files with 212 additions and 214 deletions
@@ -4,11 +4,15 @@ import { ItemListStateActions } from '/@/renderer/components/item-list/helpers/i
import { ItemListHandle } from '/@/renderer/components/item-list/types';
interface UseTableImperativeHandleProps {
autoScrollToActiveRow: boolean;
enableHeader: boolean;
handleRef: React.RefObject<ItemListHandle | null>;
internalState: ItemListStateActions;
ref?: React.Ref<ItemListHandle>;
scrollToTableIndex: (index: number, options?: { align?: 'bottom' | 'center' | 'top' }) => void;
scrollToTableIndex: (
index: number,
options?: { align?: 'bottom' | 'center' | 'top'; followActiveRow?: boolean },
) => void;
scrollToTableOffset: (offset: number) => void;
}
@@ -16,6 +20,7 @@ interface UseTableImperativeHandleProps {
* Hook to set up the imperative handle for ItemTableList, providing scroll methods and internal state.
*/
export const useTableImperativeHandle = ({
autoScrollToActiveRow,
enableHeader,
handleRef,
internalState,
@@ -27,13 +32,22 @@ export const useTableImperativeHandle = ({
() => ({
internalState,
scrollToIndex: (index: number, options?: { align?: 'bottom' | 'center' | 'top' }) => {
scrollToTableIndex(enableHeader ? index + 1 : index, options);
scrollToTableIndex(enableHeader ? index + 1 : index, {
...options,
followActiveRow: autoScrollToActiveRow,
});
},
scrollToOffset: (offset: number) => {
scrollToTableOffset(offset);
},
}),
[enableHeader, internalState, scrollToTableIndex, scrollToTableOffset],
[
autoScrollToActiveRow,
enableHeader,
internalState,
scrollToTableIndex,
scrollToTableOffset,
],
);
useImperativeHandle(ref, () => imperativeHandle);
@@ -4,21 +4,17 @@ import {
ItemListStateActions,
ItemListStateItemWithRequiredProperties,
} from '/@/renderer/components/item-list/helpers/item-list-state';
import { TableItemProps } from '/@/renderer/components/item-list/item-table-list/item-table-list';
import { ItemControls } from '/@/renderer/components/item-list/types';
import { PlayerContext } from '/@/renderer/features/player/context/player-context';
import { LibraryItem } from '/@/shared/types/domain-types';
interface UseTableKeyboardNavigationProps {
calculateScrollTopForIndex: (index: number) => number;
cellPadding: TableItemProps['cellPadding'];
data: unknown[];
DEFAULT_ROW_HEIGHT: number;
enableHeader: boolean;
enableSelection: boolean;
extractRowId: (item: unknown) => string | undefined;
getItem?: (index: number) => undefined | unknown;
getItemIndex?: (rowId: string) => number | undefined;
getRowHeightAtIndex: (index: number) => number;
getStateItem: (item: any) => ItemListStateItemWithRequiredProperties | null;
hasRequiredStateItemProperties: (
item: unknown,
@@ -26,15 +22,10 @@ interface UseTableKeyboardNavigationProps {
internalState: ItemListStateActions;
itemCount?: number;
itemType: LibraryItem;
parsedColumns: TableItemProps['columns'];
pinnedRightColumnCount: number;
pinnedRightColumnRef: React.RefObject<HTMLDivElement | null>;
playerContext: PlayerContext;
rowHeight: ((index: number, cellProps: TableItemProps) => number) | number | undefined;
rowRef: React.RefObject<HTMLDivElement | null>;
scrollToTableIndex: (index: number, options?: { align?: 'bottom' | 'center' | 'top' }) => void;
size: TableItemProps['size'];
tableId: string;
}
/**
@@ -42,28 +33,21 @@ interface UseTableKeyboardNavigationProps {
*/
export const useTableKeyboardNavigation = ({
calculateScrollTopForIndex,
cellPadding,
data,
DEFAULT_ROW_HEIGHT,
enableHeader,
enableSelection,
extractRowId,
getItem,
getItemIndex,
getRowHeightAtIndex,
getStateItem,
hasRequiredStateItemProperties,
internalState,
itemCount,
itemType,
parsedColumns,
pinnedRightColumnCount,
pinnedRightColumnRef,
playerContext,
rowHeight,
rowRef,
scrollToTableIndex,
size,
tableId,
}: UseTableKeyboardNavigationProps) => {
const handleKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLDivElement>) => {
@@ -122,42 +106,7 @@ export const useTableKeyboardNavigation = ({
const viewportBottom = viewportTop + viewportHeight;
const rowTop = calculateScrollTopForIndex(gridIndex);
const adjustedIndex = enableHeader ? Math.max(0, newIndex - 1) : newIndex;
const mockCellProps: TableItemProps = {
cellPadding,
columns: parsedColumns,
controls: {} as ItemControls,
data: enableHeader ? [null] : [],
enableAlternateRowColors: false,
enableExpansion: false,
enableHeader,
enableHorizontalBorders: false,
enableRowHoverHighlight: false,
enableSelection,
enableVerticalBorders: false,
getRowHeight: () => DEFAULT_ROW_HEIGHT,
getRowItem: (rowIndex: number) => {
if (!getItem) return undefined;
if (enableHeader && rowIndex === 0) return null;
const dataIndex = enableHeader ? rowIndex - 1 : rowIndex;
return getItem(dataIndex);
},
internalState: {} as ItemListStateActions,
itemType,
playerContext,
size,
tableId,
};
let calculatedRowHeight: number;
if (typeof rowHeight === 'number') {
calculatedRowHeight = rowHeight;
} else if (typeof rowHeight === 'function') {
calculatedRowHeight = rowHeight(adjustedIndex, mockCellProps);
} else {
calculatedRowHeight = DEFAULT_ROW_HEIGHT;
}
const calculatedRowHeight = getRowHeightAtIndex(gridIndex);
const rowBottom = rowTop + calculatedRowHeight;
// Check if row is fully visible within viewport
@@ -187,28 +136,21 @@ export const useTableKeyboardNavigation = ({
},
[
calculateScrollTopForIndex,
cellPadding,
data,
getItem,
getItemIndex,
DEFAULT_ROW_HEIGHT,
enableHeader,
enableSelection,
extractRowId,
getRowHeightAtIndex,
getStateItem,
hasRequiredStateItemProperties,
internalState,
itemCount,
itemType,
parsedColumns,
pinnedRightColumnCount,
pinnedRightColumnRef,
playerContext,
rowHeight,
rowRef,
scrollToTableIndex,
size,
tableId,
],
);
@@ -1,101 +1,45 @@
import { useCallback, useMemo } from 'react';
import { useCallback, useEffect, useMemo, useRef } from 'react';
import { TableItemProps } from '../item-table-list';
import { ItemListStateActions } from '/@/renderer/components/item-list/helpers/item-list-state';
import { ItemControls } from '/@/renderer/components/item-list/types';
import { PlayerContext } from '/@/renderer/features/player/context/player-context';
import { LibraryItem } from '/@/shared/types/domain-types';
export type TableScrollToIndexOptions = {
align?: 'bottom' | 'center' | 'top';
followActiveRow?: boolean;
};
export const useTableScrollToIndex = ({
cellPadding,
columns,
data,
enableAlternateRowColors,
enableExpansion,
albumGroupContentHeights,
autoScrollToActiveRow,
enableHeader,
enableHorizontalBorders,
enableRowHoverHighlight,
enableSelection,
enableVerticalBorders,
itemType,
getRowHeight,
hasAlbumGroupColumn,
pinnedLeftColumnRef,
pinnedRightColumnRef,
playerContext,
rowHeight,
pinnedRowCount,
rowRef,
size,
tableId,
scrollCellProps,
}: {
cellPadding: 'lg' | 'md' | 'sm' | 'xl' | 'xs';
columns: TableItemProps['columns'];
data: unknown[];
enableAlternateRowColors: boolean;
enableExpansion: boolean;
albumGroupContentHeights: Map<number, number>;
autoScrollToActiveRow: boolean;
enableHeader: boolean;
enableHorizontalBorders: boolean;
enableRowHoverHighlight: boolean;
enableSelection: boolean;
enableVerticalBorders: boolean;
itemType: LibraryItem;
getRowHeight: (index: number, cellProps: TableItemProps) => number;
hasAlbumGroupColumn: boolean;
pinnedLeftColumnRef: React.RefObject<HTMLDivElement | null>;
pinnedRightColumnRef: React.RefObject<HTMLDivElement | null>;
playerContext: PlayerContext;
rowHeight: ((index: number, cellProps: TableItemProps) => number) | number | undefined;
pinnedRowCount: number;
rowRef: React.RefObject<HTMLDivElement | null>;
size: 'compact' | 'default' | 'large';
tableId: string;
scrollCellProps: TableItemProps;
}) => {
const DEFAULT_ROW_HEIGHT = useMemo(() => {
return size === 'compact' ? 40 : size === 'large' ? 88 : 64;
}, [size]);
const mockCellPropsBase = useMemo<TableItemProps>(
() => ({
cellPadding,
columns,
controls: {} as ItemControls,
data: enableHeader ? [null, ...data] : data,
enableAlternateRowColors,
enableExpansion,
enableHeader,
enableHorizontalBorders,
enableRowHoverHighlight,
enableSelection,
enableVerticalBorders,
getRowHeight: () => DEFAULT_ROW_HEIGHT,
internalState: {} as ItemListStateActions,
itemType,
playerContext,
size,
tableId,
}),
[
DEFAULT_ROW_HEIGHT,
cellPadding,
columns,
data,
enableAlternateRowColors,
enableExpansion,
enableHeader,
enableHorizontalBorders,
enableRowHoverHighlight,
enableSelection,
enableVerticalBorders,
itemType,
playerContext,
size,
tableId,
],
);
const isProgrammaticScrollRef = useRef(false);
const userInterruptedFollowRef = useRef(false);
const pendingFollowScrollRef = useRef<null | {
index: number;
options?: TableScrollToIndexOptions;
}>(null);
const getRowHeightAtIndex = useCallback(
(index: number) => {
if (typeof rowHeight === 'number') return rowHeight;
if (typeof rowHeight === 'function') return rowHeight(index, mockCellPropsBase);
return DEFAULT_ROW_HEIGHT;
},
[DEFAULT_ROW_HEIGHT, mockCellPropsBase, rowHeight],
(index: number) => getRowHeight(index, scrollCellProps),
[getRowHeight, scrollCellProps],
);
const scrollToTableOffset = useCallback(
@@ -110,6 +54,8 @@ export const useTableScrollToIndex = ({
const behavior = 'instant';
isProgrammaticScrollRef.current = true;
if (mainContainer) {
mainContainer.scrollTo({ behavior, top: offset });
}
@@ -119,37 +65,38 @@ export const useTableScrollToIndex = ({
if (pinnedRightContainer) {
pinnedRightContainer.scrollTo({ behavior, top: offset });
}
requestAnimationFrame(() => {
isProgrammaticScrollRef.current = false;
});
},
[pinnedLeftColumnRef, pinnedRightColumnRef, rowRef],
);
const scrollRowOffset = enableHeader ? pinnedRowCount : 0;
const calculateScrollTopForIndex = useCallback(
(index: number) => {
const adjustedIndex = enableHeader ? Math.max(0, index - 1) : index;
let scrollTop = 0;
for (let i = 0; i < adjustedIndex; i++) {
for (let i = scrollRowOffset; i < index; i++) {
scrollTop += getRowHeightAtIndex(i);
}
return scrollTop;
},
[enableHeader, getRowHeightAtIndex],
[getRowHeightAtIndex, scrollRowOffset],
);
const scrollToTableIndex = useCallback(
(index: number, options?: { align?: 'bottom' | 'center' | 'top' }) => {
const applyScrollToIndex = useCallback(
(index: number, options?: TableScrollToIndexOptions) => {
const mainContainer = rowRef.current?.childNodes[0] as HTMLDivElement | undefined;
if (!mainContainer) return;
const viewportHeight = mainContainer.clientHeight;
const align = options?.align || 'top';
// Calculate the base scroll offset (top of the row)
let offset = calculateScrollTopForIndex(index);
// Calculate row height for the target index
const adjustedIndex = enableHeader ? Math.max(0, index - 1) : index;
const targetRowHeight = getRowHeightAtIndex(adjustedIndex);
const targetRowHeight = getRowHeightAtIndex(index);
if (align === 'center') {
offset = offset - viewportHeight / 2 + targetRowHeight / 2;
@@ -160,22 +107,64 @@ export const useTableScrollToIndex = ({
offset = Math.max(0, offset);
scrollToTableOffset(offset);
},
[
calculateScrollTopForIndex,
enableHeader,
getRowHeightAtIndex,
rowRef,
scrollToTableOffset,
],
[calculateScrollTopForIndex, getRowHeightAtIndex, rowRef, scrollToTableOffset],
);
const scrollToTableIndex = useCallback(
(index: number, options?: TableScrollToIndexOptions) => {
const shouldFollow = autoScrollToActiveRow && options?.followActiveRow;
if (shouldFollow) {
pendingFollowScrollRef.current = { index, options };
userInterruptedFollowRef.current = false;
}
applyScrollToIndex(index, options);
},
[applyScrollToIndex, autoScrollToActiveRow],
);
useEffect(() => {
const viewport = rowRef.current?.childNodes[0] as HTMLDivElement | undefined;
if (!viewport) return;
const handleUserScroll = () => {
if (isProgrammaticScrollRef.current) return;
userInterruptedFollowRef.current = true;
pendingFollowScrollRef.current = null;
};
viewport.addEventListener('scroll', handleUserScroll, { passive: true });
return () => viewport.removeEventListener('scroll', handleUserScroll);
}, [rowRef]);
useEffect(() => {
if (!autoScrollToActiveRow || !hasAlbumGroupColumn) return;
const pending = pendingFollowScrollRef.current;
if (!pending || userInterruptedFollowRef.current) return;
if (albumGroupContentHeights.size === 0) return;
const timeout = window.setTimeout(() => {
if (userInterruptedFollowRef.current) return;
const currentPending = pendingFollowScrollRef.current;
if (!currentPending) return;
applyScrollToIndex(currentPending.index, currentPending.options);
}, 50);
return () => window.clearTimeout(timeout);
}, [albumGroupContentHeights, applyScrollToIndex, autoScrollToActiveRow, hasAlbumGroupColumn]);
return useMemo(
() => ({
calculateScrollTopForIndex,
DEFAULT_ROW_HEIGHT,
getRowHeightAtIndex,
scrollToTableIndex,
scrollToTableOffset,
}),
[calculateScrollTopForIndex, DEFAULT_ROW_HEIGHT, scrollToTableIndex, scrollToTableOffset],
[calculateScrollTopForIndex, getRowHeightAtIndex, scrollToTableIndex, scrollToTableOffset],
);
};
@@ -816,6 +816,7 @@ export interface TableItemProps {
interface ItemTableListProps {
activeRowId?: string;
autoFitColumns?: boolean;
autoScrollToActiveRow?: boolean;
CellComponent?: JSXElementConstructor<CellComponentProps<TableItemProps>>;
cellPadding?: 'lg' | 'md' | 'sm' | 'xl' | 'xs';
columns: ItemTableListColumnConfig[];
@@ -1255,6 +1256,7 @@ ItemTableListStickyUI.displayName = 'ItemTableListStickyUI';
const BaseItemTableList = ({
activeRowId,
autoFitColumns = false,
autoScrollToActiveRow = false,
CellComponent = ItemTableListColumn,
cellPadding = 'sm',
columns,
@@ -1411,47 +1413,10 @@ const BaseItemTableList = ({
onScrollEndRef.current = onScrollEnd;
}, [onScrollEnd]);
const {
calculateScrollTopForIndex,
DEFAULT_ROW_HEIGHT,
scrollToTableIndex,
scrollToTableOffset,
} = useTableScrollToIndex({
cellPadding,
columns: parsedColumns,
data,
enableAlternateRowColors,
enableExpansion,
enableHeader,
enableHorizontalBorders,
enableRowHoverHighlight,
enableSelection,
enableVerticalBorders,
itemType,
pinnedLeftColumnRef,
pinnedRightColumnRef,
playerContext,
rowHeight,
rowRef,
size,
tableId,
});
useTablePaneSync({
enableDrag,
enableDragScroll,
enableHeader,
handleRef,
onScrollEndRef,
pinnedLeftColumnCount,
pinnedLeftColumnRef,
pinnedRightColumnCount,
pinnedRightColumnRef,
pinnedRowRef,
rowRef,
scrollContainerRef,
scrollShadowStore,
});
const hasAlbumGroupColumn = useMemo(
() => parsedColumns.some((col) => col.id === TableColumn.ALBUM_GROUP),
[parsedColumns],
);
const getRowHeight = useCallback(
(index: number, cellProps: TableItemProps) => {
@@ -1515,6 +1480,99 @@ const BaseItemTableList = ({
[albumGroupImageSize, enableHeader, headerHeight, rowHeight, pinnedRowCount, size],
);
const scrollCellProps = useMemo<TableItemProps>(
() => ({
albumGroupContentHeights,
albumGroupImageSize,
cellPadding,
columns: parsedColumns,
controls: {} as ItemControls,
data: dataWithGroups,
enableAlternateRowColors,
enableExpansion,
enableHeader,
enableHorizontalBorders,
enableRowHoverHighlight,
enableSelection,
enableVerticalBorders,
getRowHeight,
getRowItem: (rowIndex: number) => {
if (shouldUseAccessor && getItem) {
if (enableHeader && rowIndex === 0) {
return null;
}
const dataIndex = enableHeader ? rowIndex - 1 : rowIndex;
return getItem(dataIndex);
}
return dataWithGroups[rowIndex];
},
hasAlbumGroupColumn,
internalState: {} as ItemListStateActions,
itemType,
playerContext,
size,
tableId,
}),
[
albumGroupContentHeights,
albumGroupImageSize,
cellPadding,
dataWithGroups,
enableAlternateRowColors,
enableExpansion,
enableHeader,
enableHorizontalBorders,
enableRowHoverHighlight,
enableSelection,
enableVerticalBorders,
getItem,
getRowHeight,
hasAlbumGroupColumn,
itemType,
parsedColumns,
playerContext,
shouldUseAccessor,
size,
tableId,
],
);
const {
calculateScrollTopForIndex,
getRowHeightAtIndex,
scrollToTableIndex,
scrollToTableOffset,
} = useTableScrollToIndex({
albumGroupContentHeights,
autoScrollToActiveRow,
enableHeader,
getRowHeight,
hasAlbumGroupColumn,
pinnedLeftColumnRef,
pinnedRightColumnRef,
pinnedRowCount,
rowRef,
scrollCellProps,
});
useTablePaneSync({
enableDrag,
enableDragScroll,
enableHeader,
handleRef,
onScrollEndRef,
pinnedLeftColumnCount,
pinnedLeftColumnRef,
pinnedRightColumnCount,
pinnedRightColumnRef,
pinnedRowRef,
rowRef,
scrollContainerRef,
scrollShadowStore,
});
// Create a wrapper for getRowHeight that doesn't require cellProps (for sticky group rows hook)
const getRowHeightWrapper = useCallback(
(index: number) => {
@@ -1574,28 +1632,21 @@ const BaseItemTableList = ({
const { handleKeyDown } = useTableKeyboardNavigation({
calculateScrollTopForIndex,
cellPadding,
data,
DEFAULT_ROW_HEIGHT,
enableHeader,
enableSelection,
extractRowId,
getItem,
getItemIndex,
getRowHeightAtIndex,
getStateItem,
hasRequiredStateItemProperties,
internalState,
itemCount: baseItemCount,
itemType,
parsedColumns,
pinnedRightColumnCount,
pinnedRightColumnRef,
playerContext,
rowHeight,
rowRef,
scrollToTableIndex,
size,
tableId,
});
useTableInitialScroll({
@@ -1606,6 +1657,7 @@ const BaseItemTableList = ({
});
useTableImperativeHandle({
autoScrollToActiveRow,
enableHeader,
handleRef,
internalState,
@@ -179,6 +179,7 @@ export const PlayQueue = forwardRef<ItemListHandle, QueueProps>(
<ItemTableList
activeRowId={currentSongUniqueId}
autoFitColumns={table.autoFitColumns}
autoScrollToActiveRow={followCurrentSong}
CellComponent={ItemTableListColumn}
columns={table.columns}
data={filteredData}