refactor(frontend): optimize high-frequency interactions
This commit is contained in:
parent
63b7da46d1
commit
b0e2f1803b
@ -584,7 +584,9 @@ function MessageDialogPageContent() {
|
||||
if (!c?.containerRef?.current) return;
|
||||
const viewport = c.containerRef.current;
|
||||
|
||||
const onScroll = () => {
|
||||
let frameId: number | null = null;
|
||||
const processScrollPosition = () => {
|
||||
frameId = null;
|
||||
if (!cm.loadingOlder && !cm.messagesLoading && (cm.hasMoreOlder || cm.allowOneOlderProbe) && viewport.scrollTop <= 120) {
|
||||
cm.loadOlderMessages();
|
||||
}
|
||||
@ -603,8 +605,17 @@ function MessageDialogPageContent() {
|
||||
}
|
||||
};
|
||||
|
||||
const onScroll = () => {
|
||||
if (frameId === null) {
|
||||
frameId = window.requestAnimationFrame(processScrollPosition);
|
||||
}
|
||||
};
|
||||
|
||||
viewport.addEventListener("scroll", onScroll, { passive: true });
|
||||
return () => viewport.removeEventListener("scroll", onScroll);
|
||||
return () => {
|
||||
viewport.removeEventListener("scroll", onScroll);
|
||||
if (frameId !== null) window.cancelAnimationFrame(frameId);
|
||||
};
|
||||
}, [cm, markRead, scroll]);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@ -68,7 +68,6 @@ import {
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type PointerEvent as ReactPointerEvent,
|
||||
type ReactNode,
|
||||
type RefObject,
|
||||
} from "react";
|
||||
@ -84,6 +83,7 @@ import { CommentComposer, CommentDeleteButton } from "@/components/shared/Commen
|
||||
import { Modal } from "@/components/ui";
|
||||
import { useNotifications } from "@/contexts/NotificationsContext";
|
||||
import { useUser } from "@/contexts/UserContext";
|
||||
import { useTaskBoardScroll } from "@/hooks/useTaskBoardScroll";
|
||||
import { useTaskBoardRealtime } from "@/hooks/useTaskBoardRealtime";
|
||||
import { apiClient } from "@/lib/api";
|
||||
import { displayUserName, formatDate, formatDateTime, formatMoney } from "@/lib/shared";
|
||||
@ -1340,7 +1340,7 @@ function BoardTaskCell({
|
||||
<section
|
||||
ref={setNodeRef}
|
||||
className={`flex w-full min-w-0 flex-col transition ${
|
||||
rowId ? "min-h-[10rem]" : "min-h-[28rem]"
|
||||
rowId ? "min-h-0" : "min-h-[28rem]"
|
||||
} xl:w-full ${fillAvailableHeight ? "xl:min-h-full" : ""}`}
|
||||
>
|
||||
<div
|
||||
@ -3396,14 +3396,6 @@ function TasksPageContent() {
|
||||
const selectedBoardIdRef = useRef<number | null>(null);
|
||||
const boardViewStateRef = useRef<TaskBoardViewState>(EMPTY_BOARD_VIEW_STATE);
|
||||
const pendingBoardViewStateRef = useRef<Map<string, boolean>>(new Map());
|
||||
const boardScrollRef = useRef<HTMLDivElement | null>(null);
|
||||
const boardPanRef = useRef<{
|
||||
pointerId: number;
|
||||
startX: number;
|
||||
startY: number;
|
||||
scrollLeft: number;
|
||||
scrollTop: number;
|
||||
} | null>(null);
|
||||
const columnNodeRefs = useRef<Map<number, HTMLElement>>(new Map());
|
||||
const taskAttachmentInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const taskFormAttachmentInputRef = useRef<HTMLInputElement | null>(null);
|
||||
@ -3412,8 +3404,13 @@ function TasksPageContent() {
|
||||
const taskFormResourcesRequestRef = useRef(0);
|
||||
const readingBoardNotificationsRef = useRef<Set<number>>(new Set());
|
||||
const initialLoadCompleteRef = useRef(false);
|
||||
const [boardPanning, setBoardPanning] = useState(false);
|
||||
const [boardViewportHeight, setBoardViewportHeight] = useState(0);
|
||||
const {
|
||||
boardScrollRef,
|
||||
finishBoardPan,
|
||||
moveBoardPan,
|
||||
startBoardPan,
|
||||
viewportHeight: boardViewportHeight,
|
||||
} = useTaskBoardScroll({ board, desktopWideMode, loading });
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, {
|
||||
@ -3421,32 +3418,6 @@ function TasksPageContent() {
|
||||
}),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const container = boardScrollRef.current;
|
||||
if (!container || loading) return undefined;
|
||||
|
||||
const updateBoardViewport = () => {
|
||||
setBoardViewportHeight(container.clientHeight);
|
||||
container.querySelectorAll<HTMLElement>(".tasks-board-column-group").forEach((group) => {
|
||||
const content = group.querySelector<HTMLElement>(".tasks-board-column-content");
|
||||
const stickyHeader = group.querySelector<HTMLElement>(".tasks-board-group-header-sticky");
|
||||
const contentBottomReached = content && stickyHeader
|
||||
? content.getBoundingClientRect().bottom <= stickyHeader.getBoundingClientRect().bottom + 1
|
||||
: false;
|
||||
group.dataset.contentBottomReached = contentBottomReached ? "true" : "false";
|
||||
});
|
||||
};
|
||||
updateBoardViewport();
|
||||
|
||||
container.addEventListener("scroll", updateBoardViewport, { passive: true });
|
||||
const observer = new ResizeObserver(updateBoardViewport);
|
||||
observer.observe(container);
|
||||
return () => {
|
||||
container.removeEventListener("scroll", updateBoardViewport);
|
||||
observer.disconnect();
|
||||
};
|
||||
}, [board?.id, desktopWideMode, loading]);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
typeof window !== "undefined" &&
|
||||
@ -3828,7 +3799,7 @@ function TasksPageContent() {
|
||||
|
||||
const scrollBoardToStart = useCallback(() => {
|
||||
boardScrollRef.current?.scrollTo({ left: 0, behavior: "smooth" });
|
||||
}, []);
|
||||
}, [boardScrollRef]);
|
||||
|
||||
const scrollToColumn = useCallback((columnId: number) => {
|
||||
const container = boardScrollRef.current;
|
||||
@ -3843,7 +3814,7 @@ function TasksPageContent() {
|
||||
left: Math.max(0, targetLeft),
|
||||
behavior: "smooth",
|
||||
});
|
||||
}, []);
|
||||
}, [boardScrollRef]);
|
||||
|
||||
const focusAllColumns = useCallback(() => {
|
||||
setSelectedColumnTarget("all");
|
||||
@ -3855,47 +3826,6 @@ function TasksPageContent() {
|
||||
window.requestAnimationFrame(() => scrollToColumn(columnId));
|
||||
}, [scrollToColumn]);
|
||||
|
||||
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,
|
||||
scrollLeft: container.scrollLeft,
|
||||
scrollTop: container.scrollTop,
|
||||
};
|
||||
container.setPointerCapture(event.pointerId);
|
||||
setBoardPanning(true);
|
||||
}, []);
|
||||
|
||||
const moveBoardPan = useCallback((event: ReactPointerEvent<HTMLDivElement>) => {
|
||||
const pan = boardPanRef.current;
|
||||
const container = boardScrollRef.current;
|
||||
if (!pan || !container || pan.pointerId !== event.pointerId) return;
|
||||
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
container.scrollLeft = pan.scrollLeft - (event.clientX - pan.startX);
|
||||
container.scrollTop = pan.scrollTop - (event.clientY - pan.startY);
|
||||
}, []);
|
||||
|
||||
const finishBoardPan = useCallback((event: ReactPointerEvent<HTMLDivElement>) => {
|
||||
const pan = boardPanRef.current;
|
||||
const container = boardScrollRef.current;
|
||||
if (!pan || pan.pointerId !== event.pointerId) return;
|
||||
|
||||
if (container?.hasPointerCapture(event.pointerId)) {
|
||||
container.releasePointerCapture(event.pointerId);
|
||||
}
|
||||
boardPanRef.current = null;
|
||||
setBoardPanning(false);
|
||||
}, []);
|
||||
|
||||
const syncTaskBoardState = useCallback(async (eventBoardId?: number | null) => {
|
||||
const currentBoardId = selectedBoardIdRef.current;
|
||||
try {
|
||||
@ -6934,12 +6864,13 @@ function TasksPageContent() {
|
||||
onPointerMoveCapture={moveBoardPan}
|
||||
onPointerUpCapture={finishBoardPan}
|
||||
onPointerCancelCapture={finishBoardPan}
|
||||
onLostPointerCapture={finishBoardPan}
|
||||
onAuxClick={(event) => {
|
||||
if (event.button === 1) event.preventDefault();
|
||||
}}
|
||||
className={`tasks-board-scroll min-w-0 overflow-hidden pb-3 xl:overflow-auto ${
|
||||
boardPanning ? "cursor-grabbing select-none" : ""
|
||||
} ${desktopWideMode ? "lg:min-h-0 lg:flex-1" : "xl:min-h-0 xl:flex-1"}`}
|
||||
desktopWideMode ? "lg:min-h-0 lg:flex-1" : "xl:min-h-0 xl:flex-1"
|
||||
}`}
|
||||
>
|
||||
<div className="flex w-full min-w-0 gap-3 xl:min-h-full xl:w-auto xl:min-w-max xl:items-stretch">
|
||||
{topLevelColumns.map((column) => {
|
||||
|
||||
@ -54,6 +54,8 @@ export default function SwipeApprovalMode({ requests, onApprove, onReject, onClo
|
||||
const [dragY, setDragY] = useState(0);
|
||||
const [dragging, setDragging] = useState(false);
|
||||
const startPos = useRef({ x: 0, y: 0 });
|
||||
const pendingDrag = useRef({ x: 0, y: 0 });
|
||||
const dragFrame = useRef<number | null>(null);
|
||||
const cardRef = useRef<HTMLDivElement>(null);
|
||||
const [flyOut, setFlyOut] = useState<"left" | "right" | "up" | null>(null);
|
||||
|
||||
@ -64,6 +66,18 @@ export default function SwipeApprovalMode({ requests, onApprove, onReject, onClo
|
||||
|
||||
const current = queue[0] || null;
|
||||
|
||||
const applyPendingDrag = useCallback(() => {
|
||||
dragFrame.current = null;
|
||||
setDragX(pendingDrag.current.x);
|
||||
setDragY(pendingDrag.current.y);
|
||||
}, []);
|
||||
|
||||
useEffect(() => () => {
|
||||
if (dragFrame.current !== null) {
|
||||
window.cancelAnimationFrame(dragFrame.current);
|
||||
}
|
||||
}, []);
|
||||
|
||||
/* ── perform action ── */
|
||||
|
||||
const performAction = useCallback(
|
||||
@ -89,6 +103,7 @@ export default function SwipeApprovalMode({ requests, onApprove, onReject, onClo
|
||||
// error is handled by parent (actionError state)
|
||||
} finally {
|
||||
setBusy(false);
|
||||
pendingDrag.current = { x: 0, y: 0 };
|
||||
setDragX(0);
|
||||
setDragY(0);
|
||||
setFlyOut(null);
|
||||
@ -118,6 +133,7 @@ export default function SwipeApprovalMode({ requests, onApprove, onReject, onClo
|
||||
(e: React.PointerEvent) => {
|
||||
if (busy || detailOpen) return;
|
||||
startPos.current = { x: e.clientX, y: e.clientY };
|
||||
pendingDrag.current = { x: 0, y: 0 };
|
||||
setDragging(true);
|
||||
(e.target as HTMLElement).setPointerCapture?.(e.pointerId);
|
||||
},
|
||||
@ -127,30 +143,45 @@ export default function SwipeApprovalMode({ requests, onApprove, onReject, onClo
|
||||
const onPointerMove = useCallback(
|
||||
(e: React.PointerEvent) => {
|
||||
if (!dragging) return;
|
||||
setDragX(e.clientX - startPos.current.x);
|
||||
setDragY(e.clientY - startPos.current.y);
|
||||
pendingDrag.current = {
|
||||
x: e.clientX - startPos.current.x,
|
||||
y: e.clientY - startPos.current.y,
|
||||
};
|
||||
if (dragFrame.current === null) {
|
||||
dragFrame.current = window.requestAnimationFrame(applyPendingDrag);
|
||||
}
|
||||
},
|
||||
[dragging]
|
||||
[applyPendingDrag, dragging]
|
||||
);
|
||||
|
||||
const onPointerUp = useCallback(() => {
|
||||
const onPointerUp = useCallback((e: React.PointerEvent) => {
|
||||
if (!dragging) return;
|
||||
const finalDrag = {
|
||||
x: e.clientX - startPos.current.x,
|
||||
y: e.clientY - startPos.current.y,
|
||||
};
|
||||
pendingDrag.current = finalDrag;
|
||||
if (dragFrame.current !== null) {
|
||||
window.cancelAnimationFrame(dragFrame.current);
|
||||
dragFrame.current = null;
|
||||
}
|
||||
setDragging(false);
|
||||
|
||||
if (dragX > SWIPE_THRESHOLD) {
|
||||
if (finalDrag.x > SWIPE_THRESHOLD) {
|
||||
setFlyOut("right");
|
||||
setTimeout(() => performAction("approved"), 250);
|
||||
} else if (dragX < -SWIPE_THRESHOLD) {
|
||||
} else if (finalDrag.x < -SWIPE_THRESHOLD) {
|
||||
setFlyOut("left");
|
||||
setTimeout(() => performAction("rejected"), 250);
|
||||
} else if (dragY < -SWIPE_THRESHOLD) {
|
||||
} else if (finalDrag.y < -SWIPE_THRESHOLD) {
|
||||
setFlyOut("up");
|
||||
setTimeout(() => performAction("skipped"), 250);
|
||||
} else {
|
||||
pendingDrag.current = { x: 0, y: 0 };
|
||||
setDragX(0);
|
||||
setDragY(0);
|
||||
}
|
||||
}, [dragging, dragX, dragY, performAction]);
|
||||
}, [dragging, performAction]);
|
||||
|
||||
/* ── keyboard ── */
|
||||
useEffect(() => {
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { ChevronDown } from "lucide-react";
|
||||
import type { ReactNode } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import type { ReactNode, RefObject } from "react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
|
||||
type DropdownPosition = {
|
||||
@ -31,6 +31,56 @@ function getDropdownPosition(anchor: HTMLElement): DropdownPosition {
|
||||
};
|
||||
}
|
||||
|
||||
function dropdownPositionsEqual(
|
||||
current: DropdownPosition | null,
|
||||
next: DropdownPosition,
|
||||
) {
|
||||
return current?.left === next.left
|
||||
&& current.maxHeight === next.maxHeight
|
||||
&& current.top === next.top
|
||||
&& current.width === next.width;
|
||||
}
|
||||
|
||||
function useDropdownPosition(
|
||||
open: boolean,
|
||||
anchorRef: RefObject<HTMLDivElement | null>,
|
||||
) {
|
||||
const [position, setPosition] = useState<DropdownPosition | null>(null);
|
||||
|
||||
const updatePosition = useCallback(() => {
|
||||
const anchor = anchorRef.current;
|
||||
if (!anchor) return;
|
||||
const nextPosition = getDropdownPosition(anchor);
|
||||
setPosition((currentPosition) => (
|
||||
dropdownPositionsEqual(currentPosition, nextPosition) ? currentPosition : nextPosition
|
||||
));
|
||||
}, [anchorRef]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return undefined;
|
||||
|
||||
let frameId: number | null = null;
|
||||
const schedulePositionUpdate = () => {
|
||||
if (frameId !== null) return;
|
||||
frameId = window.requestAnimationFrame(() => {
|
||||
frameId = null;
|
||||
updatePosition();
|
||||
});
|
||||
};
|
||||
|
||||
updatePosition();
|
||||
window.addEventListener("resize", schedulePositionUpdate);
|
||||
window.addEventListener("scroll", schedulePositionUpdate, true);
|
||||
return () => {
|
||||
window.removeEventListener("resize", schedulePositionUpdate);
|
||||
window.removeEventListener("scroll", schedulePositionUpdate, true);
|
||||
if (frameId !== null) window.cancelAnimationFrame(frameId);
|
||||
};
|
||||
}, [open, updatePosition]);
|
||||
|
||||
return { position, updatePosition };
|
||||
}
|
||||
|
||||
/* ── Single-select dropdown with search ── */
|
||||
export function SearchableSelectSingle<T extends string | number>({
|
||||
label,
|
||||
@ -49,9 +99,12 @@ export function SearchableSelectSingle<T extends string | number>({
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [q, setQ] = useState("");
|
||||
const [dropdownPosition, setDropdownPosition] = useState<DropdownPosition | null>(null);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
const {
|
||||
position: dropdownPosition,
|
||||
updatePosition: updateDropdownPosition,
|
||||
} = useDropdownPosition(open, ref);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e: MouseEvent) => {
|
||||
@ -63,29 +116,10 @@ export function SearchableSelectSingle<T extends string | number>({
|
||||
return () => document.removeEventListener("mousedown", handler);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
|
||||
const updatePosition = () => {
|
||||
if (ref.current) {
|
||||
setDropdownPosition(getDropdownPosition(ref.current));
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("resize", updatePosition);
|
||||
window.addEventListener("scroll", updatePosition, true);
|
||||
return () => {
|
||||
window.removeEventListener("resize", updatePosition);
|
||||
window.removeEventListener("scroll", updatePosition, true);
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
const filtered = items.filter((i) => i.name.toLowerCase().includes(q.toLowerCase()));
|
||||
const selectedName = items.find((i) => i.id === selectedId)?.name;
|
||||
const toggleOpen = () => {
|
||||
if (!open && ref.current) {
|
||||
setDropdownPosition(getDropdownPosition(ref.current));
|
||||
}
|
||||
if (!open) updateDropdownPosition();
|
||||
setOpen((value) => !value);
|
||||
};
|
||||
|
||||
@ -180,9 +214,12 @@ export function SearchableSelectMulti<T extends { id: number; name: string }>({
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [q, setQ] = useState("");
|
||||
const [dropdownPosition, setDropdownPosition] = useState<DropdownPosition | null>(null);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
const {
|
||||
position: dropdownPosition,
|
||||
updatePosition: updateDropdownPosition,
|
||||
} = useDropdownPosition(open, ref);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e: MouseEvent) => {
|
||||
@ -194,31 +231,12 @@ export function SearchableSelectMulti<T extends { id: number; name: string }>({
|
||||
return () => document.removeEventListener("mousedown", handler);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
|
||||
const updatePosition = () => {
|
||||
if (ref.current) {
|
||||
setDropdownPosition(getDropdownPosition(ref.current));
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("resize", updatePosition);
|
||||
window.addEventListener("scroll", updatePosition, true);
|
||||
return () => {
|
||||
window.removeEventListener("resize", updatePosition);
|
||||
window.removeEventListener("scroll", updatePosition, true);
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
const filtered = items.filter((i) => i.name.toLowerCase().includes(q.toLowerCase()));
|
||||
const selectedItems = items.filter((i) => selectedIds.includes(i.id));
|
||||
const selectedNames = selectedItems.map((i) => i.name);
|
||||
const isInline = layout === "inline";
|
||||
const toggleOpen = () => {
|
||||
if (!open && ref.current) {
|
||||
setDropdownPosition(getDropdownPosition(ref.current));
|
||||
}
|
||||
if (!open) updateDropdownPosition();
|
||||
setOpen((value) => !value);
|
||||
};
|
||||
|
||||
|
||||
@ -76,13 +76,6 @@ export function useChatScroll({
|
||||
|
||||
const floatingDateTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
// Sync selected date from floating date when navigator is closed
|
||||
useEffect(() => {
|
||||
if (floatingDateValue && !isDateNavigatorOpen) {
|
||||
setSelectedHistoryDate(floatingDateValue);
|
||||
}
|
||||
}, [floatingDateValue, isDateNavigatorOpen]);
|
||||
|
||||
// Cleanup
|
||||
useEffect(() => () => {
|
||||
if (floatingDateTimeoutRef.current) clearTimeout(floatingDateTimeoutRef.current);
|
||||
@ -144,6 +137,9 @@ export function useChatScroll({
|
||||
setFloatingDate(topDate);
|
||||
setFloatingDateValue(topDateVal);
|
||||
setShowFloatingDate(true);
|
||||
if (topDateVal && !isDateNavigatorOpen) {
|
||||
setSelectedHistoryDate(topDateVal);
|
||||
}
|
||||
|
||||
if (floatingDateTimeoutRef.current) clearTimeout(floatingDateTimeoutRef.current);
|
||||
floatingDateTimeoutRef.current = setTimeout(() => setShowFloatingDate(false), 1100);
|
||||
@ -153,7 +149,7 @@ export function useChatScroll({
|
||||
setFloatingDate(null);
|
||||
setFloatingDateValue(null);
|
||||
setShowFloatingDate(false);
|
||||
}, []);
|
||||
}, [isDateNavigatorOpen]);
|
||||
|
||||
// Expose updateFloatingDate as a stable ref so the scroll handler can call it
|
||||
const updateFloatingDateRef = useRef(updateFloatingDate);
|
||||
@ -166,13 +162,22 @@ export function useChatScroll({
|
||||
if (!c?.containerRef?.current) return;
|
||||
const viewport = c.containerRef.current;
|
||||
|
||||
const onScroll = () => updateFloatingDateRef.current(viewport);
|
||||
const frameId = window.requestAnimationFrame(onScroll);
|
||||
let frameId: number | null = null;
|
||||
const updateFromScrollPosition = () => {
|
||||
frameId = null;
|
||||
updateFloatingDateRef.current(viewport);
|
||||
};
|
||||
const scheduleUpdate = () => {
|
||||
if (frameId === null) {
|
||||
frameId = window.requestAnimationFrame(updateFromScrollPosition);
|
||||
}
|
||||
};
|
||||
|
||||
viewport.addEventListener("scroll", onScroll, { passive: true });
|
||||
scheduleUpdate();
|
||||
viewport.addEventListener("scroll", scheduleUpdate, { passive: true });
|
||||
return () => {
|
||||
window.cancelAnimationFrame(frameId);
|
||||
viewport.removeEventListener("scroll", onScroll);
|
||||
if (frameId !== null) window.cancelAnimationFrame(frameId);
|
||||
viewport.removeEventListener("scroll", scheduleUpdate);
|
||||
};
|
||||
}, [chatId, messageCount, viewportRef]);
|
||||
|
||||
|
||||
184
frontend/src/hooks/useTaskBoardScroll.ts
Normal file
184
frontend/src/hooks/useTaskBoardScroll.ts
Normal file
@ -0,0 +1,184 @@
|
||||
"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,
|
||||
};
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user