refactor(frontend): optimize high-frequency interactions

This commit is contained in:
lizerk 2026-08-06 20:22:36 +03:00
parent 63b7da46d1
commit b0e2f1803b
6 changed files with 330 additions and 150 deletions

View File

@ -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(() => {

View File

@ -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) => {

View File

@ -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(() => {

View File

@ -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);
};

View File

@ -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]);

View 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,
};
}