"use client"; import type { ReactNode } from "react"; import { NodeResizeControl, NodeToolbar, Position, useNodeId, useReactFlow } from "@xyflow/react"; import { Trash2, Copy } from "lucide-react"; import { useCanvasPlacement } from "@/components/canvas/canvas-placement-context"; import { NodeErrorBoundary } from "./node-error-boundary"; interface ResizeConfig { minWidth: number; minHeight: number; keepAspectRatio?: boolean; } const RESIZE_CONFIGS: Record = { frame: { minWidth: 200, minHeight: 150 }, group: { minWidth: 150, minHeight: 100 }, image: { minWidth: 140, minHeight: 120, keepAspectRatio: true }, asset: { minWidth: 140, minHeight: 208, keepAspectRatio: false }, video: { minWidth: 200, minHeight: 120, keepAspectRatio: true }, // Chrome 88 + min. Viewport 120 → äußere Mindesthöhe 208 (siehe canvas onNodesChange) "ai-image": { minWidth: 200, minHeight: 208, keepAspectRatio: false }, compare: { minWidth: 300, minHeight: 200 }, prompt: { minWidth: 260, minHeight: 220 }, text: { minWidth: 220, minHeight: 90 }, note: { minWidth: 200, minHeight: 90 }, }; const DEFAULT_CONFIG: ResizeConfig = { minWidth: 80, minHeight: 50 }; const CORNERS = [ "top-left", "top-right", "bottom-left", "bottom-right", ] as const; /** Internal fields to strip when duplicating a node */ const INTERNAL_FIELDS = new Set([ "_status", "_statusMessage", "retryCount", "url", "canvasId", ]); function NodeToolbarActions() { const nodeId = useNodeId(); const { deleteElements, getNode, getNodes, setNodes } = useReactFlow(); const { createNodeWithIntersection } = useCanvasPlacement(); const handleDelete = () => { if (!nodeId) return; void deleteElements({ nodes: [{ id: nodeId }] }); }; const handleDuplicate = () => { if (!nodeId) return; const node = getNode(nodeId); if (!node) return; // Strip internal/runtime fields, keep only user content const originalData = (node.data ?? {}) as Record; const cleanedData: Record = {}; for (const [key, value] of Object.entries(originalData)) { if (!INTERNAL_FIELDS.has(key)) { cleanedData[key] = value; } } const originalPosition = node.position ?? { x: 0, y: 0 }; const width = typeof node.style?.width === "number" ? node.style.width : undefined; const height = typeof node.style?.height === "number" ? node.style.height : undefined; // Find the highest zIndex across all nodes to ensure the duplicate renders on top const allNodes = getNodes(); const maxZIndex = allNodes.reduce( (max, n) => Math.max(max, n.zIndex ?? 0), 0, ); // Deselect source node immediately for instant visual feedback setNodes((nodes) => nodes.map((n) => n.id === nodeId ? { ...n, selected: false } : n, ), ); // Fire-and-forget: optimistic update makes the duplicate appear instantly void createNodeWithIntersection({ type: node.type ?? "text", position: { x: originalPosition.x + 50, y: originalPosition.y + 50, }, width, height, data: cleanedData, zIndex: maxZIndex + 1, clientRequestId: crypto.randomUUID(), }); }; const stopPropagation = (e: React.MouseEvent | React.PointerEvent) => { e.stopPropagation(); }; return (
); } interface BaseNodeWrapperProps { nodeType: string; selected?: boolean; status?: string; statusMessage?: string; children: ReactNode; className?: string; } export default function BaseNodeWrapper({ nodeType, selected, status = "idle", statusMessage, children, className = "", }: BaseNodeWrapperProps) { const config = RESIZE_CONFIGS[nodeType] ?? DEFAULT_CONFIG; const statusStyles: Record = { idle: "", analyzing: "border-yellow-400 animate-pulse", clarifying: "border-amber-400", executing: "border-yellow-400 animate-pulse", done: "border-green-500", error: "border-red-500", }; return (
{selected && CORNERS.map((corner) => ( ))} {children} {status === "error" && statusMessage && (
{statusMessage}
)}
); }