mirror of
https://github.com/jeffvli/feishin.git
synced 2026-05-16 21:50:35 +02:00
217 lines
8.2 KiB
TypeScript
217 lines
8.2 KiB
TypeScript
import { useCallback } from 'react';
|
|
|
|
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;
|
|
getStateItem: (item: any) => ItemListStateItemWithRequiredProperties | null;
|
|
hasRequiredStateItemProperties: (
|
|
item: unknown,
|
|
) => item is ItemListStateItemWithRequiredProperties;
|
|
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;
|
|
}
|
|
|
|
/**
|
|
* Hook to handle keyboard navigation (ArrowUp/ArrowDown) for table row selection and scrolling.
|
|
*/
|
|
export const useTableKeyboardNavigation = ({
|
|
calculateScrollTopForIndex,
|
|
cellPadding,
|
|
data,
|
|
DEFAULT_ROW_HEIGHT,
|
|
enableHeader,
|
|
enableSelection,
|
|
extractRowId,
|
|
getItem,
|
|
getItemIndex,
|
|
getStateItem,
|
|
hasRequiredStateItemProperties,
|
|
internalState,
|
|
itemCount,
|
|
itemType,
|
|
parsedColumns,
|
|
pinnedRightColumnCount,
|
|
pinnedRightColumnRef,
|
|
playerContext,
|
|
rowHeight,
|
|
rowRef,
|
|
scrollToTableIndex,
|
|
size,
|
|
tableId,
|
|
}: UseTableKeyboardNavigationProps) => {
|
|
const handleKeyDown = useCallback(
|
|
(e: React.KeyboardEvent<HTMLDivElement>) => {
|
|
if (!enableSelection) return;
|
|
if (e.key !== 'ArrowDown' && e.key !== 'ArrowUp') return;
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
|
|
const selected = internalState.getSelected();
|
|
const validSelected = selected.filter(hasRequiredStateItemProperties);
|
|
let currentIndex = -1;
|
|
const totalCount = itemCount ?? data.length;
|
|
|
|
if (validSelected.length > 0) {
|
|
const lastSelected = validSelected[validSelected.length - 1];
|
|
const rowId = extractRowId(lastSelected);
|
|
if (rowId) {
|
|
currentIndex =
|
|
getItemIndex?.(rowId) ?? data.findIndex((d) => extractRowId(d) === rowId);
|
|
}
|
|
}
|
|
|
|
let newIndex = 0;
|
|
if (currentIndex !== -1) {
|
|
newIndex =
|
|
e.key === 'ArrowDown'
|
|
? Math.min(currentIndex + 1, totalCount - 1)
|
|
: Math.max(currentIndex - 1, 0);
|
|
}
|
|
|
|
const newItem: any = getItem ? getItem(newIndex) : data[newIndex];
|
|
if (!newItem) return;
|
|
|
|
const newItemListItem = getStateItem(newItem);
|
|
if (newItemListItem && extractRowId(newItemListItem)) {
|
|
internalState.setSelected([newItemListItem]);
|
|
}
|
|
|
|
// Check if we need to scroll by determining if the item is at the edge of the viewport
|
|
const gridIndex = enableHeader ? newIndex + 1 : newIndex;
|
|
|
|
const mainContainer = rowRef.current?.childNodes[0] as HTMLDivElement | undefined;
|
|
const pinnedRightContainer = pinnedRightColumnRef.current?.childNodes[0] as
|
|
| HTMLDivElement
|
|
| undefined;
|
|
|
|
// Use right pinned column scroll position if right-pinned columns exist
|
|
const scrollContainer =
|
|
pinnedRightColumnCount > 0 && pinnedRightContainer
|
|
? pinnedRightContainer
|
|
: mainContainer;
|
|
|
|
if (scrollContainer) {
|
|
const viewportTop = scrollContainer.scrollTop;
|
|
const viewportHeight = scrollContainer.clientHeight;
|
|
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 rowBottom = rowTop + calculatedRowHeight;
|
|
|
|
// Check if row is fully visible within viewport
|
|
const isFullyVisible = rowTop >= viewportTop && rowBottom <= viewportBottom;
|
|
|
|
// Check if row is at the edge (top or bottom of viewport)
|
|
const isAtTopEdge = rowTop < viewportTop;
|
|
const isAtBottomEdge = rowBottom >= viewportBottom;
|
|
|
|
// Only scroll if the item is not fully visible or at the edge
|
|
if (!isFullyVisible || isAtTopEdge || isAtBottomEdge) {
|
|
// Determine alignment based on direction
|
|
const align: 'bottom' | 'top' =
|
|
e.key === 'ArrowDown' && isAtBottomEdge
|
|
? 'bottom'
|
|
: e.key === 'ArrowUp' && isAtTopEdge
|
|
? 'top'
|
|
: isAtBottomEdge
|
|
? 'bottom'
|
|
: isAtTopEdge
|
|
? 'top'
|
|
: 'top';
|
|
|
|
scrollToTableIndex(gridIndex, { align });
|
|
}
|
|
}
|
|
},
|
|
[
|
|
calculateScrollTopForIndex,
|
|
cellPadding,
|
|
data,
|
|
getItem,
|
|
getItemIndex,
|
|
DEFAULT_ROW_HEIGHT,
|
|
enableHeader,
|
|
enableSelection,
|
|
extractRowId,
|
|
getStateItem,
|
|
hasRequiredStateItemProperties,
|
|
internalState,
|
|
itemCount,
|
|
itemType,
|
|
parsedColumns,
|
|
pinnedRightColumnCount,
|
|
pinnedRightColumnRef,
|
|
playerContext,
|
|
rowHeight,
|
|
rowRef,
|
|
scrollToTableIndex,
|
|
size,
|
|
tableId,
|
|
],
|
|
);
|
|
|
|
return { handleKeyDown };
|
|
};
|