"use client"; import React, { useRef, useState, useCallback, useMemo } from "react"; import dynamic from "next/dynamic"; import { Rnd } from "react-rnd"; import { motion } from "framer-motion"; import { useCanvasStore, canvasSelectors } from "@/store/canvas"; import type { AgentSlotNodeData, CanvasEdge, CanvasNode, Position } from "@/types/canvas"; import { AgentSlotNode } from "./nodes/AgentSlotNode"; import { TextNode } from "./nodes/TextNode"; import { UiScreenNode } from "./nodes/UiScreenNode"; import { VideoNode } from "./nodes/VideoNode"; import { WorkflowNode } from "./nodes/WorkflowNode"; import { CanvasToolbarIcon } from "./CanvasToolbarIcon"; import { cn } from "@/lib/utils"; import { Button } from "@/components/ui/button"; import { toast } from "sonner"; import { imageGenerationService } from "@/services/canvas"; import { AlertTriangle, Check, Download, Flag, GitBranch, Heart, HelpCircle, ImagePlus, LayoutGrid, Lightbulb, Minus, Pin, Plus, RefreshCw, Star, Tag, type LucideIcon, UserRound, Video, Wand2, Zap, } from "lucide-react"; import { getVideoCanvasMarkerColor, getVideoCanvasSymbolMarker, getVideoCanvasTaskMarker, normalizeVideoCanvasMarkers, VIDEO_CANVAS_MARKER_METADATA_KEY, VIDEO_CANVAS_PRIORITY_MARKERS, } from "@/lib/canvas/video-canvas-markers"; import { getCanvasToolbarItem, normalizeCanvasToolbarConfig, type CanvasToolbarConfig, } from "@/lib/canvas-toolbar-config"; import { isCanvasImageReadyForAi, isCanvasImageUploadPending, LOCAL_UPLOAD_PENDING_METADATA_KEY, LOCAL_UPLOAD_PREVIEW_METADATA_KEY, } from "@/lib/canvas/local-upload-state"; import { dispatchCanvasAgentOpenChatPanelRequest, dispatchCanvasAgentSlotFillRequest, storeCanvasAgentSlotFillRequest, type CanvasAgentPendingRequestWindow, } from "@/lib/canvas/agent-pending-requests"; import { inferOriginalImageUrl } from "@/lib/image-url"; type ImageNodeModule = typeof import("./nodes/ImageNode"); type Model3DNodeModule = typeof import("./nodes/Model3DNode"); type World3DNodeModule = typeof import("./nodes/World3DNode"); let imageNodeModulePromise: Promise | null = null; let model3DNodeModulePromise: Promise | null = null; let world3DNodeModulePromise: Promise | null = null; function loadImageNodeModule() { imageNodeModulePromise ??= import("./nodes/ImageNode"); return imageNodeModulePromise; } function loadModel3DNodeModule() { model3DNodeModulePromise ??= import("./nodes/Model3DNode"); return model3DNodeModulePromise; } function loadWorld3DNodeModule() { world3DNodeModulePromise ??= import("./nodes/World3DNode"); return world3DNodeModulePromise; } function preloadCanvas3DNodeRenderers() { void loadModel3DNodeModule(); void loadWorld3DNodeModule(); } function preloadCanvasImageNodeRenderer() { void loadImageNodeModule(); } function CanvasImageNodeLoading() { return (
正在加载图片节点
); } function Canvas3DNodeLoading() { return (
正在加载 3D 预览
); } const ImageNode = dynamic( () => loadImageNodeModule().then((module) => module.ImageNode), { loading: () => , ssr: false, } ); const Model3DNode = dynamic( () => loadModel3DNodeModule().then((module) => module.Model3DNode), { loading: () => , ssr: false, } ); const World3DNode = dynamic( () => loadWorld3DNodeModule().then((module) => module.World3DNode), { loading: () => , ssr: false, } ); const CANVAS_NO_DRAG_SELECTOR = '[data-canvas-no-drag="true"], [data-canvas-no-drag="true"] *'; const CANVAS_DRAG_CANCEL_SELECTOR = [ "input", "textarea", "select", "button", "a", '[contenteditable="true"]', '[role="textbox"]', CANVAS_NO_DRAG_SELECTOR, ].join(", "); const VIDEO_CANVAS_SYMBOL_ICON_MAP: Record = { idea: Lightbulb, question: HelpCircle, warning: AlertTriangle, pin: Pin, heart: Heart, bolt: Zap, }; function isImageFile(file: File) { return file.type.startsWith("image/"); } function extractImageFilesFromDataTransfer(dataTransfer?: DataTransfer | null) { if (!dataTransfer) return []; const seen = new Set(); const files: File[] = []; const pushFile = (file: File | null) => { if (!file || !isImageFile(file)) return; const key = `${file.name}:${file.type}:${file.size}:${file.lastModified}`; if (seen.has(key)) return; seen.add(key); files.push(file); }; Array.from(dataTransfer.files || []).forEach(pushFile); Array.from(dataTransfer.items || []).forEach((item) => { if (item.kind === "file") { pushFile(item.getAsFile()); } }); return files; } function isEditablePasteTarget(target: EventTarget | null) { if (!(target instanceof HTMLElement)) return false; if (target.isContentEditable) return true; return Boolean(target.closest(CANVAS_DRAG_CANCEL_SELECTOR)); } function getNodeVideoCanvasMarkers(node: CanvasNode) { const metadata = (node.data as { metadata?: unknown } | undefined)?.metadata; return normalizeVideoCanvasMarkers( metadata && typeof metadata === "object" && !Array.isArray(metadata) ? (metadata as Record)[VIDEO_CANVAS_MARKER_METADATA_KEY] : undefined ); } function VideoCanvasNodeMarkers({ node }: { node: CanvasNode }) { const markers = getNodeVideoCanvasMarkers(node); const tagColor = getVideoCanvasMarkerColor(markers.tagColor); const flagColor = getVideoCanvasMarkerColor(markers.flagColor); const starColor = getVideoCanvasMarkerColor(markers.starColor); const avatarColor = getVideoCanvasMarkerColor(markers.avatarColor); const task = getVideoCanvasTaskMarker(markers.task); const priority = VIDEO_CANVAS_PRIORITY_MARKERS.find( (entry) => entry.value === markers.priority ); const symbol = getVideoCanvasSymbolMarker(markers.symbol); const SymbolIcon = markers.symbol ? VIDEO_CANVAS_SYMBOL_ICON_MAP[markers.symbol] : null; const hasMarkers = Boolean( tagColor || flagColor || starColor || avatarColor || task || priority || symbol ); if (!hasMarkers) { return null; } return (
{tagColor ? ( ) : null} {priority ? ( {priority.value} ) : null} {task ? ( {task.progress >= 100 ? : null} ) : null} {flagColor ? ( ) : null} {starColor ? ( ) : null} {avatarColor ? ( ) : null} {symbol && SymbolIcon ? ( ) : null}
); } // 性能优化:判断节点是否在视口内 function isNodeInViewport( node: CanvasNode, viewport: { x: number; y: number; zoom: number }, containerSize: { width: number; height: number } ): boolean { const nodeScreenX = node.position.x * viewport.zoom + viewport.x; const nodeScreenY = node.position.y * viewport.zoom + viewport.y; const nodeScreenWidth = node.size.width * viewport.zoom; const nodeScreenHeight = node.size.height * viewport.zoom; // 拖动画布时会先走 GPU 位移预览,保留更大的热区避免快速平移时露白。 const buffer = 800; return ( nodeScreenX + nodeScreenWidth > -buffer && nodeScreenX < containerSize.width + buffer && nodeScreenY + nodeScreenHeight > -buffer && nodeScreenY < containerSize.height + buffer ); } interface CanvasWorkspaceProps { occlusionRight?: number; workspaceLabel?: string; emptyStateDescription?: string; workspaceVariant?: "canvas" | "video"; canvasToolbarConfig?: CanvasToolbarConfig; layerSplitEnabled?: boolean; angleEditEnabled?: boolean; tripo3dEnabled?: boolean; worldlabs3dEnabled?: boolean; batchProcessingEnabled?: boolean; showEmptyState?: boolean; showZoomControls?: boolean; onBlankCanvasDoubleClick?: (payload: { screenPosition: Position; canvasPosition: Position; }) => void; } export function CanvasWorkspace({ occlusionRight = 0, workspaceLabel = "画布", emptyStateDescription = '点击顶部"添加"按钮开始创作', workspaceVariant = "canvas", canvasToolbarConfig: rawCanvasToolbarConfig, layerSplitEnabled = false, angleEditEnabled = false, tripo3dEnabled = false, worldlabs3dEnabled = false, batchProcessingEnabled = true, showEmptyState = true, showZoomControls = true, onBlankCanvasDoubleClick, }: CanvasWorkspaceProps) { const canvasToolbarConfig = useMemo( () => normalizeCanvasToolbarConfig(rawCanvasToolbarConfig), [rawCanvasToolbarConfig] ); const SNAP_THRESHOLD = 10; const SNAP_GAP = 12; const SELECTION_COLOR = "#27a8ff"; const SELECTION_STROKE_WIDTH = 2; const IMAGE_HANDLE_SIZE = 9.6; const IMAGE_HANDLE_OFFSET = IMAGE_HANDLE_SIZE / 2; const SNAP_GUIDE_COLOR = "#ff8a3d"; const WORKFLOW_EDGE_COLOR = "#8fa3ff"; const WORKFLOW_EDGE_ACTIVE_COLOR = "#5b76fe"; const WORKFLOW_HANDLE_SIZE = 16; const isWorkflowCanvas = workspaceVariant === "video"; const overlayPanelClassName = isWorkflowCanvas ? "rounded-full border border-[#dbe2ee] bg-white px-3.5 py-1.5 text-xs font-medium text-[#627089] shadow-[0_0_0_1px_rgba(219,226,238,0.32),0_12px_28px_rgba(22,24,29,0.06)] dark:border-zinc-800 dark:bg-zinc-950" : "bg-background/80 backdrop-blur-sm rounded-md px-2 py-1 text-xs text-muted-foreground"; const zoomOverlayClassName = isWorkflowCanvas ? "absolute bottom-4 right-4 rounded-full border border-[#dbe2ee] bg-white shadow-[0_0_0_1px_rgba(219,226,238,0.32),0_12px_28px_rgba(22,24,29,0.06)] dark:border-zinc-800 dark:bg-zinc-950" : "absolute bottom-4 right-4 bg-background/80 backdrop-blur-sm rounded-lg flex items-center gap-1 px-1 py-1 shadow-sm border border-border/50"; type SnapGuideLines = { vertical?: { x: number; top: number; bottom: number }; horizontal?: { y: number; left: number; right: number }; }; type AgentSlotContextMenuState = { nodeId: string; label: string; x: number; y: number; } | null; const containerRef = useRef(null); const contentLayerRef = useRef(null); const [isPanning, setIsPanning] = useState(false); const [panStart, setPanStart] = useState({ x: 0, y: 0 }); const [isMarqueeSelecting, setIsMarqueeSelecting] = useState(false); const [marqueeStart, setMarqueeStart] = useState(null); const [marqueeEnd, setMarqueeEnd] = useState(null); const [isFileDragActive, setIsFileDragActive] = useState(false); const [isDownloadSubmitting, setIsDownloadSubmitting] = useState(false); const [snapGuides, setSnapGuides] = useState(null); const [selectedEdgeId, setSelectedEdgeId] = useState(null); const [agentSlotContextMenu, setAgentSlotContextMenu] = useState(null); const [connectionDraft, setConnectionDraft] = useState<{ sourceId: string; current: Position; } | null>(null); const [containerSize, setContainerSize] = useState({ width: 1920, height: 1080 }); const dragCounterRef = useRef(0); const dragPointerRef = useRef({ x: 0, y: 0 }); const suppressCanvasClickRef = useRef(false); const lastBlankCanvasClickAtRef = useRef(0); const groupDragRef = useRef<{ nodeId: string; startX: number; startY: number; originPositions: Record; } | null>(null); // Store state const nodes = useCanvasStore(canvasSelectors.nodes); const edges = useCanvasStore(canvasSelectors.edges); const selectedNodeIds = useCanvasStore(canvasSelectors.selectedNodeIds); const viewport = useCanvasStore(canvasSelectors.viewport); const tool = useCanvasStore(canvasSelectors.tool); const lastAddedNodeId = useCanvasStore((state) => state.lastAddedNodeId); const setViewport = useCanvasStore((state) => state.setViewport); const hasImageNodes = useMemo( () => nodes.some((node) => node.type === "image"), [nodes] ); const has3DNodes = useMemo( () => nodes.some((node) => node.type === "model3d" || node.type === "world3d"), [nodes] ); const viewportRef = useRef(viewport); const pendingViewportRef = useRef(null); const pendingViewportRafRef = useRef(null); const panPreviewRef = useRef<{ startClientX: number; startClientY: number; startViewport: typeof viewport; currentViewport: typeof viewport; offsetX: number; offsetY: number; rafId: number | null; } | null>(null); // 性能优化:监听容器大小变化 React.useEffect(() => { viewportRef.current = viewport; }, [viewport]); React.useEffect(() => { if (hasImageNodes) { preloadCanvasImageNodeRenderer(); return; } if (typeof window === "undefined") return; const connection = ( navigator as Navigator & { connection?: { effectiveType?: string; saveData?: boolean }; } ).connection; if (connection?.saveData || /(^|-)2g$/i.test(connection?.effectiveType || "")) { return; } const idleWindow = window as typeof window & { cancelIdleCallback?: (handle: number) => void; requestIdleCallback?: ( callback: IdleRequestCallback, options?: IdleRequestOptions ) => number; }; let idleId: number | null = null; const warmupTimer = window.setTimeout(() => { if (document.visibilityState !== "visible") return; if (typeof idleWindow.requestIdleCallback === "function") { idleId = idleWindow.requestIdleCallback(preloadCanvasImageNodeRenderer, { timeout: 12000, }); return; } preloadCanvasImageNodeRenderer(); }, 9000); return () => { window.clearTimeout(warmupTimer); if (idleId !== null) { idleWindow.cancelIdleCallback?.(idleId); } }; }, [hasImageNodes]); React.useEffect(() => { if (has3DNodes) { preloadCanvas3DNodeRenderers(); return; } if (typeof window === "undefined") return; const connection = ( navigator as Navigator & { connection?: { effectiveType?: string; saveData?: boolean }; } ).connection; if (connection?.saveData || /(^|-)2g$/i.test(connection?.effectiveType || "")) { return; } const idleWindow = window as typeof window & { cancelIdleCallback?: (handle: number) => void; requestIdleCallback?: ( callback: IdleRequestCallback, options?: IdleRequestOptions ) => number; }; let idleId: number | null = null; const warmupTimer = window.setTimeout(() => { if (document.visibilityState !== "visible") return; if (typeof idleWindow.requestIdleCallback === "function") { idleId = idleWindow.requestIdleCallback(preloadCanvas3DNodeRenderers, { timeout: 15000, }); return; } preloadCanvas3DNodeRenderers(); }, 12000); return () => { window.clearTimeout(warmupTimer); if (idleId !== null) { idleWindow.cancelIdleCallback?.(idleId); } }; }, [has3DNodes]); React.useEffect(() => { return () => { if (pendingViewportRafRef.current !== null) { window.cancelAnimationFrame(pendingViewportRafRef.current); } const panPreview = panPreviewRef.current; if (panPreview && panPreview.rafId !== null) { window.cancelAnimationFrame(panPreview.rafId); } }; }, []); const resetContentLayerTransform = useCallback(() => { const layer = contentLayerRef.current; if (!layer) return; layer.style.transform = "translate3d(0px, 0px, 0px)"; layer.style.willChange = ""; }, []); const schedulePanPreviewTransform = useCallback((offsetX: number, offsetY: number) => { const session = panPreviewRef.current; if (!session) return; session.offsetX = offsetX; session.offsetY = offsetY; if (session.rafId !== null) return; session.rafId = window.requestAnimationFrame(() => { const activeSession = panPreviewRef.current; if (!activeSession) return; activeSession.rafId = null; const layer = contentLayerRef.current; if (!layer) return; layer.style.transform = `translate3d(${activeSession.offsetX}px, ${activeSession.offsetY}px, 0px)`; layer.style.willChange = "transform"; }); }, []); const finishPanPreview = useCallback(() => { const session = panPreviewRef.current; if (!session) return null; if (session.rafId !== null) { window.cancelAnimationFrame(session.rafId); } const finalViewport = session.currentViewport; panPreviewRef.current = null; return finalViewport; }, []); const cancelPanPreview = useCallback(() => { const session = panPreviewRef.current; if (session && session.rafId !== null) { window.cancelAnimationFrame(session.rafId); } panPreviewRef.current = null; resetContentLayerTransform(); }, [resetContentLayerTransform]); const scheduleViewport = useCallback( (nextViewport: typeof viewport) => { pendingViewportRef.current = nextViewport; viewportRef.current = nextViewport; if (pendingViewportRafRef.current !== null) return; pendingViewportRafRef.current = window.requestAnimationFrame(() => { pendingViewportRafRef.current = null; const queuedViewport = pendingViewportRef.current; pendingViewportRef.current = null; if (queuedViewport) { setViewport(queuedViewport); } }); }, [setViewport] ); React.useEffect(() => { const updateSize = () => { if (containerRef.current) { setContainerSize({ width: containerRef.current.clientWidth, height: containerRef.current.clientHeight, }); } }; updateSize(); window.addEventListener('resize', updateSize); return () => window.removeEventListener('resize', updateSize); }, []); // Browser zoom/history gestures can fire before React handlers if nested viewers stop propagation. // Capture-phase non-passive handling keeps wheel gestures scoped to the canvas. React.useEffect(() => { const container = containerRef.current; if (!container) return; const preventTrackpadHistorySwipe = (event: WheelEvent) => { if (event.cancelable) { event.preventDefault(); } }; const wheelListenerOptions: AddEventListenerOptions = { capture: true, passive: false, }; container.addEventListener("wheel", preventTrackpadHistorySwipe, wheelListenerOptions); return () => { container.removeEventListener("wheel", preventTrackpadHistorySwipe, wheelListenerOptions); }; }, []); const isFileDrag = useCallback((dataTransfer?: DataTransfer | null) => { if (!dataTransfer) return false; const types = Array.from(dataTransfer.types || []); if ( types.includes("Files") || types.includes("application/x-moz-file") || types.includes("public.file-url") ) { return true; } const items = dataTransfer.items; if (items && Array.from(items).some((item) => item.kind === "file")) { return true; } return false; }, []); // 防止浏览器默认打开文件 React.useEffect(() => { const handleWindowDragOver = (e: DragEvent) => { if (isFileDrag(e.dataTransfer)) { e.preventDefault(); } }; const handleWindowDrop = (e: DragEvent) => { if (isFileDrag(e.dataTransfer)) { e.preventDefault(); } }; window.addEventListener("dragover", handleWindowDragOver); window.addEventListener("drop", handleWindowDrop); return () => { window.removeEventListener("dragover", handleWindowDragOver); window.removeEventListener("drop", handleWindowDrop); }; }, [isFileDrag]); // 性能优化:始终只渲染视口附近节点,避免打开大画布时一次性拉取所有云端图片。 const visibleNodes = useMemo(() => { return nodes.filter(node => isNodeInViewport(node, viewport, containerSize) || selectedNodeIds.includes(node.id) // 选中的节点总是渲染 ); }, [nodes, viewport, containerSize, selectedNodeIds]); const selectedImageNodes = useMemo( () => nodes.filter( (node) => selectedNodeIds.includes(node.id) && node.type === "image" && isCanvasImageReadyForAi(node) ), [nodes, selectedNodeIds] ); const selectedPendingImageUploadCount = useMemo( () => nodes.filter( (node) => selectedNodeIds.includes(node.id) && node.type === "image" && isCanvasImageUploadPending(node) ).length, [nodes, selectedNodeIds] ); const selectedImageLocalEditItem = getCanvasToolbarItem( canvasToolbarConfig, "imageLocalEdit" ); // 局部编辑属于主无限画布图片的固定能力,旧配置缺少该 key 时也保持可见。 const selectedImageLocalEditEnabled = true; // Store actions const selectNode = useCanvasStore((state) => state.selectNode); const selectNodes = useCanvasStore((state) => state.selectNodes); const clearSelection = useCanvasStore((state) => state.clearSelection); const addNode = useCanvasStore((state) => state.addNode); const addEdge = useCanvasStore((state) => state.addEdge); const deleteEdge = useCanvasStore((state) => state.deleteEdge); const moveNode = useCanvasStore((state) => state.moveNode); const resizeNode = useCanvasStore((state) => state.resizeNode); const pushHistory = useCanvasStore((state) => state.pushHistory); const updateNode = useCanvasStore((state) => state.updateNode); const requestImageNodeAction = useCanvasStore( (state) => state.requestImageNodeAction ); const clearLastAddedNodeId = useCanvasStore((state) => state.clearLastAddedNodeId); const centerTimeoutRef = useRef(null); const clearLatestGeneratedImageHighlights = useCallback(() => { const currentNodes = useCanvasStore.getState().nodes; const clearedAt = Date.now(); currentNodes.forEach((node) => { if (node.type !== "image") return; const data = node.data as Record; const metadata = (data?.metadata || {}) as Record; if (metadata.latestGeneratedHighlight !== true) return; updateNode(node.id, { data: { ...data, metadata: { ...metadata, latestGeneratedHighlight: false, latestGeneratedClearedAt: clearedAt, }, } as any, }); }); }, [updateNode]); const handleAgentSlotDataChange = useCallback( (node: CanvasNode, updates: Partial) => { const currentData = (node.data || {}) as AgentSlotNodeData; updateNode(node.id, { data: { ...currentData, ...updates, metadata: { ...(currentData.metadata || {}), ...(updates.metadata || {}), }, }, }); }, [updateNode] ); const openAgentSlotContextMenu = useCallback( (event: React.MouseEvent, node: CanvasNode) => { if (node.type !== "agent-slot") return; event.preventDefault(); event.stopPropagation(); const rect = containerRef.current?.getBoundingClientRect(); const menuWidth = 236; const menuHeight = 240; const rawX = rect ? event.clientX - rect.left : event.clientX; const rawY = rect ? event.clientY - rect.top : event.clientY; const label = String((node.data as AgentSlotNodeData | undefined)?.label || "").trim() || "Agent 槽位"; clearLatestGeneratedImageHighlights(); setSelectedEdgeId(null); selectNode(node.id, false); setAgentSlotContextMenu({ nodeId: node.id, label, x: Math.max(8, Math.min(rawX, Math.max(8, containerSize.width - menuWidth - 8))), y: Math.max(8, Math.min(rawY, Math.max(8, containerSize.height - menuHeight - 8))), }); }, [ clearLatestGeneratedImageHighlights, containerSize.height, containerSize.width, selectNode, ] ); const updateAgentSlotFromContextMenu = useCallback( (updates: Partial) => { if (!agentSlotContextMenu) return; const node = nodes.find((item) => item.id === agentSlotContextMenu.nodeId); if (!node || node.type !== "agent-slot") return; handleAgentSlotDataChange(node, updates); if (updates.label) { setAgentSlotContextMenu((current) => current ? { ...current, label: String(updates.label) } : current ); } }, [agentSlotContextMenu, handleAgentSlotDataChange, nodes] ); const updateAgentSlotTypeFromContextMenu = useCallback( (slotType: NonNullable) => { if (!agentSlotContextMenu) return; const node = nodes.find((item) => item.id === agentSlotContextMenu.nodeId); if (!node || node.type !== "agent-slot") return; const data = (node.data || {}) as AgentSlotNodeData; const currentLabel = String(data.label || "").trim(); const defaultLabels = new Set(["图片槽位", "视频槽位", "UI 槽位", "Agent 槽位"]); const defaults = slotType === "video" ? { label: "视频槽位", aspectRatio: "16:9" } : slotType === "ui-screen" ? { label: "UI 槽位", aspectRatio: "16:9" } : { label: "图片槽位", aspectRatio: "1:1" }; const nextLabel = !currentLabel || defaultLabels.has(currentLabel) ? defaults.label : currentLabel; handleAgentSlotDataChange(node, { slotType, label: nextLabel, aspectRatio: defaults.aspectRatio, }); setAgentSlotContextMenu((current) => current ? { ...current, label: nextLabel } : current ); }, [agentSlotContextMenu, handleAgentSlotDataChange, nodes] ); const requestFillAgentSlotFromContextMenu = useCallback(() => { if (!agentSlotContextMenu || typeof window === "undefined") return; const detail = { nodeId: agentSlotContextMenu.nodeId, label: agentSlotContextMenu.label, }; const targetWindow = window as CanvasAgentPendingRequestWindow; selectNode(agentSlotContextMenu.nodeId, false); storeCanvasAgentSlotFillRequest(targetWindow, detail); dispatchCanvasAgentOpenChatPanelRequest(targetWindow, { reason: "agent-slot-fill", nodeId: detail.nodeId, }); dispatchCanvasAgentSlotFillRequest(targetWindow, detail); setAgentSlotContextMenu(null); }, [agentSlotContextMenu, selectNode]); const isMacPlatform = React.useMemo(() => { if (typeof navigator === "undefined") return false; return /Mac|iPhone|iPad|iPod/i.test(navigator.platform); }, []); const hasBlockingDialogOpen = useCallback(() => { if (typeof document === "undefined") return false; return Boolean(document.querySelector('[role="dialog"][data-state="open"]')); }, []); const getSnapPreview = useCallback( ( node: CanvasNode, target: Position, ignoreNodeIds: Set = new Set(), ): { position: Position; guides: SnapGuideLines | null } => { if (node.type !== "image") { return { position: target, guides: null }; } const candidates = nodes.filter( (item) => item.type === "image" && item.id !== node.id && !ignoreNodeIds.has(item.id), ); if (candidates.length === 0) { return { position: target, guides: null }; } let bestMatchX: { offset: number; delta: number; guide?: SnapGuideLines["vertical"]; } = { offset: 0, delta: Number.POSITIVE_INFINITY, }; let bestMatchY: { offset: number; delta: number; guide?: SnapGuideLines["horizontal"]; } = { offset: 0, delta: Number.POSITIVE_INFINITY, }; const selfLeft = target.x; const selfCenterX = target.x + node.size.width / 2; const selfRight = target.x + node.size.width; const selfTop = target.y; const selfCenterY = target.y + node.size.height / 2; const selfBottom = target.y + node.size.height; const pickBestX = (delta: number, guide?: SnapGuideLines["vertical"]) => { const abs = Math.abs(delta); if (abs <= SNAP_THRESHOLD && abs < bestMatchX.delta) { bestMatchX = { offset: delta, delta: abs, guide, }; } }; const pickBestY = (delta: number, guide?: SnapGuideLines["horizontal"]) => { const abs = Math.abs(delta); if (abs <= SNAP_THRESHOLD && abs < bestMatchY.delta) { bestMatchY = { offset: delta, delta: abs, guide, }; } }; for (const candidate of candidates) { const targetLeft = candidate.position.x; const targetCenterX = candidate.position.x + candidate.size.width / 2; const targetRight = candidate.position.x + candidate.size.width; const targetTop = candidate.position.y; const targetCenterY = candidate.position.y + candidate.size.height / 2; const targetBottom = candidate.position.y + candidate.size.height; pickBestX(targetLeft - selfLeft, { x: targetLeft, top: Math.min(selfTop, targetTop), bottom: Math.max(selfBottom, targetBottom), }); pickBestX(targetCenterX - selfCenterX, { x: targetCenterX, top: Math.min(selfTop, targetTop), bottom: Math.max(selfBottom, targetBottom), }); pickBestX(targetRight - selfRight, { x: targetRight, top: Math.min(selfTop, targetTop), bottom: Math.max(selfBottom, targetBottom), }); pickBestX(targetRight + SNAP_GAP - selfLeft); pickBestX(targetLeft - SNAP_GAP - selfRight); pickBestY(targetTop - selfTop, { y: targetTop, left: Math.min(selfLeft, targetLeft), right: Math.max(selfRight, targetRight), }); pickBestY(targetCenterY - selfCenterY, { y: targetCenterY, left: Math.min(selfLeft, targetLeft), right: Math.max(selfRight, targetRight), }); pickBestY(targetBottom - selfBottom, { y: targetBottom, left: Math.min(selfLeft, targetLeft), right: Math.max(selfRight, targetRight), }); pickBestY(targetBottom + SNAP_GAP - selfTop); pickBestY(targetTop - SNAP_GAP - selfBottom); } const snappedPosition = { x: target.x + (Number.isFinite(bestMatchX.delta) ? bestMatchX.offset : 0), y: target.y + (Number.isFinite(bestMatchY.delta) ? bestMatchY.offset : 0), }; return { position: snappedPosition, guides: { vertical: bestMatchX.guide, horizontal: bestMatchY.guide, }, }; }, [nodes, SNAP_GAP], ); const getSnappedCanvasPosition = useCallback( ( node: CanvasNode, target: Position, ignoreNodeIds: Set = new Set(), ): Position => { return getSnapPreview(node, target, ignoreNodeIds).position; }, [getSnapPreview], ); const toCanvasPosition = useCallback((clientX: number, clientY: number): Position => { const rect = containerRef.current?.getBoundingClientRect(); if (!rect) return { x: 0, y: 0 }; return { x: clientX - rect.left, y: clientY - rect.top, }; }, []); const isPointOnNodeElement = useCallback((target: EventTarget | null) => { if (!(target instanceof HTMLElement)) return false; return !!target.closest("[data-canvas-node-id], [data-canvas-overlay]"); }, []); const isEventFromCanvasContainer = useCallback((target: EventTarget | null) => { if (!(target instanceof Node) || !containerRef.current) return false; return containerRef.current.contains(target); }, []); const clearBrowserTextSelection = useCallback(() => { if (typeof window === "undefined") return; const selection = window.getSelection(); if (!selection || selection.rangeCount === 0) return; selection.removeAllRanges(); }, []); const handleBlankCanvasDoubleClick = useCallback( (event: React.MouseEvent) => { if (!isWorkflowCanvas || !onBlankCanvasDoubleClick) return; if (hasBlockingDialogOpen()) return; if (isPointOnNodeElement(event.target)) return; const rect = containerRef.current?.getBoundingClientRect(); if (!rect) return; event.preventDefault(); event.stopPropagation(); clearBrowserTextSelection(); const viewportPosition = { x: event.clientX - rect.left, y: event.clientY - rect.top, }; clearLatestGeneratedImageHighlights(); onBlankCanvasDoubleClick({ screenPosition: { x: event.clientX, y: event.clientY, }, canvasPosition: { x: (viewportPosition.x - viewport.x) / viewport.zoom, y: (viewportPosition.y - viewport.y) / viewport.zoom, }, }); }, [ clearBrowserTextSelection, hasBlockingDialogOpen, isPointOnNodeElement, isWorkflowCanvas, onBlankCanvasDoubleClick, viewport.x, viewport.y, viewport.zoom, clearLatestGeneratedImageHighlights, ] ); const fitAllImageNodesIntoViewport = useCallback(() => { const imageNodes = nodes.filter( (node) => node.type === "image" && Boolean((node.data as any)?.src) ); if (imageNodes.length === 0) return; if (containerSize.width <= 0 || containerSize.height <= 0) return; const padding = 80; const minX = Math.min(...imageNodes.map((node) => node.position.x)); const minY = Math.min(...imageNodes.map((node) => node.position.y)); const maxX = Math.max( ...imageNodes.map((node) => node.position.x + node.size.width) ); const maxY = Math.max( ...imageNodes.map((node) => node.position.y + node.size.height) ); const contentWidth = Math.max(1, maxX - minX + padding * 2); const contentHeight = Math.max(1, maxY - minY + padding * 2); const visibleWidth = Math.max(240, containerSize.width - Math.max(0, occlusionRight)); const fitZoomX = visibleWidth / contentWidth; const fitZoomY = containerSize.height / contentHeight; const targetZoom = Math.max(0.05, Math.min(4, Math.min(fitZoomX, fitZoomY))); const centerX = (minX + maxX) / 2; const centerY = (minY + maxY) / 2; const visibleCenterX = visibleWidth / 2; const nextViewportX = visibleCenterX - centerX * targetZoom; const nextViewportY = containerSize.height / 2 - centerY * targetZoom; setViewport({ x: nextViewportX, y: nextViewportY, zoom: targetZoom, }); }, [containerSize.height, containerSize.width, nodes, occlusionRight, setViewport]); const getNodeInputAnchor = useCallback( (node: CanvasNode): Position => ({ x: node.position.x, y: node.position.y + node.size.height / 2, }), [] ); const getNodeOutputAnchor = useCallback( (node: CanvasNode): Position => ({ x: node.position.x + node.size.width, y: node.position.y + node.size.height / 2, }), [] ); const toScreenPosition = useCallback( (point: Position): Position => ({ x: point.x * viewport.zoom + viewport.x, y: point.y * viewport.zoom + viewport.y, }), [viewport.x, viewport.y, viewport.zoom] ); const buildWorkflowPath = useCallback((source: Position, target: Position) => { const deltaX = Math.abs(target.x - source.x); const controlOffset = Math.max(48, Math.min(180, deltaX * 0.5)); return `M ${source.x} ${source.y} C ${source.x + controlOffset} ${source.y}, ${target.x - controlOffset} ${target.y}, ${target.x} ${target.y}`; }, []); const { renderedEdges, renderedMergeTrunks } = useMemo(() => { type RenderedWorkflowEdgeBase = { edge: CanvasEdge; sourcePoint: Position; targetPoint: Position; }; const nodeById = new Map(nodes.map((node) => [node.id, node])); const baseEdges = edges .map((edge) => { const sourceNode = nodeById.get(edge.source); const targetNode = nodeById.get(edge.target); if (!sourceNode || !targetNode) return null; return { edge, sourcePoint: toScreenPosition(getNodeOutputAnchor(sourceNode)), targetPoint: toScreenPosition(getNodeInputAnchor(targetNode)), }; }) .filter((item): item is RenderedWorkflowEdgeBase => Boolean(item)); const incomingEdges = new Map(); baseEdges.forEach((item) => { const group = incomingEdges.get(item.edge.target) ?? []; group.push(item); incomingEdges.set(item.edge.target, group); }); const mergePointByTarget = new Map(); const renderedMergeTrunks: { targetNodeId: string; targetPoint: Position; mergePoint: Position; path: string; edgeIds: string[]; }[] = []; incomingEdges.forEach((items, targetNodeId) => { if (items.length <= 1) return; const targetPoint = items[0].targetPoint; const averageSourceX = items.reduce((total, item) => total + item.sourcePoint.x, 0) / items.length; const averageDeltaX = items.reduce((total, item) => total + Math.abs(targetPoint.x - item.sourcePoint.x), 0) / items.length; const mergeDistance = Math.max(56, Math.min(180, averageDeltaX * 0.32)); const direction = averageSourceX <= targetPoint.x ? -1 : 1; const mergePoint = { x: targetPoint.x + direction * mergeDistance, y: targetPoint.y, }; mergePointByTarget.set(targetNodeId, mergePoint); renderedMergeTrunks.push({ targetNodeId, targetPoint, mergePoint, path: buildWorkflowPath(mergePoint, targetPoint), edgeIds: items.map((item) => item.edge.id), }); }); const renderedEdges = baseEdges.map((item) => { const mergePoint = mergePointByTarget.get(item.edge.target); const visualTargetPoint = mergePoint ?? item.targetPoint; return { ...item, mergePoint, path: buildWorkflowPath(item.sourcePoint, visualTargetPoint), midPoint: { x: (item.sourcePoint.x + visualTargetPoint.x) / 2, y: (item.sourcePoint.y + visualTargetPoint.y) / 2, }, }; }); return { renderedEdges, renderedMergeTrunks }; }, [buildWorkflowPath, edges, getNodeInputAnchor, getNodeOutputAnchor, nodes, toScreenPosition]); // 处理画布点击(取消选择) const handleCanvasClick = useCallback((e: React.MouseEvent) => { if (hasBlockingDialogOpen()) return; setAgentSlotContextMenu(null); if (!isEventFromCanvasContainer(e.target)) return; if (suppressCanvasClickRef.current) { suppressCanvasClickRef.current = false; return; } clearLatestGeneratedImageHighlights(); setSelectedEdgeId(null); if (!isPointOnNodeElement(e.target) && !isMarqueeSelecting) { const now = Date.now(); const isDoubleBlankClick = now - lastBlankCanvasClickAtRef.current <= 320; lastBlankCanvasClickAtRef.current = now; clearSelection(); if (isDoubleBlankClick) { fitAllImageNodesIntoViewport(); } } }, [clearLatestGeneratedImageHighlights, clearSelection, fitAllImageNodesIntoViewport, hasBlockingDialogOpen, isEventFromCanvasContainer, isMarqueeSelecting, isPointOnNodeElement]); // 处理画布平移开始 const handleMouseDown = useCallback((e: React.MouseEvent) => { if (hasBlockingDialogOpen()) return; if (!isEventFromCanvasContainer(e.target)) return; if (connectionDraft) { return; } clearLatestGeneratedImageHighlights(); const shouldPanBlankWorkflowCanvas = isWorkflowCanvas && tool === "select" && e.button === 0 && !e.altKey && !e.shiftKey && !isPointOnNodeElement(e.target); if ( tool === 'pan' || e.button === 1 || (e.button === 0 && e.altKey) || shouldPanBlankWorkflowCanvas ) { cancelPanPreview(); const startViewport = viewportRef.current; setIsPanning(true); setPanStart({ x: e.clientX - startViewport.x, y: e.clientY - startViewport.y }); panPreviewRef.current = { startClientX: e.clientX, startClientY: e.clientY, startViewport, currentViewport: startViewport, offsetX: 0, offsetY: 0, rafId: null, }; e.preventDefault(); return; } if (tool === "select" && e.button === 0 && !isPointOnNodeElement(e.target)) { e.preventDefault(); clearBrowserTextSelection(); const start = toCanvasPosition(e.clientX, e.clientY); setIsMarqueeSelecting(true); setMarqueeStart(start); setMarqueeEnd(start); } }, [cancelPanPreview, clearBrowserTextSelection, clearLatestGeneratedImageHighlights, connectionDraft, hasBlockingDialogOpen, isEventFromCanvasContainer, isPointOnNodeElement, isWorkflowCanvas, toCanvasPosition, tool]); // 处理画布平移 const handleMouseMove = useCallback((e: React.MouseEvent) => { if (hasBlockingDialogOpen()) { if (isPanning) { setIsPanning(false); cancelPanPreview(); } return; } if (!isEventFromCanvasContainer(e.target)) return; dragPointerRef.current = toCanvasPosition(e.clientX, e.clientY); if (connectionDraft) { e.preventDefault(); setConnectionDraft((current) => current ? { ...current, current: { x: (e.clientX - viewport.x - (containerRef.current?.getBoundingClientRect().left || 0)) / viewport.zoom, y: (e.clientY - viewport.y - (containerRef.current?.getBoundingClientRect().top || 0)) / viewport.zoom, }, } : null ); return; } if (isPanning) { e.preventDefault(); const session = panPreviewRef.current; const currentViewport = viewportRef.current; const nextViewport = session ? { ...session.startViewport, x: session.startViewport.x + e.clientX - session.startClientX, y: session.startViewport.y + e.clientY - session.startClientY, } : { ...currentViewport, x: e.clientX - panStart.x, y: e.clientY - panStart.y, }; if (session) { session.currentViewport = nextViewport; viewportRef.current = nextViewport; schedulePanPreviewTransform( nextViewport.x - session.startViewport.x, nextViewport.y - session.startViewport.y, ); } else { scheduleViewport(nextViewport); } return; } if (isMarqueeSelecting) { e.preventDefault(); setMarqueeEnd(toCanvasPosition(e.clientX, e.clientY)); } }, [cancelPanPreview, connectionDraft, hasBlockingDialogOpen, isEventFromCanvasContainer, isMarqueeSelecting, isPanning, panStart.x, panStart.y, schedulePanPreviewTransform, scheduleViewport, toCanvasPosition, viewport.x, viewport.y, viewport.zoom]); // 处理画布平移结束 const handleMouseUp = useCallback((e?: React.MouseEvent) => { if (e && !isEventFromCanvasContainer(e.target) && !isPanning) return; if (connectionDraft) { if (e) { const releaseTarget = document.elementFromPoint(e.clientX, e.clientY); const handleEl = releaseTarget instanceof HTMLElement ? releaseTarget.closest("[data-canvas-input-handle]") : null; const targetNodeId = handleEl instanceof HTMLElement ? handleEl.dataset.nodeId || null : null; if (targetNodeId && targetNodeId !== connectionDraft.sourceId) { addEdge({ source: connectionDraft.sourceId, target: targetNodeId, type: "default", }); setSelectedEdgeId(null); } } setConnectionDraft(null); return; } const finalPanViewport = isPanning ? finishPanPreview() : null; if (finalPanViewport) { setViewport(finalPanViewport); resetContentLayerTransform(); } setIsPanning(false); setSnapGuides(null); clearBrowserTextSelection(); if (isMarqueeSelecting && marqueeStart && marqueeEnd) { const left = Math.min(marqueeStart.x, marqueeEnd.x); const top = Math.min(marqueeStart.y, marqueeEnd.y); const right = Math.max(marqueeStart.x, marqueeEnd.x); const bottom = Math.max(marqueeStart.y, marqueeEnd.y); const minSize = 4; if (right - left < minSize && bottom - top < minSize) { clearSelection(); } else { const selectedIds = nodes .filter((node) => { const x = node.position.x * viewport.zoom + viewport.x; const y = node.position.y * viewport.zoom + viewport.y; const width = node.size.width * viewport.zoom; const height = node.size.height * viewport.zoom; const nodeRight = x + width; const nodeBottom = y + height; return !(nodeRight < left || x > right || nodeBottom < top || y > bottom); }) .map((node) => node.id); if (selectedIds.length > 0) { selectNodes(selectedIds); suppressCanvasClickRef.current = true; } else { clearSelection(); } } } setIsMarqueeSelecting(false); setMarqueeStart(null); setMarqueeEnd(null); }, [addEdge, clearBrowserTextSelection, clearSelection, connectionDraft, finishPanPreview, isEventFromCanvasContainer, isMarqueeSelecting, isPanning, marqueeEnd, marqueeStart, nodes, resetContentLayerTransform, selectNodes, setViewport, viewport.x, viewport.y, viewport.zoom]); // 新增图片后自动居中展示 const centerNodeInView = useCallback((nodeId: string) => { const node = nodes.find(n => n.id === nodeId); if (!node) return; const width = containerSize.width || 0; const height = containerSize.height || 0; if (width === 0 || height === 0) return; const zoom = viewport.zoom; const centerX = node.position.x + node.size.width / 2; const centerY = node.position.y + node.size.height / 2; const newX = width / 2 - centerX * zoom; const newY = height / 2 - centerY * zoom; setViewport({ x: newX, y: newY }); }, [containerSize.width, containerSize.height, nodes, setViewport, viewport.zoom]); React.useEffect(() => { if (!lastAddedNodeId) return; if (containerSize.width === 0 || containerSize.height === 0) return; if (centerTimeoutRef.current) { window.clearTimeout(centerTimeoutRef.current); } centerTimeoutRef.current = window.setTimeout(() => { centerNodeInView(lastAddedNodeId); clearLastAddedNodeId(); }, 80); return () => { if (centerTimeoutRef.current) { window.clearTimeout(centerTimeoutRef.current); centerTimeoutRef.current = null; } }; }, [lastAddedNodeId, centerNodeInView, clearLastAddedNodeId, containerSize.width, containerSize.height]); // 处理滚轮缩放 const handleWheel = useCallback((e: React.WheelEvent) => { if (hasBlockingDialogOpen()) return; if (!isEventFromCanvasContainer(e.target)) return; clearLatestGeneratedImageHighlights(); if (e.cancelable) { e.preventDefault(); } e.stopPropagation(); // mac: 双指滚动默认平移,Cmd/Ctrl+滚轮才缩放(保留触控板体验) // windows/linux: 鼠标滚轮默认缩放画布,不触发整页缩放 const shouldZoom = isMacPlatform ? (e.ctrlKey || e.metaKey) : !e.shiftKey; const currentViewport = viewportRef.current; if (!shouldZoom) { scheduleViewport({ ...currentViewport, x: currentViewport.x - e.deltaX, y: currentViewport.y - e.deltaY, }); return; } const zoomFactor = e.deltaY > 0 ? 0.95 : 1.05; const newZoom = Math.max(0.1, Math.min(4, currentViewport.zoom * zoomFactor)); const rect = containerRef.current?.getBoundingClientRect(); if (!rect) return; const mouseX = e.clientX - rect.left; const mouseY = e.clientY - rect.top; const scaleChange = newZoom / currentViewport.zoom; const newX = mouseX - (mouseX - currentViewport.x) * scaleChange; const newY = mouseY - (mouseY - currentViewport.y) * scaleChange; scheduleViewport({ ...currentViewport, x: newX, y: newY, zoom: newZoom }); }, [clearLatestGeneratedImageHighlights, hasBlockingDialogOpen, isEventFromCanvasContainer, isMacPlatform, scheduleViewport]); const getImageDimensionsFromFile = useCallback((file: File): Promise<{ width: number; height: number; originalWidth: number; originalHeight: number; }> => { return new Promise((resolve) => { const url = URL.createObjectURL(file); const img = new Image(); img.onload = () => { const maxSize = 500; const originalWidth = img.naturalWidth; const originalHeight = img.naturalHeight; let width = originalWidth; let height = originalHeight; if (width > maxSize || height > maxSize) { const scale = Math.min(maxSize / width, maxSize / height); width = Math.round(width * scale); height = Math.round(height * scale); } const minSize = 150; if (width < minSize && height < minSize) { const scale = minSize / Math.min(width, height); width = Math.round(width * scale); height = Math.round(height * scale); } URL.revokeObjectURL(url); resolve({ width, height, originalWidth, originalHeight }); }; img.onerror = () => { URL.revokeObjectURL(url); resolve({ width: 300, height: 300, originalWidth: 0, originalHeight: 0 }); }; img.src = url; }); }, []); const processDroppedFiles = useCallback(async (files: File[], dropClientX: number, dropClientY: number) => { if (files.length === 0) return; clearLatestGeneratedImageHighlights(); const rect = containerRef.current?.getBoundingClientRect(); const baseX = rect ? (dropClientX - rect.left - viewport.x) / viewport.zoom : 100; const baseY = rect ? (dropClientY - rect.top - viewport.y) / viewport.zoom : 100; const spacing = 20; for (let index = 0; index < files.length; index += 1) { const file = files[index]; if (!isImageFile(file)) continue; if (file.size > 50 * 1024 * 1024) { toast.error("图片大小不能超过 50MB"); continue; } const dimensions = await getImageDimensionsFromFile(file); const cleanName = file.name.replace(/\.[^.]+$/, ""); const previewUrl = URL.createObjectURL(file); const placeholderId = addNode({ type: "image", position: { x: baseX + index * spacing, y: baseY + index * spacing }, size: { width: dimensions.width, height: dimensions.height }, data: { src: previewUrl, status: "generating", prompt: cleanName || "拖拽上传", title: cleanName || "拖拽上传", metadata: { originalWidth: dimensions.originalWidth, originalHeight: dimensions.originalHeight, [LOCAL_UPLOAD_PREVIEW_METADATA_KEY]: previewUrl, [LOCAL_UPLOAD_PENDING_METADATA_KEY]: true, }, }, connections: [], }); void imageGenerationService.uploadImage(file).then((result) => { if (result.success && result.data?.url) { updateNode(placeholderId, { data: { src: result.data.url, assetId: result.data.assetId, status: "completed", prompt: cleanName || "拖拽上传", title: cleanName || "拖拽上传", metadata: { originalWidth: dimensions.originalWidth, originalHeight: dimensions.originalHeight, }, }, }); URL.revokeObjectURL(previewUrl); toast.success("图片已添加到画布"); return; } updateNode(placeholderId, { data: { src: previewUrl, status: "failed", prompt: cleanName || "拖拽上传", title: cleanName || "拖拽上传", metadata: { originalWidth: dimensions.originalWidth, originalHeight: dimensions.originalHeight, uploadError: result.error || "图片上传失败", [LOCAL_UPLOAD_PREVIEW_METADATA_KEY]: previewUrl, [LOCAL_UPLOAD_PENDING_METADATA_KEY]: false, }, }, }); toast.error(result.error || "图片上传失败"); }).catch(() => { updateNode(placeholderId, { data: { src: previewUrl, status: "failed", prompt: cleanName || "拖拽上传", title: cleanName || "拖拽上传", metadata: { originalWidth: dimensions.originalWidth, originalHeight: dimensions.originalHeight, uploadError: "图片上传失败", [LOCAL_UPLOAD_PREVIEW_METADATA_KEY]: previewUrl, [LOCAL_UPLOAD_PENDING_METADATA_KEY]: false, }, }, }); toast.error("图片上传失败"); }); } }, [addNode, clearLatestGeneratedImageHighlights, getImageDimensionsFromFile, updateNode, viewport.x, viewport.y, viewport.zoom]); React.useEffect(() => { const handleWindowDrop = async (e: DragEvent) => { if (!isFileDrag(e.dataTransfer)) return; const container = containerRef.current; if (!container) return; const rect = container.getBoundingClientRect(); const x = e.clientX || dragPointerRef.current.x + rect.left; const y = e.clientY || dragPointerRef.current.y + rect.top; const inContainer = x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom; if (!inContainer) return; const files = extractImageFilesFromDataTransfer(e.dataTransfer); if (files.length === 0) return; e.preventDefault(); dragCounterRef.current = 0; setIsFileDragActive(false); await processDroppedFiles(files, x, y); }; window.addEventListener("drop", handleWindowDrop); return () => window.removeEventListener("drop", handleWindowDrop); }, [isFileDrag, processDroppedFiles]); const handleDragEnter = useCallback((e: React.DragEvent) => { if (!isFileDrag(e.dataTransfer)) return; e.preventDefault(); dragCounterRef.current += 1; setIsFileDragActive(true); dragPointerRef.current = toCanvasPosition(e.clientX, e.clientY); }, [isFileDrag, toCanvasPosition]); const handleDragLeave = useCallback((e: React.DragEvent) => { if (!isFileDrag(e.dataTransfer)) return; e.preventDefault(); dragCounterRef.current = Math.max(0, dragCounterRef.current - 1); if (dragCounterRef.current === 0) { setIsFileDragActive(false); } }, [isFileDrag]); const handleDragOver = useCallback((e: React.DragEvent) => { if (!isFileDrag(e.dataTransfer)) return; e.preventDefault(); e.dataTransfer.dropEffect = "copy"; dragPointerRef.current = toCanvasPosition(e.clientX, e.clientY); }, [isFileDrag, toCanvasPosition]); const handleDrop = useCallback(async (e: React.DragEvent) => { if (!isFileDrag(e.dataTransfer)) return; e.preventDefault(); e.stopPropagation(); dragCounterRef.current = 0; setIsFileDragActive(false); const files = extractImageFilesFromDataTransfer(e.dataTransfer); if (files.length > 0) { await processDroppedFiles(files, e.clientX, e.clientY); } }, [isFileDrag, processDroppedFiles]); React.useEffect(() => { const handlePaste = async (e: ClipboardEvent) => { if (hasBlockingDialogOpen() || isEditablePasteTarget(e.target)) return; const container = containerRef.current; if (!container) return; const activeElement = document.activeElement; if ( activeElement instanceof HTMLElement && activeElement !== document.body && !container.contains(activeElement) ) { return; } const files = extractImageFilesFromDataTransfer(e.clipboardData); if (files.length === 0) return; e.preventDefault(); const rect = container.getBoundingClientRect(); await processDroppedFiles( files, rect.left + rect.width / 2, rect.top + rect.height / 2 ); }; window.addEventListener("paste", handlePaste); return () => window.removeEventListener("paste", handlePaste); }, [hasBlockingDialogOpen, processDroppedFiles]); const handleDownloadSelectedImages = useCallback(async () => { if (selectedImageNodes.length === 0) { toast.error("请先框选要下载的图片"); return; } if (isDownloadSubmitting) { return; } if (selectedImageNodes.length > 50) { toast.warning("当前选择图片较多,正在打包,请稍候"); } const now = new Date(); const folderName = `canvas-images-${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, "0")}${String(now.getDate()).padStart(2, "0")}-${String(now.getHours()).padStart(2, "0")}${String(now.getMinutes()).padStart(2, "0")}${String(now.getSeconds()).padStart(2, "0")}`; const browserOrigin = typeof window !== "undefined" ? window.location.origin : ""; const files = selectedImageNodes .map((node, index) => { const nodeData = node.data as any; const rawImageUrl = String(nodeData?.src || "").trim(); if (!rawImageUrl) return null; let imageUrl = inferOriginalImageUrl(rawImageUrl) || rawImageUrl; if (browserOrigin && rawImageUrl.startsWith("/")) { imageUrl = `${browserOrigin}${rawImageUrl}`; } else if (browserOrigin && rawImageUrl.startsWith("//")) { imageUrl = `${window.location.protocol}${rawImageUrl}`; } const name = String(nodeData?.title || "").trim() || String(nodeData?.prompt || "").trim() || `canvas-image-${index + 1}`; return { url: imageUrl, name }; }) .filter((item): item is { url: string; name: string } => Boolean(item)); if (files.length === 0) { toast.error("当前没有可下载的图片"); return; } setIsDownloadSubmitting(true); try { const form = document.createElement("form"); form.method = "POST"; form.action = "/api/canvas/download-zip"; form.style.display = "none"; const payloadInput = document.createElement("input"); payloadInput.type = "hidden"; payloadInput.name = "payload"; payloadInput.value = JSON.stringify({ folderName, files, }); form.appendChild(payloadInput); document.body.appendChild(form); form.submit(); form.remove(); toast.success("已开始打包下载,请留意浏览器下载栏"); } catch (_error) { toast.error("下载失败,请稍后重试"); } finally { window.setTimeout(() => { setIsDownloadSubmitting(false); }, 800); } }, [isDownloadSubmitting, selectedImageNodes]); React.useEffect(() => { if (!selectedEdgeId) return; const handleKeyDown = (event: KeyboardEvent) => { const target = event.target as HTMLElement | null; if ( target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement || target?.isContentEditable ) { return; } if (event.key === "Delete" || event.key === "Backspace") { event.preventDefault(); deleteEdge(selectedEdgeId); setSelectedEdgeId(null); } }; window.addEventListener("keydown", handleKeyDown); return () => window.removeEventListener("keydown", handleKeyDown); }, [deleteEdge, selectedEdgeId]); // 渲染节点 const renderNode = (node: CanvasNode) => { const isSelected = selectedNodeIds.includes(node.id); const showWorkflowHandles = isWorkflowCanvas && (tool === "draw" || isSelected); const isImageNode = node.type === "image"; const enableImageResizeHandles = isSelected && tool === "select" && isImageNode ? { top: false, right: false, bottom: false, left: false, topRight: true, bottomRight: true, bottomLeft: true, topLeft: true, } : isSelected && tool === "select"; const imageHandleSize = IMAGE_HANDLE_SIZE * viewport.zoom; const imageHandleOffset = imageHandleSize / 2; const imageSelectionStrokeWidth = SELECTION_STROKE_WIDTH * viewport.zoom; const imageResizeHandleStyles = isImageNode ? { topRight: { width: imageHandleSize, height: imageHandleSize, right: -imageHandleOffset, top: -imageHandleOffset, borderRadius: 0, border: `${imageSelectionStrokeWidth}px solid ${SELECTION_COLOR}`, backgroundColor: "#ffffff", }, bottomRight: { width: imageHandleSize, height: imageHandleSize, right: -imageHandleOffset, bottom: -imageHandleOffset, borderRadius: 0, border: `${imageSelectionStrokeWidth}px solid ${SELECTION_COLOR}`, backgroundColor: "#ffffff", }, bottomLeft: { width: imageHandleSize, height: imageHandleSize, left: -imageHandleOffset, bottom: -imageHandleOffset, borderRadius: 0, border: `${imageSelectionStrokeWidth}px solid ${SELECTION_COLOR}`, backgroundColor: "#ffffff", }, topLeft: { width: imageHandleSize, height: imageHandleSize, left: -imageHandleOffset, top: -imageHandleOffset, borderRadius: 0, border: `${imageSelectionStrokeWidth}px solid ${SELECTION_COLOR}`, backgroundColor: "#ffffff", }, } : undefined; return ( { clearLatestGeneratedImageHighlights(); setSnapGuides(null); if (!isSelected) { selectNode(node.id); groupDragRef.current = null; return; } if (selectedNodeIds.length > 1) { const originPositions: Record = {}; nodes.forEach((item) => { if (selectedNodeIds.includes(item.id)) { originPositions[item.id] = { ...item.position }; } }); groupDragRef.current = { nodeId: node.id, startX: node.position.x * viewport.zoom + viewport.x, startY: node.position.y * viewport.zoom + viewport.y, originPositions, }; } else { groupDragRef.current = null; } }} onDrag={(e, d) => { const group = groupDragRef.current; if (group && group.nodeId === node.id && selectedNodeIds.length > 1) { const deltaCanvasX = (d.x - group.startX) / viewport.zoom; const deltaCanvasY = (d.y - group.startY) / viewport.zoom; const origin = group.originPositions[node.id]; const rawAnchor = origin ? { x: origin.x + deltaCanvasX, y: origin.y + deltaCanvasY, } : { x: node.position.x + deltaCanvasX, y: node.position.y + deltaCanvasY, }; const preview = getSnapPreview( node, rawAnchor, new Set(selectedNodeIds), ); setSnapGuides(preview.guides); selectedNodeIds.forEach((id) => { const itemOrigin = group.originPositions[id]; if (!itemOrigin) return; moveNode(id, { x: itemOrigin.x + deltaCanvasX, y: itemOrigin.y + deltaCanvasY, }); }); return; } if (selectedNodeIds.length > 1) return; const rawPosition = { x: (d.x - viewport.x) / viewport.zoom, y: (d.y - viewport.y) / viewport.zoom, }; const preview = getSnapPreview(node, rawPosition); setSnapGuides(preview.guides); }} onDragStop={(e, d) => { setSnapGuides(null); const group = groupDragRef.current; if (group && group.nodeId === node.id && selectedNodeIds.length > 1) { const deltaCanvasX = (d.x - group.startX) / viewport.zoom; const deltaCanvasY = (d.y - group.startY) / viewport.zoom; const origin = group.originPositions[node.id]; const rawAnchor = origin ? { x: origin.x + deltaCanvasX, y: origin.y + deltaCanvasY, } : { x: node.position.x + deltaCanvasX, y: node.position.y + deltaCanvasY, }; const snappedAnchor = getSnappedCanvasPosition( node, rawAnchor, new Set(selectedNodeIds), ); const offsetX = origin ? snappedAnchor.x - origin.x : deltaCanvasX; const offsetY = origin ? snappedAnchor.y - origin.y : deltaCanvasY; selectedNodeIds.forEach((id) => { const itemOrigin = group.originPositions[id]; if (!itemOrigin) return; moveNode(id, { x: itemOrigin.x + offsetX, y: itemOrigin.y + offsetY, }); }); groupDragRef.current = null; pushHistory(); return; } const newX = (d.x - viewport.x) / viewport.zoom; const newY = (d.y - viewport.y) / viewport.zoom; const snapped = getSnappedCanvasPosition(node, { x: newX, y: newY }); moveNode(node.id, snapped); groupDragRef.current = null; pushHistory(); }} onResizeStart={() => { clearLatestGeneratedImageHighlights(); }} onResizeStop={(e, direction, ref, delta, position) => { const newWidth = parseInt(ref.style.width) / viewport.zoom; const newHeight = parseInt(ref.style.height) / viewport.zoom; const newX = (position.x - viewport.x) / viewport.zoom; const newY = (position.y - viewport.y) / viewport.zoom; resizeNode(node.id, { width: newWidth, height: newHeight }); moveNode(node.id, { x: newX, y: newY }); pushHistory(); }} bounds="parent" enableResizing={enableImageResizeHandles} resizeHandleStyles={imageResizeHandleStyles} disableDragging={tool !== 'select'} cancel={CANVAS_DRAG_CANCEL_SELECTOR} className={cn( "absolute", tool === "select" && "cursor-pointer" )} data-canvas-node-id={node.id} style={{ overflow: 'visible' }} // 允许工具栏显示在节点外部 onContextMenu={(event: React.MouseEvent) => { if (node.type === "agent-slot") { openAgentSlotContextMenu(event, node); } }} onClick={(e: React.MouseEvent) => { e.stopPropagation(); setAgentSlotContextMenu(null); clearLatestGeneratedImageHighlights(); setSelectedEdgeId(null); selectNode(node.id, e.shiftKey); }} > {showWorkflowHandles ? ( <> ) : null} )} {agentSlotContextMenu ? (
event.stopPropagation()} onContextMenu={(event) => { event.preventDefault(); event.stopPropagation(); }} onMouseDown={(event) => event.stopPropagation()} >
{agentSlotContextMenu.label}
) : null} {showZoomControls ? (
) : null}
); }