mirror of
https://github.com/jeffvli/feishin.git
synced 2026-05-10 04:30:25 +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