327 lines
16 KiB
TypeScript
327 lines
16 KiB
TypeScript
|
|
import { type Dispatch, type MouseEvent, type MutableRefObject, type SetStateAction, useEffect, useRef, 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<number>;
|
||
|
|
textNodesRef: MutableRefObject<CanvasTextNode[]>;
|
||
|
|
imageNodesRef: MutableRefObject<CanvasImageNode[]>;
|
||
|
|
videoNodesRef: MutableRefObject<CanvasVideoNode[]>;
|
||
|
|
nodePackagesRef: MutableRefObject<CanvasNodePackage[]>;
|
||
|
|
setTextNodes: Dispatch<SetStateAction<CanvasTextNode[]>>;
|
||
|
|
setImageNodes: Dispatch<SetStateAction<CanvasImageNode[]>>;
|
||
|
|
setVideoNodes: Dispatch<SetStateAction<CanvasVideoNode[]>>;
|
||
|
|
setNodePackages: Dispatch<SetStateAction<CanvasNodePackage[]>>;
|
||
|
|
setCanvasViewport: Dispatch<SetStateAction<CanvasViewport>>;
|
||
|
|
callbacksRef: MutableRefObject<CanvasNodeDragCallbacks>;
|
||
|
|
suppressNextPaneClickRef: MutableRefObject<boolean>;
|
||
|
|
}
|
||
|
|
|
||
|
|
export function useCanvasNodeDrag(params: UseCanvasNodeDragParams) {
|
||
|
|
const {
|
||
|
|
zoomRef,
|
||
|
|
textNodesRef,
|
||
|
|
imageNodesRef,
|
||
|
|
videoNodesRef,
|
||
|
|
nodePackagesRef,
|
||
|
|
setTextNodes,
|
||
|
|
setImageNodes,
|
||
|
|
setVideoNodes,
|
||
|
|
setNodePackages,
|
||
|
|
setCanvasViewport,
|
||
|
|
callbacksRef,
|
||
|
|
suppressNextPaneClickRef,
|
||
|
|
} = params;
|
||
|
|
|
||
|
|
const [textNodeDrag, setTextNodeDrag] = useState<CanvasTextNodeDrag | null>(null);
|
||
|
|
const [imageNodeDrag, setImageNodeDrag] = useState<CanvasImageNodeDrag | null>(null);
|
||
|
|
const [videoNodeDrag, setVideoNodeDrag] = useState<CanvasVideoNodeDrag | null>(null);
|
||
|
|
const [packageDrag, setPackageDrag] = useState<CanvasNodePackageDrag | null>(null);
|
||
|
|
const [selectionDrag, setSelectionDrag] = useState<CanvasSelectionDrag | null>(null);
|
||
|
|
const [nodeResizeDrag, setNodeResizeDrag] = useState<CanvasNodeResizeDrag | null>(null);
|
||
|
|
const [canvasPanDrag, setCanvasPanDrag] = useState<CanvasPanDrag | null>(null);
|
||
|
|
const [alignGuides, setAlignGuides] = useState<CanvasAlignGuide[]>([]);
|
||
|
|
|
||
|
|
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 = [
|
||
|
|
...textNodesRef.current.filter((n) => n.id !== draggedId).map((n) => ({ pos: n.position, size: n.size })),
|
||
|
|
...imageNodesRef.current.filter((n) => n.id !== draggedId).map((n) => ({ pos: n.position, size: n.size })),
|
||
|
|
...videoNodesRef.current.filter((n) => n.id !== draggedId).map((n) => ({ pos: n.position, size: n.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<string>();
|
||
|
|
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<HTMLButtonElement>,
|
||
|
|
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,
|
||
|
|
};
|
||
|
|
}
|