Files
feishin/src/renderer/components/item-list/item-table-list/hooks/use-table-pane-sync.ts
T

535 lines
19 KiB
TypeScript

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,
enableDragScroll,
enableHeader,
handleRef,
onScrollEndRef,
pinnedLeftColumnCount,
pinnedLeftColumnRef,
pinnedRightColumnCount,
pinnedRightColumnRef,
pinnedRowRef,
rowRef,
scrollContainerRef,
setShowLeftShadow,
setShowRightShadow,
setShowTopShadow,
}: {
enableDrag: boolean | undefined;
enableDragScroll: 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,
});
let autoScrollCleanup: (() => void) | null = null;
if (enableDrag && enableDragScroll) {
autoScrollCleanup = autoScrollForElements({
canScroll: () => true,
element: viewport,
getAllowedAxis: () => 'vertical',
getConfiguration: () => ({ maxScrollSpeed: 'fast' }),
});
}
return () => {
if (autoScrollCleanup) {
autoScrollCleanup();
}
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,
enableDragScroll,
initialize,
osInstance,
pinnedRightColumnCount,
scrollContainerRef,
]);
useEffect(() => {
if (pinnedLeftColumnCount === 0) {
return;
}
const { current: root } = pinnedLeftColumnRef;
if (!root || !root.firstElementChild) {
return;
}
const viewport = root.firstElementChild as HTMLElement;
let autoScrollCleanup: (() => void) | null = null;
if (enableDrag && enableDragScroll) {
autoScrollCleanup = autoScrollForElements({
canScroll: () => true,
element: viewport,
getAllowedAxis: () => 'vertical',
getConfiguration: () => ({ maxScrollSpeed: 'fast' }),
});
}
return () => {
if (autoScrollCleanup) {
autoScrollCleanup();
}
};
}, [enableDrag, enableDragScroll, 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,
});
let autoScrollCleanup: (() => void) | null = null;
if (enableDrag && enableDragScroll) {
autoScrollCleanup = autoScrollForElements({
canScroll: () => true,
element: viewport,
getAllowedAxis: () => 'vertical',
getConfiguration: () => ({ maxScrollSpeed: 'fast' }),
});
}
return () => {
if (autoScrollCleanup) {
autoScrollCleanup();
}
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,
enableDragScroll,
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]);
};