mirror of
https://github.com/jeffvli/feishin.git
synced 2026-05-07 20:40:15 +02:00
split out item list table functionality
This commit is contained in:
+82
@@ -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]);
|
||||
};
|
||||
+158
@@ -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]);
|
||||
};
|
||||
+51
@@ -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]);
|
||||
};
|
||||
+52
@@ -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,
|
||||
],
|
||||
);
|
||||
};
|
||||
+43
@@ -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]);
|
||||
};
|
||||
+198
@@ -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
Reference in New Issue
Block a user