split out item list table functionality

This commit is contained in:
jeffvli
2026-01-16 14:05:32 -08:00
parent a5fa022eb6
commit e2b20eb89b
12 changed files with 1622 additions and 1237 deletions
@@ -0,0 +1,82 @@
import { useEffect } from 'react';
interface UseContainerWidthTrackingProps {
autoFitColumns: boolean;
containerRef: React.RefObject<HTMLDivElement | null>;
rowRef: React.RefObject<HTMLDivElement | null>;
setCenterContainerWidth: (width: number) => void;
setTotalContainerWidth: (width: number) => void;
}
/**
* Hook to track container widths using ResizeObserver for column width calculations.
*/
export const useContainerWidthTracking = ({
autoFitColumns,
containerRef,
rowRef,
setCenterContainerWidth,
setTotalContainerWidth,
}: UseContainerWidthTrackingProps) => {
// Track center container width (for column distribution)
useEffect(() => {
const el = rowRef.current;
if (!el) return;
const updateWidth = () => {
setCenterContainerWidth(el.clientWidth || 0);
};
updateWidth();
let debounceTimeout: NodeJS.Timeout | null = null;
const resizeObserver = new ResizeObserver(() => {
if (debounceTimeout) {
clearTimeout(debounceTimeout);
}
debounceTimeout = setTimeout(() => {
updateWidth();
}, 100);
});
resizeObserver.observe(el);
return () => {
if (debounceTimeout) {
clearTimeout(debounceTimeout);
}
resizeObserver.disconnect();
};
}, [rowRef, setCenterContainerWidth]);
// Track total container width for autoFitColumns
useEffect(() => {
const el = containerRef.current;
if (!el || !autoFitColumns) return;
const updateWidth = () => {
setTotalContainerWidth(el.clientWidth || 0);
};
updateWidth();
let debounceTimeout: NodeJS.Timeout | null = null;
const resizeObserver = new ResizeObserver(() => {
if (debounceTimeout) {
clearTimeout(debounceTimeout);
}
debounceTimeout = setTimeout(() => {
updateWidth();
}, 100);
});
resizeObserver.observe(el);
return () => {
if (debounceTimeout) {
clearTimeout(debounceTimeout);
}
resizeObserver.disconnect();
};
}, [autoFitColumns, containerRef, setTotalContainerWidth]);
};
@@ -0,0 +1,158 @@
import { useEffect } from 'react';
interface UseRowInteractionDelegateProps {
containerRef: React.RefObject<HTMLDivElement | null>;
enableRowHoverHighlight: boolean;
}
/**
* Hook to handle row hover and drag-over styling via delegated event listeners.
* This is intentionally imperative to avoid React re-rendering the entire visible grid on hover.
*/
export const useRowInteractionDelegate = ({
containerRef,
enableRowHoverHighlight,
}: UseRowInteractionDelegateProps) => {
// Row hover highlight: do one delegated listener per table rather than per cell
useEffect(() => {
if (!enableRowHoverHighlight) return;
const root = containerRef.current;
if (!root) return;
let hoveredKey: null | string = null;
let rafId: null | number = null;
const getRowKey = (target: EventTarget | null): null | string => {
const el = target instanceof Element ? target : null;
const rowEl = el?.closest?.('[data-row-index]') as HTMLElement | null;
return rowEl?.getAttribute('data-row-index') ?? null;
};
const apply = (prev: null | string, next: null | string) => {
if (rafId !== null) cancelAnimationFrame(rafId);
rafId = requestAnimationFrame(() => {
if (prev) {
root.querySelectorAll(`[data-row-index="${prev}"]`).forEach((node) => {
(node as HTMLElement).removeAttribute('data-row-hovered');
});
}
if (next) {
root.querySelectorAll(`[data-row-index="${next}"]`).forEach((node) => {
(node as HTMLElement).setAttribute('data-row-hovered', 'true');
});
}
});
};
const setHovered = (next: null | string) => {
if (next === hoveredKey) return;
const prev = hoveredKey;
hoveredKey = next;
apply(prev, next);
};
const onPointerOver = (e: PointerEvent) => {
setHovered(getRowKey(e.target));
};
const onPointerOut = (e: PointerEvent) => {
// If moving within the same row, keep it hovered
const relatedKey = getRowKey((e as any).relatedTarget);
if (relatedKey === hoveredKey) return;
setHovered(relatedKey);
};
root.addEventListener('pointerover', onPointerOver);
root.addEventListener('pointerout', onPointerOut);
return () => {
root.removeEventListener('pointerover', onPointerOver);
root.removeEventListener('pointerout', onPointerOut);
if (rafId !== null) cancelAnimationFrame(rafId);
// Ensure we don't leave stale attributes behind
if (hoveredKey) apply(hoveredKey, null);
};
}, [containerRef, enableRowHoverHighlight]);
// Dragged-over row border styling delegation
useEffect(() => {
const root = containerRef.current;
if (!root) return;
let current: null | { edge: 'bottom' | 'top'; rowKey: string } = null;
let pending: null | { edge: 'bottom' | 'top' | null; rowKey: string } = null;
let rafId: null | number = null;
const clearRow = (rowKey: string) => {
root.querySelectorAll(`[data-row-index="${rowKey}"]`).forEach((node) => {
const el = node as HTMLElement;
el.removeAttribute('data-row-dragged-over');
el.removeAttribute('data-row-dragged-over-first');
});
};
const applyRow = (rowKey: string, edge: 'bottom' | 'top') => {
const nodes = root.querySelectorAll(`[data-row-index="${rowKey}"]`);
nodes.forEach((node, idx) => {
const el = node as HTMLElement;
el.setAttribute('data-row-dragged-over', edge);
if (idx === 0) {
el.setAttribute('data-row-dragged-over-first', 'true');
} else {
el.removeAttribute('data-row-dragged-over-first');
}
});
};
const flush = () => {
rafId = null;
const next = pending;
pending = null;
if (!next) return;
// Clear previous row if we're moving rows or clearing.
if (current && current.rowKey !== next.rowKey) {
clearRow(current.rowKey);
current = null;
}
if (!next.edge) {
if (current) {
clearRow(current.rowKey);
current = null;
}
return;
}
// If same row + edge, no-op.
if (current && current.rowKey === next.rowKey && current.edge === next.edge) return;
if (current) clearRow(current.rowKey);
applyRow(next.rowKey, next.edge);
current = { edge: next.edge, rowKey: next.rowKey };
};
const scheduleFlush = () => {
if (rafId !== null) return;
rafId = requestAnimationFrame(flush);
};
const onRowDragOver = (e: Event) => {
const ev = e as CustomEvent<{ edge?: 'bottom' | 'top' | null; rowKey?: string }>;
const rowKey = ev.detail?.rowKey;
const edge = ev.detail?.edge ?? null;
if (!rowKey) return;
pending = { edge, rowKey };
scheduleFlush();
};
root.addEventListener('itl:row-drag-over', onRowDragOver as any);
return () => {
root.removeEventListener('itl:row-drag-over', onRowDragOver as any);
if (rafId !== null) cancelAnimationFrame(rafId);
if (current) clearRow(current.rowKey);
};
}, [containerRef]);
};
@@ -0,0 +1,51 @@
import { useEffect } from 'react';
interface UseStickyGroupRowPositioningProps {
containerRef: React.RefObject<HTMLDivElement | null>;
shouldRenderStickyGroupRow: boolean;
stickyGroupRowRef: React.RefObject<HTMLDivElement | null>;
}
/**
* Hook to update the position and width of the sticky group row based on container position.
*/
export const useStickyGroupRowPositioning = ({
containerRef,
shouldRenderStickyGroupRow,
stickyGroupRowRef,
}: UseStickyGroupRowPositioningProps) => {
useEffect(() => {
if (!shouldRenderStickyGroupRow || !stickyGroupRowRef.current || !containerRef.current) {
return;
}
const stickyGroupRow = stickyGroupRowRef.current;
const container = containerRef.current;
let isMounted = true;
const updatePosition = () => {
// Guard against updates after unmount
if (!isMounted || !stickyGroupRow || !container) {
return;
}
try {
const containerRect = container.getBoundingClientRect();
stickyGroupRow.style.left = `${containerRect.left}px`;
stickyGroupRow.style.width = `${containerRect.width}px`;
} catch {
// Silently handle errors if elements are no longer in DOM
}
};
updatePosition();
window.addEventListener('resize', updatePosition);
window.addEventListener('scroll', updatePosition, true);
return () => {
isMounted = false;
window.removeEventListener('resize', updatePosition);
window.removeEventListener('scroll', updatePosition, true);
};
}, [containerRef, shouldRenderStickyGroupRow, stickyGroupRowRef]);
};
@@ -0,0 +1,52 @@
import { useEffect } from 'react';
interface UseStickyHeaderPositioningProps {
containerRef: React.RefObject<HTMLDivElement | null>;
shouldShowStickyHeader: boolean;
stickyHeaderRef: React.RefObject<HTMLDivElement | null>;
}
/**
* Hook to update the position and width of the sticky header based on container position.
* Scroll synchronization is handled separately in useStickyTableHeader.
*/
export const useStickyHeaderPositioning = ({
containerRef,
shouldShowStickyHeader,
stickyHeaderRef,
}: UseStickyHeaderPositioningProps) => {
useEffect(() => {
if (!shouldShowStickyHeader || !stickyHeaderRef.current || !containerRef.current) {
return;
}
const stickyHeader = stickyHeaderRef.current;
const container = containerRef.current;
let isMounted = true;
const updatePosition = () => {
// Guard against updates after unmount
if (!isMounted || !stickyHeader || !container) {
return;
}
try {
const containerRect = container.getBoundingClientRect();
stickyHeader.style.left = `${containerRect.left}px`;
stickyHeader.style.width = `${containerRect.width}px`;
} catch {
// Silently handle errors if elements are no longer in DOM
}
};
updatePosition();
window.addEventListener('resize', updatePosition);
window.addEventListener('scroll', updatePosition, true);
return () => {
isMounted = false;
window.removeEventListener('resize', updatePosition);
window.removeEventListener('scroll', updatePosition, true);
};
}, [containerRef, shouldShowStickyHeader, stickyHeaderRef]);
};
@@ -0,0 +1,115 @@
import { useMemo } from 'react';
import { parseTableColumns } from '/@/renderer/components/item-list/helpers/parse-table-columns';
import { ItemTableListColumnConfig } from '/@/renderer/components/item-list/types';
export const useTableColumnModel = ({
autoFitColumns,
centerContainerWidth,
columns,
totalContainerWidth,
}: {
autoFitColumns: boolean;
centerContainerWidth: number;
columns: ItemTableListColumnConfig[];
totalContainerWidth: number;
}) => {
const parsedColumns = useMemo(() => parseTableColumns(columns), [columns]);
const calculatedColumnWidths = useMemo(() => {
const baseWidths = parsedColumns.map((c) => c.width);
// When autoSizeColumns is enabled, treat all widths as proportions and scale to fit container
if (autoFitColumns) {
const totalReferenceWidth = baseWidths.reduce((sum, width) => sum + width, 0);
if (totalReferenceWidth === 0 || totalContainerWidth === 0) {
return baseWidths.map((width) => Math.round(width));
}
const scaleFactor = totalContainerWidth / totalReferenceWidth;
const scaledWidths = baseWidths.map((width) => Math.round(width * scaleFactor));
// Adjust for rounding errors: ensure total equals totalContainerWidth
const totalScaled = scaledWidths.reduce((sum, width) => sum + width, 0);
const difference = totalContainerWidth - totalScaled;
if (difference !== 0 && scaledWidths.length > 0) {
const sortedIndices = scaledWidths
.map((width, idx) => ({ idx, width }))
.sort((a, b) => b.width - a.width);
const adjustmentPerColumn = Math.sign(difference);
const adjustmentCount = Math.abs(difference);
for (let i = 0; i < adjustmentCount && i < sortedIndices.length; i++) {
scaledWidths[sortedIndices[i].idx] += adjustmentPerColumn;
}
}
return scaledWidths;
}
// Original behavior: distribute extra space to auto-size columns
const distributed = baseWidths.slice();
const unpinnedIndices: number[] = [];
const autoUnpinnedIndices: number[] = [];
parsedColumns.forEach((col, idx) => {
if (col.pinned === null) {
unpinnedIndices.push(idx);
if (col.autoSize) {
autoUnpinnedIndices.push(idx);
}
}
});
if (unpinnedIndices.length === 0 || autoUnpinnedIndices.length === 0) {
return distributed.map((width) => Math.round(width));
}
const unpinnedBaseTotal = unpinnedIndices.reduce((sum, idx) => sum + baseWidths[idx], 0);
const extra = Math.max(0, centerContainerWidth - unpinnedBaseTotal);
if (extra <= 0) {
return distributed.map((width) => Math.round(width));
}
const extraPer = extra / autoUnpinnedIndices.length;
autoUnpinnedIndices.forEach((idx) => {
distributed[idx] = Math.round(baseWidths[idx] + extraPer);
});
return distributed.map((width) => Math.round(width));
}, [autoFitColumns, centerContainerWidth, parsedColumns, totalContainerWidth]);
const pinnedLeftColumnCount = useMemo(
() => parsedColumns.filter((col) => col.pinned === 'left').length,
[parsedColumns],
);
const pinnedRightColumnCount = useMemo(
() => parsedColumns.filter((col) => col.pinned === 'right').length,
[parsedColumns],
);
const columnCount = parsedColumns.length;
const totalColumnCount = columnCount - pinnedLeftColumnCount - pinnedRightColumnCount;
return useMemo(
() => ({
calculatedColumnWidths,
columnCount,
parsedColumns,
pinnedLeftColumnCount,
pinnedRightColumnCount,
totalColumnCount,
}),
[
calculatedColumnWidths,
columnCount,
parsedColumns,
pinnedLeftColumnCount,
pinnedRightColumnCount,
totalColumnCount,
],
);
};
@@ -0,0 +1,43 @@
import { useEffect, useImperativeHandle, useMemo } from 'react';
import { ItemListHandle, ItemListStateActions } from '/@/renderer/components/item-list/types';
interface UseTableImperativeHandleProps {
enableHeader: boolean;
handleRef: React.RefObject<ItemListHandle | null>;
internalState: ItemListStateActions;
ref?: React.Ref<ItemListHandle>;
scrollToTableIndex: (index: number, options?: { align?: 'bottom' | 'center' | 'top' }) => void;
scrollToTableOffset: (offset: number) => void;
}
/**
* Hook to set up the imperative handle for ItemTableList, providing scroll methods and internal state.
*/
export const useTableImperativeHandle = ({
enableHeader,
handleRef,
internalState,
ref,
scrollToTableIndex,
scrollToTableOffset,
}: UseTableImperativeHandleProps) => {
const imperativeHandle: ItemListHandle = useMemo(
() => ({
internalState,
scrollToIndex: (index: number, options?: { align?: 'bottom' | 'center' | 'top' }) => {
scrollToTableIndex(enableHeader ? index + 1 : index, options);
},
scrollToOffset: (offset: number) => {
scrollToTableOffset(offset);
},
}),
[enableHeader, internalState, scrollToTableIndex, scrollToTableOffset],
);
useImperativeHandle(ref, () => imperativeHandle);
useEffect(() => {
handleRef.current = imperativeHandle;
}, [handleRef, imperativeHandle]);
};
@@ -0,0 +1,42 @@
import { useEffect, useRef } from 'react';
interface UseTableInitialScrollProps {
initialTop?: {
behavior?: 'auto' | 'smooth';
to: number;
type: 'index' | 'offset';
};
scrollToTableIndex: (index: number, options?: { align?: 'bottom' | 'center' | 'top' }) => void;
scrollToTableOffset: (offset: number) => void;
startRowIndex?: number;
}
/**
* Hook to handle initial scroll position and scrolling to top when startRowIndex changes.
*/
export const useTableInitialScroll = ({
initialTop,
scrollToTableIndex,
scrollToTableOffset,
startRowIndex,
}: UseTableInitialScrollProps) => {
const isInitialScrollPositionSet = useRef<boolean>(false);
useEffect(() => {
if (!initialTop || isInitialScrollPositionSet.current) return;
isInitialScrollPositionSet.current = true;
if (initialTop.type === 'offset') {
scrollToTableOffset(initialTop.to);
} else {
scrollToTableIndex(initialTop.to);
}
}, [initialTop, scrollToTableIndex, scrollToTableOffset]);
// Scroll to top when startRowIndex changes
useEffect(() => {
if (startRowIndex !== undefined) {
scrollToTableOffset(0);
}
}, [startRowIndex, scrollToTableOffset]);
};
@@ -0,0 +1,198 @@
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;
getStateItem: (item: any) => ItemListStateItemWithRequiredProperties | null;
hasRequiredStateItemProperties: (
item: unknown,
) => item is ItemListStateItemWithRequiredProperties;
internalState: ItemListStateActions;
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,
getStateItem,
hasRequiredStateItemProperties,
internalState,
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;
if (validSelected.length > 0) {
const lastSelected = validSelected[validSelected.length - 1];
currentIndex = data.findIndex(
(d) => extractRowId(d) === extractRowId(lastSelected),
);
}
let newIndex = 0;
if (currentIndex !== -1) {
newIndex =
e.key === 'ArrowDown'
? Math.min(currentIndex + 1, data.length - 1)
: Math.max(currentIndex - 1, 0);
}
const newItem: any = 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, ...data] : data,
enableAlternateRowColors: false,
enableExpansion: false,
enableHeader,
enableHorizontalBorders: false,
enableRowHoverHighlight: false,
enableSelection,
enableVerticalBorders: false,
getRowHeight: () => DEFAULT_ROW_HEIGHT,
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,
DEFAULT_ROW_HEIGHT,
enableHeader,
enableSelection,
extractRowId,
getStateItem,
hasRequiredStateItemProperties,
internalState,
itemType,
parsedColumns,
pinnedRightColumnCount,
pinnedRightColumnRef,
playerContext,
rowHeight,
rowRef,
scrollToTableIndex,
size,
tableId,
],
);
return { handleKeyDown };
};
@@ -0,0 +1,507 @@
import { autoScrollForElements } from '@atlaskit/pragmatic-drag-and-drop-auto-scroll/element';
import throttle from 'lodash/throttle';
import { useOverlayScrollbars } from 'overlayscrollbars-react';
import { useEffect } from 'react';
import { ItemListStateActions } from '/@/renderer/components/item-list/helpers/item-list-state';
export const useTablePaneSync = ({
enableDrag,
enableHeader,
handleRef,
onScrollEndRef,
pinnedLeftColumnCount,
pinnedLeftColumnRef,
pinnedRightColumnCount,
pinnedRightColumnRef,
pinnedRowRef,
rowRef,
scrollContainerRef,
setShowLeftShadow,
setShowRightShadow,
setShowTopShadow,
}: {
enableDrag: boolean | undefined;
enableHeader: boolean;
handleRef: React.RefObject<null | { internalState: ItemListStateActions }>;
onScrollEndRef: React.RefObject<
((offset: number, internalState: ItemListStateActions) => void) | undefined
>;
pinnedLeftColumnCount: number;
pinnedLeftColumnRef: React.RefObject<HTMLDivElement | null>;
pinnedRightColumnCount: number;
pinnedRightColumnRef: React.RefObject<HTMLDivElement | null>;
pinnedRowRef: React.RefObject<HTMLDivElement | null>;
rowRef: React.RefObject<HTMLDivElement | null>;
scrollContainerRef: React.RefObject<HTMLDivElement | null>;
setShowLeftShadow: (v: boolean) => void;
setShowRightShadow: (v: boolean) => void;
setShowTopShadow: (v: boolean) => void;
}) => {
// Main grid overlayscrollbars - only handle X-axis if right-pinned columns exist
const [initialize, osInstance] = useOverlayScrollbars({
defer: false,
events: {
initialized(osInstance) {
const { viewport } = osInstance.elements();
viewport.style.overflowX = `var(--os-viewport-overflow-x)`;
if (pinnedRightColumnCount > 0) {
viewport.style.overflowY = 'auto';
} else {
viewport.style.overflowY = `var(--os-viewport-overflow-y)`;
}
},
},
options: {
overflow: {
x: 'scroll',
y: pinnedRightColumnCount > 0 ? 'hidden' : 'scroll',
},
paddingAbsolute: true,
scrollbars: {
autoHide: 'leave',
autoHideDelay: 500,
pointers: ['mouse', 'pen', 'touch'],
theme: 'feishin-os-scrollbar',
},
},
});
// Right pinned columns overlayscrollbars - enable Y-axis scroll when right-pinned columns exist
const [initializeRightPinned, osInstanceRightPinned] = useOverlayScrollbars({
defer: false,
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: 'hidden', y: 'scroll' },
paddingAbsolute: true,
scrollbars: {
autoHide: 'leave',
autoHideDelay: 500,
pointers: ['mouse', 'pen', 'touch'],
theme: 'feishin-os-scrollbar',
},
},
});
useEffect(() => {
const { current: root } = scrollContainerRef;
if (!root || !root.firstElementChild) {
return;
}
const viewport = root.firstElementChild as HTMLElement;
initialize({
elements: { viewport },
target: root,
});
if (enableDrag) {
autoScrollForElements({
canScroll: () => true,
element: viewport,
getAllowedAxis: () => 'vertical',
getConfiguration: () => ({ maxScrollSpeed: 'fast' }),
});
}
return () => {
try {
const instance = osInstance();
const { current: root } = scrollContainerRef;
if (instance && root) {
const viewport = root.firstElementChild as HTMLElement;
const rootInDocument = document.contains(root);
const viewportInDocument = viewport && document.contains(viewport);
if (rootInDocument && viewportInDocument) {
instance.destroy();
}
}
} catch {
// Ignore error
}
};
}, [enableDrag, initialize, osInstance, pinnedRightColumnCount, scrollContainerRef]);
useEffect(() => {
if (pinnedLeftColumnCount === 0) {
return;
}
const { current: root } = pinnedLeftColumnRef;
if (!root || !root.firstElementChild) {
return;
}
const viewport = root.firstElementChild as HTMLElement;
if (enableDrag) {
autoScrollForElements({
canScroll: () => true,
element: viewport,
getAllowedAxis: () => 'vertical',
getConfiguration: () => ({ maxScrollSpeed: 'fast' }),
});
}
}, [enableDrag, pinnedLeftColumnCount, pinnedLeftColumnRef]);
// Initialize overlayscrollbars for right pinned columns
useEffect(() => {
if (pinnedRightColumnCount === 0) {
return;
}
const { current: root } = pinnedRightColumnRef;
if (!root || !root.firstElementChild) {
return;
}
const viewport = root.firstElementChild as HTMLElement;
initializeRightPinned({
elements: { viewport },
target: root,
});
if (enableDrag) {
autoScrollForElements({
canScroll: () => true,
element: viewport,
getAllowedAxis: () => 'vertical',
getConfiguration: () => ({ maxScrollSpeed: 'fast' }),
});
}
return () => {
try {
const instance = osInstanceRightPinned();
const { current: root } = pinnedRightColumnRef;
if (instance && root) {
const viewport = root.firstElementChild as HTMLElement;
const rootInDocument = document.contains(root);
const viewportInDocument = viewport && document.contains(viewport);
if (rootInDocument && viewportInDocument) {
instance.destroy();
}
}
} catch {
// Ignore error
}
};
}, [
enableDrag,
initializeRightPinned,
osInstanceRightPinned,
pinnedRightColumnCount,
pinnedRightColumnRef,
]);
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) return;
// 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);
}
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<HTMLDivElement>();
const scrollTimeouts = new Map<HTMLDivElement, NodeJS.Timeout>();
const setActiveElement = (e: HTMLElementEventMap['pointermove']) => {
activeElement.element = e.currentTarget as HTMLDivElement;
};
const setActiveElementFromWheel = (e: HTMLElementEventMap['wheel']) => {
activeElement.element = e.currentTarget as HTMLDivElement;
};
const markElementAsScrolling = (element: HTMLDivElement) => {
scrollingElements.add(element);
const existingTimeout = scrollTimeouts.get(element);
if (existingTimeout) {
clearTimeout(existingTimeout);
}
const timeout = setTimeout(() => {
scrollingElements.delete(element);
const hasRightPinnedColumns = pinnedRightColumnCount > 0;
const scrollElement = hasRightPinnedColumns && pinnedRight ? pinnedRight : row;
if (scrollElement && onScrollEndRef.current) {
onScrollEndRef.current(
scrollElement.scrollTop,
(handleRef.current?.internalState ??
(undefined as any)) as ItemListStateActions,
);
}
scrollTimeouts.delete(element);
}, 150);
scrollTimeouts.set(element, timeout);
};
const syncScroll = (e: HTMLElementEventMap['scroll']) => {
const currentElement = e.currentTarget as HTMLDivElement;
markElementAsScrolling(currentElement);
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;
const isScrolling = {
header: false,
pinnedLeft: false,
pinnedRight: false,
row: false,
};
const hasRightPinnedColumns = pinnedRightColumnCount > 0;
if (header && e.currentTarget === header && !isScrolling.row) {
isScrolling.row = true;
row.scrollTo({ behavior: 'instant', left: scrollLeft });
isScrolling.row = false;
}
if (
e.currentTarget === row &&
!isScrolling.header &&
!isScrolling.pinnedLeft &&
!isScrolling.pinnedRight
) {
if (header) {
isScrolling.header = true;
header.scrollTo({ behavior: 'instant', left: scrollLeft });
}
if (hasRightPinnedColumns && pinnedRight) {
isScrolling.pinnedRight = true;
pinnedRight.scrollTo({ behavior: 'instant', top: scrollTop });
isScrolling.pinnedRight = false;
} else {
if (pinnedLeft) {
isScrolling.pinnedLeft = true;
pinnedLeft.scrollTo({ behavior: 'instant', top: scrollTop });
}
if (pinnedRight) {
isScrolling.pinnedRight = true;
pinnedRight.scrollTo({ behavior: 'instant', top: scrollTop });
}
}
isScrolling.header = false;
isScrolling.pinnedLeft = false;
}
if (pinnedLeft && e.currentTarget === pinnedLeft && !isScrolling.row) {
if (hasRightPinnedColumns && pinnedRight) {
isScrolling.pinnedRight = true;
pinnedRight.scrollTo({ behavior: 'instant', top: scrollTop });
isScrolling.pinnedRight = false;
} else {
isScrolling.row = true;
row.scrollTo({ behavior: 'instant', top: scrollTop });
isScrolling.row = false;
}
}
if (pinnedRight && e.currentTarget === pinnedRight && !isScrolling.row) {
isScrolling.row = true;
row.scrollTo({ behavior: 'instant', top: scrollTop });
isScrolling.row = false;
if (pinnedLeft) {
isScrolling.pinnedLeft = true;
pinnedLeft.scrollTo({ behavior: 'instant', top: scrollTop });
isScrolling.pinnedLeft = false;
}
}
};
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);
}
let heightSyncDebounceTimeout: NodeJS.Timeout | null = null;
const resizeObserver = new ResizeObserver(() => {
if (heightSyncDebounceTimeout) {
clearTimeout(heightSyncDebounceTimeout);
}
heightSyncDebounceTimeout = setTimeout(() => {
syncHeights();
}, 100);
});
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);
}
if (heightSyncDebounceTimeout) {
clearTimeout(heightSyncDebounceTimeout);
}
resizeObserver.disconnect();
};
}, [
handleRef,
onScrollEndRef,
pinnedLeftColumnCount,
pinnedLeftColumnRef,
pinnedRightColumnCount,
pinnedRightColumnRef,
pinnedRowRef,
rowRef,
]);
// Handle left and right shadow visibility based on horizontal scroll
useEffect(() => {
const row = rowRef.current?.childNodes[0] as HTMLDivElement;
if (!row) {
const timeout = setTimeout(() => {
setShowLeftShadow(false);
setShowRightShadow(false);
}, 0);
return () => clearTimeout(timeout);
}
const checkScrollPosition = throttle(() => {
const scrollLeft = row.scrollLeft;
const maxScrollLeft = row.scrollWidth - row.clientWidth;
setShowLeftShadow(pinnedLeftColumnCount > 0 && scrollLeft > 0);
setShowRightShadow(pinnedRightColumnCount > 0 && scrollLeft < maxScrollLeft);
}, 50);
checkScrollPosition();
row.addEventListener('scroll', checkScrollPosition, { passive: true });
return () => {
checkScrollPosition.cancel();
row.removeEventListener('scroll', checkScrollPosition);
};
}, [
pinnedLeftColumnCount,
pinnedRightColumnCount,
rowRef,
setShowLeftShadow,
setShowRightShadow,
]);
// Handle top shadow visibility based on vertical scroll
useEffect(() => {
const row = rowRef.current?.childNodes[0] as HTMLDivElement;
const pinnedRight = pinnedRightColumnRef.current?.childNodes[0] as HTMLDivElement;
if (!row || !enableHeader) {
const timeout = setTimeout(() => {
setShowTopShadow(false);
}, 0);
return () => clearTimeout(timeout);
}
const scrollElement = pinnedRightColumnCount > 0 && pinnedRight ? pinnedRight : row;
const checkScrollPosition = throttle(() => {
const currentScrollTop = scrollElement.scrollTop;
setShowTopShadow(currentScrollTop > 0);
}, 50);
checkScrollPosition();
scrollElement.addEventListener('scroll', checkScrollPosition, { passive: true });
return () => {
checkScrollPosition.cancel();
scrollElement.removeEventListener('scroll', checkScrollPosition);
};
}, [enableHeader, pinnedRightColumnCount, pinnedRightColumnRef, rowRef, setShowTopShadow]);
};
@@ -0,0 +1,55 @@
import { useMemo } from 'react';
import { TableGroupHeader } from '/@/renderer/components/item-list/item-table-list/item-table-list';
export const useTableRowModel = ({
data,
enableHeader,
groups,
}: {
data: unknown[];
enableHeader: boolean;
groups?: TableGroupHeader[];
}) => {
const dataWithGroups = useMemo(() => {
const result: (null | unknown)[] = enableHeader ? [null] : [];
if (!groups || groups.length === 0) {
result.push(...data);
return result;
}
// Build the expanded row model: [header?] + (groupHeader + groupItems)* + any remaining items.
let dataIndex = 0;
for (const group of groups) {
// Group header row
result.push(null);
// Group items
const end = Math.min(data.length, dataIndex + group.itemCount);
for (; dataIndex < end; dataIndex++) {
result.push(data[dataIndex]);
}
}
// If groups don't account for all items, append the remainder.
for (; dataIndex < data.length; dataIndex++) {
result.push(data[dataIndex]);
}
return result;
}, [data, enableHeader, groups]);
const groupHeaderRowCount = useMemo(() => {
if (!groups || groups.length === 0) return 0;
return groups.length;
}, [groups]);
return useMemo(
() => ({
dataWithGroups,
groupHeaderRowCount,
}),
[dataWithGroups, groupHeaderRowCount],
);
};
@@ -0,0 +1,181 @@
import { useCallback, useMemo } 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 const useTableScrollToIndex = ({
cellPadding,
columns,
data,
enableAlternateRowColors,
enableExpansion,
enableHeader,
enableHorizontalBorders,
enableRowHoverHighlight,
enableSelection,
enableVerticalBorders,
itemType,
pinnedLeftColumnRef,
pinnedRightColumnRef,
playerContext,
rowHeight,
rowRef,
size,
tableId,
}: {
cellPadding: 'lg' | 'md' | 'sm' | 'xl' | 'xs';
columns: TableItemProps['columns'];
data: unknown[];
enableAlternateRowColors: boolean;
enableExpansion: boolean;
enableHeader: boolean;
enableHorizontalBorders: boolean;
enableRowHoverHighlight: boolean;
enableSelection: boolean;
enableVerticalBorders: boolean;
itemType: LibraryItem;
pinnedLeftColumnRef: React.RefObject<HTMLDivElement | null>;
pinnedRightColumnRef: React.RefObject<HTMLDivElement | null>;
playerContext: PlayerContext;
rowHeight: ((index: number, cellProps: TableItemProps) => number) | number | undefined;
rowRef: React.RefObject<HTMLDivElement | null>;
size: 'compact' | 'default' | 'large';
tableId: string;
}) => {
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 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],
);
const scrollToTableOffset = useCallback(
(offset: number) => {
const mainContainer = rowRef.current?.childNodes[0] as HTMLDivElement | undefined;
const pinnedLeftContainer = pinnedLeftColumnRef.current?.childNodes[0] as
| HTMLDivElement
| undefined;
const pinnedRightContainer = pinnedRightColumnRef.current?.childNodes[0] as
| HTMLDivElement
| undefined;
const behavior = 'instant';
if (mainContainer) {
mainContainer.scrollTo({ behavior, top: offset });
}
if (pinnedLeftContainer) {
pinnedLeftContainer.scrollTo({ behavior, top: offset });
}
if (pinnedRightContainer) {
pinnedRightContainer.scrollTo({ behavior, top: offset });
}
},
[pinnedLeftColumnRef, pinnedRightColumnRef, rowRef],
);
const calculateScrollTopForIndex = useCallback(
(index: number) => {
const adjustedIndex = enableHeader ? Math.max(0, index - 1) : index;
let scrollTop = 0;
for (let i = 0; i < adjustedIndex; i++) {
scrollTop += getRowHeightAtIndex(i);
}
return scrollTop;
},
[enableHeader, getRowHeightAtIndex],
);
const scrollToTableIndex = useCallback(
(index: number, options?: { align?: 'bottom' | 'center' | 'top' }) => {
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);
if (align === 'center') {
offset = offset - viewportHeight / 2 + targetRowHeight / 2;
} else if (align === 'bottom') {
offset = offset - viewportHeight + targetRowHeight;
}
offset = Math.max(0, offset);
scrollToTableOffset(offset);
},
[
calculateScrollTopForIndex,
enableHeader,
getRowHeightAtIndex,
rowRef,
scrollToTableOffset,
],
);
return useMemo(
() => ({
calculateScrollTopForIndex,
DEFAULT_ROW_HEIGHT,
scrollToTableIndex,
scrollToTableOffset,
}),
[calculateScrollTopForIndex, DEFAULT_ROW_HEIGHT, scrollToTableIndex, scrollToTableOffset],
);
};
File diff suppressed because it is too large Load Diff