Files
omniai-web/src/features/canvas/useCanvasNodeDrag.ts
T

327 lines
16 KiB
TypeScript
Raw Normal View History

2026-06-02 12:38:01 +08:00
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,
};
}