import { type Dispatch, type MouseEvent, type MutableRefObject, type SetStateAction, useEffect, useState } from "react"; import { canvasNodeClickMoveThreshold } from "./canvasConstants"; import type { CanvasAlignGuide, CanvasImageNode, CanvasImageNodeDrag, CanvasNodeKind, CanvasNodePackage, CanvasNodePackageDrag, CanvasNodeResizeDrag, CanvasNodeSize, CanvasPanDrag, CanvasPoint, CanvasSelectedNode, CanvasSelectionDrag, CanvasTextNode, CanvasTextNodeDrag, CanvasVideoNode, CanvasVideoNodeDrag, CanvasViewport, } from "./canvasTypes"; import { clampCanvasNodeSize, moveCanvasNodesForPackageDrag, normalizeCanvasSelectionRect } from "./canvasUtils"; export interface CanvasNodeDragCallbacks { pushHistorySnapshot: () => void; clearCanvasSelection: () => void; selectCanvasNode: (kind: CanvasNodeKind, id: string, addToSelection?: boolean) => void; applyCanvasSelection: (nodes: CanvasSelectedNode[]) => void; getCanvasPointFromClient: (clientX: number, clientY: number) => CanvasPoint; getNodesInSelectionRect: (rect: { left: number; top: number; width: number; height: number }) => CanvasSelectedNode[]; expandCanvasNodePackage: (pkg: CanvasNodePackage) => void; onBeforeResize?: () => void; } export interface UseCanvasNodeDragParams { zoomRef: MutableRefObject; textNodesRef: MutableRefObject; imageNodesRef: MutableRefObject; videoNodesRef: MutableRefObject; nodePackagesRef: MutableRefObject; setTextNodes: Dispatch>; setImageNodes: Dispatch>; setVideoNodes: Dispatch>; setNodePackages: Dispatch>; setCanvasViewport: Dispatch>; callbacksRef: MutableRefObject; suppressNextPaneClickRef: MutableRefObject; } export function useCanvasNodeDrag(params: UseCanvasNodeDragParams) { const { zoomRef, textNodesRef, imageNodesRef, videoNodesRef, nodePackagesRef, setTextNodes, setImageNodes, setVideoNodes, setNodePackages, setCanvasViewport, callbacksRef, suppressNextPaneClickRef, } = params; const [textNodeDrag, setTextNodeDrag] = useState(null); const [imageNodeDrag, setImageNodeDrag] = useState(null); const [videoNodeDrag, setVideoNodeDrag] = useState(null); const [packageDrag, setPackageDrag] = useState(null); const [selectionDrag, setSelectionDrag] = useState(null); const [nodeResizeDrag, setNodeResizeDrag] = useState(null); const [canvasPanDrag, setCanvasPanDrag] = useState(null); const [alignGuides, setAlignGuides] = useState([]); const didNodeDragStayClick = (event: globalThis.MouseEvent, drag: { startX: number; startY: number }) => Math.hypot(event.clientX - drag.startX, event.clientY - drag.startY) <= canvasNodeClickMoveThreshold; const computeAlignGuides = (draggedId: string, pos: { x: number; y: number }, size: { width: number; height: number }): CanvasAlignGuide[] => { const threshold = 6; const guides: CanvasAlignGuide[] = []; const cx = pos.x + size.width / 2; const cy = pos.y + size.height / 2; const right = pos.x + size.width; const bottom = pos.y + size.height; const others: Array<{ pos: CanvasPoint; size: CanvasNodeSize }> = []; for (const node of textNodesRef.current) { if (node.id !== draggedId) others.push({ pos: node.position, size: node.size }); } for (const node of imageNodesRef.current) { if (node.id !== draggedId) others.push({ pos: node.position, size: node.size }); } for (const node of videoNodesRef.current) { if (node.id !== draggedId) others.push({ pos: node.position, size: node.size }); } for (const other of others) { const ocx = other.pos.x + other.size.width / 2; const ocy = other.pos.y + other.size.height / 2; const oRight = other.pos.x + other.size.width; const oBottom = other.pos.y + other.size.height; if (Math.abs(cx - ocx) < threshold) guides.push({ axis: "x", position: ocx }); if (Math.abs(pos.x - other.pos.x) < threshold) guides.push({ axis: "x", position: other.pos.x }); if (Math.abs(right - oRight) < threshold) guides.push({ axis: "x", position: oRight }); if (Math.abs(cy - ocy) < threshold) guides.push({ axis: "y", position: ocy }); if (Math.abs(pos.y - other.pos.y) < threshold) guides.push({ axis: "y", position: other.pos.y }); if (Math.abs(bottom - oBottom) < threshold) guides.push({ axis: "y", position: oBottom }); } const seen = new Set(); return guides.filter((g) => { const key = `${g.axis}:${Math.round(g.position)}`; if (seen.has(key)) return false; seen.add(key); return true; }); }; useEffect(() => { if (!textNodeDrag) return; const draggedNode = textNodesRef.current.find((n) => n.id === textNodeDrag.nodeId); const draggedSize = draggedNode?.size || { width: 320, height: 200 }; const handleMove = (event: globalThis.MouseEvent) => { const hasMoved = !didNodeDragStayClick(event, textNodeDrag); if (hasMoved && !textNodeDrag.hasMoved) { callbacksRef.current.pushHistorySnapshot(); callbacksRef.current.clearCanvasSelection(); setTextNodeDrag((d) => d?.nodeId === textNodeDrag.nodeId ? { ...d, hasMoved: true } : d); } if (!hasMoved && !textNodeDrag.hasMoved) return; const newPos = { x: textNodeDrag.originX + (event.clientX - textNodeDrag.startX) / zoomRef.current, y: textNodeDrag.originY + (event.clientY - textNodeDrag.startY) / zoomRef.current, }; setTextNodes((nodes) => nodes.map((n) => n.id === textNodeDrag.nodeId ? { ...n, position: newPos } : n)); setAlignGuides(computeAlignGuides(textNodeDrag.nodeId, newPos, draggedSize)); }; const handleUp = (event: globalThis.MouseEvent) => { if (didNodeDragStayClick(event, textNodeDrag)) callbacksRef.current.selectCanvasNode("text", textNodeDrag.nodeId, event.shiftKey); setTextNodeDrag(null); setAlignGuides([]); }; window.addEventListener("mousemove", handleMove); window.addEventListener("mouseup", handleUp); return () => { window.removeEventListener("mousemove", handleMove); window.removeEventListener("mouseup", handleUp); }; }, [textNodeDrag]); useEffect(() => { if (!imageNodeDrag) return; const draggedNode = imageNodesRef.current.find((n) => n.id === imageNodeDrag.nodeId); const draggedSize = draggedNode?.size || { width: 320, height: 320 }; const handleMove = (event: globalThis.MouseEvent) => { const hasMoved = !didNodeDragStayClick(event, imageNodeDrag); if (hasMoved && !imageNodeDrag.hasMoved) { callbacksRef.current.pushHistorySnapshot(); callbacksRef.current.clearCanvasSelection(); setImageNodeDrag((d) => d?.nodeId === imageNodeDrag.nodeId ? { ...d, hasMoved: true } : d); } if (!hasMoved && !imageNodeDrag.hasMoved) return; const newPos = { x: imageNodeDrag.originX + (event.clientX - imageNodeDrag.startX) / zoomRef.current, y: imageNodeDrag.originY + (event.clientY - imageNodeDrag.startY) / zoomRef.current, }; setImageNodes((nodes) => nodes.map((n) => n.id === imageNodeDrag.nodeId ? { ...n, position: newPos } : n)); setAlignGuides(computeAlignGuides(imageNodeDrag.nodeId, newPos, draggedSize)); }; const handleUp = (event: globalThis.MouseEvent) => { if (didNodeDragStayClick(event, imageNodeDrag)) callbacksRef.current.selectCanvasNode("image", imageNodeDrag.nodeId, event.shiftKey); setImageNodeDrag(null); setAlignGuides([]); }; window.addEventListener("mousemove", handleMove); window.addEventListener("mouseup", handleUp); return () => { window.removeEventListener("mousemove", handleMove); window.removeEventListener("mouseup", handleUp); }; }, [imageNodeDrag]); useEffect(() => { if (!videoNodeDrag) return; const draggedNode = videoNodesRef.current.find((n) => n.id === videoNodeDrag.nodeId); const draggedSize = draggedNode?.size || { width: 320, height: 240 }; const handleMove = (event: globalThis.MouseEvent) => { const hasMoved = !didNodeDragStayClick(event, videoNodeDrag); if (hasMoved && !videoNodeDrag.hasMoved) { callbacksRef.current.pushHistorySnapshot(); callbacksRef.current.clearCanvasSelection(); setVideoNodeDrag((d) => d?.nodeId === videoNodeDrag.nodeId ? { ...d, hasMoved: true } : d); } if (!hasMoved && !videoNodeDrag.hasMoved) return; const newPos = { x: videoNodeDrag.originX + (event.clientX - videoNodeDrag.startX) / zoomRef.current, y: videoNodeDrag.originY + (event.clientY - videoNodeDrag.startY) / zoomRef.current, }; setVideoNodes((nodes) => nodes.map((n) => n.id === videoNodeDrag.nodeId ? { ...n, position: newPos } : n)); setAlignGuides(computeAlignGuides(videoNodeDrag.nodeId, newPos, draggedSize)); }; const handleUp = (event: globalThis.MouseEvent) => { if (didNodeDragStayClick(event, videoNodeDrag)) callbacksRef.current.selectCanvasNode("video", videoNodeDrag.nodeId, event.shiftKey); setVideoNodeDrag(null); setAlignGuides([]); }; window.addEventListener("mousemove", handleMove); window.addEventListener("mouseup", handleUp); return () => { window.removeEventListener("mousemove", handleMove); window.removeEventListener("mouseup", handleUp); }; }, [videoNodeDrag]); useEffect(() => { if (!packageDrag) return; const handleMove = (event: globalThis.MouseEvent) => { const deltaX = (event.clientX - packageDrag.startX) / zoomRef.current; const deltaY = (event.clientY - packageDrag.startY) / zoomRef.current; const hasMoved = Math.abs(event.clientX - packageDrag.startX) > canvasNodeClickMoveThreshold || Math.abs(event.clientY - packageDrag.startY) > canvasNodeClickMoveThreshold; if (hasMoved && !packageDrag.hasMoved) { callbacksRef.current.pushHistorySnapshot(); setPackageDrag((c) => c?.packageId === packageDrag.packageId ? { ...c, hasMoved: true } : c); } if (!hasMoved && !packageDrag.hasMoved) return; const delta = { x: deltaX, y: deltaY }; if (packageDrag.collapsed && packageDrag.collapsedBounds) { setNodePackages((current) => current.map((pkg) => pkg.id === packageDrag.packageId ? { ...pkg, collapsedBounds: { ...packageDrag.collapsedBounds!, left: packageDrag.collapsedBounds!.left + deltaX, top: packageDrag.collapsedBounds!.top + deltaY } } : pkg )); } else { setTextNodes((nodes) => moveCanvasNodesForPackageDrag(nodes, packageDrag.textOrigins, delta)); setImageNodes((nodes) => moveCanvasNodesForPackageDrag(nodes, packageDrag.imageOrigins, delta)); setVideoNodes((nodes) => moveCanvasNodesForPackageDrag(nodes, packageDrag.videoOrigins, delta)); } }; const handleUp = () => { if (!packageDrag.hasMoved && packageDrag.collapsed) { const pkg = nodePackagesRef.current.find((p) => p.id === packageDrag.packageId); if (pkg) callbacksRef.current.expandCanvasNodePackage(pkg); } setPackageDrag(null); }; window.addEventListener("mousemove", handleMove); window.addEventListener("mouseup", handleUp); return () => { window.removeEventListener("mousemove", handleMove); window.removeEventListener("mouseup", handleUp); }; }, [packageDrag]); useEffect(() => { if (!nodeResizeDrag) return; callbacksRef.current.pushHistorySnapshot(); const handleMove = (event: globalThis.MouseEvent) => { const nextSize = clampCanvasNodeSize( nodeResizeDrag.kind, nodeResizeDrag.originWidth + (event.clientX - nodeResizeDrag.startX) / zoomRef.current, nodeResizeDrag.originHeight + (event.clientY - nodeResizeDrag.startY) / zoomRef.current ); if (nodeResizeDrag.kind === "text") { setTextNodes((nodes) => nodes.map((n) => n.id === nodeResizeDrag.nodeId ? { ...n, size: nextSize } : n)); } else if (nodeResizeDrag.kind === "image") { setImageNodes((nodes) => nodes.map((n) => n.id === nodeResizeDrag.nodeId ? { ...n, size: nextSize } : n)); } else { setVideoNodes((nodes) => nodes.map((n) => n.id === nodeResizeDrag.nodeId ? { ...n, size: nextSize } : n)); } }; const handleUp = () => setNodeResizeDrag(null); window.addEventListener("mousemove", handleMove); window.addEventListener("mouseup", handleUp); return () => { window.removeEventListener("mousemove", handleMove); window.removeEventListener("mouseup", handleUp); }; }, [nodeResizeDrag]); useEffect(() => { if (!selectionDrag) return; const handleMove = (event: globalThis.MouseEvent) => { const current = callbacksRef.current.getCanvasPointFromClient(event.clientX, event.clientY); setSelectionDrag((drag) => drag ? { ...drag, current, hasMoved: drag.hasMoved || Math.hypot(current.x - drag.start.x, current.y - drag.start.y) > canvasNodeClickMoveThreshold } : drag); }; const handleUp = (event: globalThis.MouseEvent) => { const current = callbacksRef.current.getCanvasPointFromClient(event.clientX, event.clientY); const hasMoved = selectionDrag.hasMoved || Math.hypot(current.x - selectionDrag.start.x, current.y - selectionDrag.start.y) > canvasNodeClickMoveThreshold; if (hasMoved) { const selectionRect = normalizeCanvasSelectionRect(selectionDrag.start, current); callbacksRef.current.applyCanvasSelection(callbacksRef.current.getNodesInSelectionRect(selectionRect)); suppressNextPaneClickRef.current = true; } else { callbacksRef.current.clearCanvasSelection(); } setSelectionDrag(null); }; window.addEventListener("mousemove", handleMove); window.addEventListener("mouseup", handleUp); return () => { window.removeEventListener("mousemove", handleMove); window.removeEventListener("mouseup", handleUp); }; }, [selectionDrag]); useEffect(() => { if (!canvasPanDrag) return; const handleMove = (event: globalThis.MouseEvent) => { setCanvasViewport((vp) => ({ ...vp, x: canvasPanDrag.originX + event.clientX - canvasPanDrag.startX, y: canvasPanDrag.originY + event.clientY - canvasPanDrag.startY })); }; const handleUp = () => setCanvasPanDrag(null); window.addEventListener("mousemove", handleMove); window.addEventListener("mouseup", handleUp); return () => { window.removeEventListener("mousemove", handleMove); window.removeEventListener("mouseup", handleUp); }; }, [canvasPanDrag]); const handleNodeResizeStart = ( event: MouseEvent, kind: CanvasNodeKind, nodeId: string, size: CanvasNodeSize ) => { if (event.button !== 0) return; event.preventDefault(); event.stopPropagation(); callbacksRef.current.selectCanvasNode(kind, nodeId); setSelectionDrag(null); setTextNodeDrag(null); setImageNodeDrag(null); setVideoNodeDrag(null); callbacksRef.current.onBeforeResize?.(); setNodeResizeDrag({ kind, nodeId, startX: event.clientX, startY: event.clientY, originWidth: size.width, originHeight: size.height, }); }; return { textNodeDrag, setTextNodeDrag, imageNodeDrag, setImageNodeDrag, videoNodeDrag, setVideoNodeDrag, packageDrag, setPackageDrag, selectionDrag, setSelectionDrag, nodeResizeDrag, setNodeResizeDrag, canvasPanDrag, setCanvasPanDrag, alignGuides, setAlignGuides, handleNodeResizeStart, }; }