185 lines
5.4 KiB
TypeScript
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,
|
|
};
|
|
}
|