Corp/frontend/src/hooks/useTaskBoardScroll.ts

185 lines
5.4 KiB
TypeScript

"use client";
import {
useCallback,
useEffect,
useRef,
useState,
type PointerEvent as ReactPointerEvent,
} from "react";
import type { TaskBoard } from "@/types/api";
type BoardPanState = {
pointerId: number;
startX: number;
startY: number;
currentX: number;
currentY: number;
scrollLeft: number;
scrollTop: number;
frameId: number | null;
};
type UseTaskBoardScrollOptions = {
board: TaskBoard | null;
desktopWideMode: boolean;
loading: boolean;
};
export function useTaskBoardScroll({
board,
desktopWideMode,
loading,
}: UseTaskBoardScrollOptions) {
const boardScrollRef = useRef<HTMLDivElement | null>(null);
const boardPanRef = useRef<BoardPanState | null>(null);
const [viewportHeight, setViewportHeight] = useState(0);
const applyBoardPan = useCallback(() => {
const pan = boardPanRef.current;
const container = boardScrollRef.current;
if (!pan || !container) return;
pan.frameId = null;
container.scrollLeft = pan.scrollLeft - (pan.currentX - pan.startX);
container.scrollTop = pan.scrollTop - (pan.currentY - pan.startY);
}, []);
const startBoardPan = useCallback((event: ReactPointerEvent<HTMLDivElement>) => {
if (event.button !== 1) return;
const container = boardScrollRef.current;
if (!container) return;
event.preventDefault();
event.stopPropagation();
boardPanRef.current = {
pointerId: event.pointerId,
startX: event.clientX,
startY: event.clientY,
currentX: event.clientX,
currentY: event.clientY,
scrollLeft: container.scrollLeft,
scrollTop: container.scrollTop,
frameId: null,
};
container.setPointerCapture(event.pointerId);
container.classList.add("cursor-grabbing", "select-none");
}, []);
const moveBoardPan = useCallback((event: ReactPointerEvent<HTMLDivElement>) => {
const pan = boardPanRef.current;
if (!pan || pan.pointerId !== event.pointerId) return;
event.preventDefault();
event.stopPropagation();
pan.currentX = event.clientX;
pan.currentY = event.clientY;
if (pan.frameId === null) {
pan.frameId = window.requestAnimationFrame(applyBoardPan);
}
}, [applyBoardPan]);
const finishBoardPan = useCallback((event: ReactPointerEvent<HTMLDivElement>) => {
const pan = boardPanRef.current;
const container = boardScrollRef.current;
if (!pan || pan.pointerId !== event.pointerId) return;
event.preventDefault();
event.stopPropagation();
pan.currentX = event.clientX;
pan.currentY = event.clientY;
if (pan.frameId !== null) {
window.cancelAnimationFrame(pan.frameId);
pan.frameId = null;
}
applyBoardPan();
boardPanRef.current = null;
container?.classList.remove("cursor-grabbing", "select-none");
if (container?.hasPointerCapture(event.pointerId)) {
container.releasePointerCapture(event.pointerId);
}
}, [applyBoardPan]);
useEffect(() => {
const container = boardScrollRef.current;
if (!container || loading) return undefined;
let scrollFrameId: number | null = null;
const updateViewportHeight = () => {
const nextHeight = container.clientHeight;
setViewportHeight((currentHeight) => (
currentHeight === nextHeight ? currentHeight : nextHeight
));
};
const updateContentBottomState = () => {
scrollFrameId = null;
const updates = Array.from(
container.querySelectorAll<HTMLElement>(".tasks-board-column-group"),
(group) => {
const content = group.querySelector<HTMLElement>(".tasks-board-column-content");
const stickyHeader = group.querySelector<HTMLElement>(".tasks-board-group-header-sticky");
const reached = content && stickyHeader
? content.getBoundingClientRect().bottom <= stickyHeader.getBoundingClientRect().bottom + 1
: false;
return { group, value: reached ? "true" : "false" };
},
);
updates.forEach(({ group, value }) => {
if (group.dataset.contentBottomReached !== value) {
group.dataset.contentBottomReached = value;
}
});
};
const scheduleContentBottomUpdate = () => {
if (scrollFrameId === null) {
scrollFrameId = window.requestAnimationFrame(updateContentBottomState);
}
};
const handleResize = () => {
updateViewportHeight();
scheduleContentBottomUpdate();
};
updateViewportHeight();
updateContentBottomState();
container.addEventListener("scroll", scheduleContentBottomUpdate, { passive: true });
const observer = new ResizeObserver(handleResize);
observer.observe(container);
container.querySelectorAll<HTMLElement>(".tasks-board-column-content").forEach((content) => {
observer.observe(content);
});
return () => {
container.removeEventListener("scroll", scheduleContentBottomUpdate);
observer.disconnect();
if (scrollFrameId !== null) {
window.cancelAnimationFrame(scrollFrameId);
}
};
}, [board, desktopWideMode, loading]);
useEffect(() => () => {
const pan = boardPanRef.current;
if (pan?.frameId !== null && pan?.frameId !== undefined) {
window.cancelAnimationFrame(pan.frameId);
}
boardScrollRef.current?.classList.remove("cursor-grabbing", "select-none");
boardPanRef.current = null;
}, []);
return {
boardScrollRef,
finishBoardPan,
moveBoardPan,
startBoardPan,
viewportHeight,
};
}