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 }; setViewport: Dispatch>; setSelectedNodeIds: Dispatch>>; setSelectedConnectionId: Dispatch>; setContextMenu: Dispatch>; setDialogNodeId: Dispatch>; setToolbarNodeId: Dispatch>; }; export function useCanvasViewportController({ containerRef, size, viewportRef, nodesRef, selectedNodeIdsRef, setViewport, setSelectedNodeIds, setSelectedConnectionId, setContextMenu, setDialogNodeId, setToolbarNodeId, }: UseCanvasViewportControllerOptions) { const commitTimerRef = useRef | 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) => { 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, }; }