Initial commit: OmniAI Web Frontend
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,326 @@
|
||||
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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user