Files
ttstd-infinite-canvas/web/src/pages/canvas/use-canvas-viewport-controller.ts

174 lines
8.4 KiB
TypeScript

import { useCallback, useEffect, useRef, type Dispatch, type MouseEvent, type SetStateAction } from "react";
import { isNodeHiddenByCollapsedFrame } from "@/lib/canvas/canvas-frame";
import { isHiddenBatchChild } from "@/lib/canvas/canvas-project-domain";
import { applyCanvasLiveViewport } from "@/lib/canvas/canvas-live-viewport";
import { getCanvasNodesBounds, viewportAtScale, viewportForBounds, type CanvasViewportSize } from "@/lib/canvas/canvas-viewport";
import { CanvasNodeType, type CanvasNodeData, type ContextMenuState, type Position, type ViewportTransform } from "@/types/canvas";
import { useCanvasViewportTransition } from "./use-canvas-viewport-transition";
type UseCanvasViewportControllerOptions = {
containerRef: { current: HTMLDivElement | null };
size: CanvasViewportSize;
viewportRef: { current: ViewportTransform };
nodesRef: { current: CanvasNodeData[] };
selectedNodeIdsRef: { current: Set<string> };
setViewport: Dispatch<SetStateAction<ViewportTransform>>;
setSelectedNodeIds: Dispatch<SetStateAction<Set<string>>>;
setSelectedConnectionId: Dispatch<SetStateAction<string | null>>;
setContextMenu: Dispatch<SetStateAction<ContextMenuState | null>>;
setDialogNodeId: Dispatch<SetStateAction<string | null>>;
setToolbarNodeId: Dispatch<SetStateAction<string | null>>;
};
export function useCanvasViewportController({
containerRef,
size,
viewportRef,
nodesRef,
selectedNodeIdsRef,
setViewport,
setSelectedNodeIds,
setSelectedConnectionId,
setContextMenu,
setDialogNodeId,
setToolbarNodeId,
}: UseCanvasViewportControllerOptions) {
const commitTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const previewViewport = useCallback((next: ViewportTransform) => {
viewportRef.current = next;
if (containerRef.current) containerRef.current.dataset.canvasViewportInteracting = "true";
applyCanvasLiveViewport(containerRef.current, next);
}, [containerRef, viewportRef]);
const commitViewport = useCallback((next: ViewportTransform) => {
if (commitTimerRef.current) {
clearTimeout(commitTimerRef.current);
commitTimerRef.current = null;
}
viewportRef.current = next;
delete containerRef.current?.dataset.canvasViewportInteracting;
setViewport((current) => current.x === next.x && current.y === next.y && current.k === next.k ? current : next);
}, [containerRef, setViewport, viewportRef]);
useEffect(() => () => {
if (commitTimerRef.current) clearTimeout(commitTimerRef.current);
delete containerRef.current?.dataset.canvasViewportInteracting;
}, [containerRef]);
const { cancelViewportTransition, transitionViewportTo } = useCanvasViewportTransition(viewportRef, previewViewport, commitViewport);
const screenToCanvas = useCallback((clientX: number, clientY: number): Position => {
const rect = containerRef.current?.getBoundingClientRect();
const viewport = viewportRef.current;
const localX = clientX - (rect?.left || 0);
const localY = clientY - (rect?.top || 0);
return { x: (localX - viewport.x) / viewport.k, y: (localY - viewport.y) / viewport.k };
}, [containerRef, viewportRef]);
const getCanvasCenter = useCallback(() => {
const rect = containerRef.current?.getBoundingClientRect();
return screenToCanvas((rect?.left || 0) + (rect?.width || size.width) / 2, (rect?.top || 0) + (rect?.height || size.height) / 2);
}, [containerRef, screenToCanvas, size.height, size.width]);
const focusNodesInView = useCallback((targetNodes: CanvasNodeData[], maxScale = 1) => {
const bounds = getCanvasNodesBounds(targetNodes);
if (!bounds) return false;
transitionViewportTo(viewportForBounds(bounds, size, { padding: 88, maxScale }));
setContextMenu(null);
return true;
}, [setContextMenu, size, transitionViewportTo]);
const fitCanvasContent = useCallback(() => {
const nodes = nodesRef.current;
return focusNodesInView(nodes.filter((node) => !isHiddenBatchChild(node, nodes) && !isNodeHiddenByCollapsedFrame(node, nodes)));
}, [focusNodesInView, nodesRef]);
const fitCanvasSelection = useCallback(() => {
const nodes = nodesRef.current;
return focusNodesInView(nodes.filter((node) => selectedNodeIdsRef.current.has(node.id) && !isHiddenBatchChild(node, nodes) && !isNodeHiddenByCollapsedFrame(node, nodes)), 1.25);
}, [focusNodesInView, nodesRef, selectedNodeIdsRef]);
const handleCanvasDoubleClick = useCallback((event: MouseEvent<HTMLDivElement>) => {
event.preventDefault();
event.stopPropagation();
setSelectedNodeIds(new Set());
setSelectedConnectionId(null);
setDialogNodeId(null);
setToolbarNodeId(null);
setContextMenu({ type: "canvas", x: event.clientX, y: event.clientY, position: screenToCanvas(event.clientX, event.clientY), createOpen: true });
}, [screenToCanvas, setContextMenu, setDialogNodeId, setSelectedConnectionId, setSelectedNodeIds, setToolbarNodeId]);
const selectFocusedNode = useCallback((nodeId: string) => {
const selection = new Set([nodeId]);
selectedNodeIdsRef.current = selection;
setSelectedNodeIds(selection);
setSelectedConnectionId(null);
setContextMenu(null);
}, [selectedNodeIdsRef, setContextMenu, setSelectedConnectionId, setSelectedNodeIds]);
const focusCanvasImageNode = useCallback((nodeId: string) => {
const node = nodesRef.current.find((item) => item.id === nodeId && item.type === CanvasNodeType.Image);
if (!node) return;
const scale = Math.min(1.25, Math.max(viewportRef.current.k, 0.78));
transitionViewportTo({ x: size.width / 2 - (node.position.x + node.width / 2) * scale, y: size.height / 2 - (node.position.y + node.height / 2) * scale, k: scale });
selectFocusedNode(node.id);
setDialogNodeId(null);
setToolbarNodeId(node.id);
}, [nodesRef, selectFocusedNode, setDialogNodeId, setToolbarNodeId, size.height, size.width, transitionViewportTo, viewportRef]);
const focusCanvasNode = useCallback((nodeId: string) => {
const node = nodesRef.current.find((item) => item.id === nodeId);
if (!node) return;
const scale = Math.min(1.18, Math.max(viewportRef.current.k, 0.72));
transitionViewportTo({ x: size.width / 2 - (node.position.x + node.width / 2) * scale, y: size.height / 2 - (node.position.y + node.height / 2) * scale, k: scale });
selectFocusedNode(node.id);
setDialogNodeId(node.type === CanvasNodeType.Drawing ? null : node.id);
}, [nodesRef, selectFocusedNode, setDialogNodeId, size.height, size.width, transitionViewportTo, viewportRef]);
const setZoomScale = useCallback((scale: number) => {
cancelViewportTransition();
const next = viewportAtScale(viewportRef.current, size, scale);
previewViewport(next);
if (commitTimerRef.current) clearTimeout(commitTimerRef.current);
commitTimerRef.current = setTimeout(() => commitViewport(viewportRef.current), 120);
setContextMenu(null);
}, [cancelViewportTransition, commitViewport, previewViewport, setContextMenu, size, viewportRef]);
const zoomCanvasIn = useCallback(() => setZoomScale(viewportRef.current.k + 0.1), [setZoomScale, viewportRef]);
const zoomCanvasOut = useCallback(() => setZoomScale(viewportRef.current.k - 0.1), [setZoomScale, viewportRef]);
const zoomToActualSize = useCallback(() => {
transitionViewportTo(viewportAtScale(viewportRef.current, size, 1));
}, [size, transitionViewportTo, viewportRef]);
const handleViewportChange = useCallback((next: ViewportTransform) => {
cancelViewportTransition();
commitViewport(next);
setContextMenu(null);
}, [cancelViewportTransition, commitViewport, setContextMenu]);
const handleViewportPreviewChange = useCallback((next: ViewportTransform) => {
cancelViewportTransition();
viewportRef.current = next;
}, [cancelViewportTransition, viewportRef]);
return {
fitCanvasContent,
fitCanvasSelection,
focusCanvasImageNode,
focusCanvasNode,
getCanvasCenter,
handleCanvasDoubleClick,
handleViewportChange,
handleViewportPreviewChange,
previewViewport,
screenToCanvas,
setZoomScale,
zoomCanvasIn,
zoomCanvasOut,
zoomToActualSize,
};
}