import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import type { ChangeEvent as ReactChangeEvent, DragEvent as ReactDragEvent, MouseEvent as ReactMouseEvent, PointerEvent as ReactPointerEvent } from "react"; import { useNavigate, useParams, useSearchParams } from "react-router-dom"; import { Group, Video } from "lucide-react"; import { saveAs } from "file-saver"; import { requestEdit, requestGeneration, requestImageQuestion } from "@/services/api/image"; import { requestAudioGeneration, storeGeneratedAudio } from "@/services/api/audio"; import { requestVideoGeneration, storeGeneratedVideo } from "@/services/api/video"; import { defaultConfig, useConfigStore, useEffectiveConfig } from "@/stores/use-config-store"; import { uploadImage } from "@/services/image-storage"; import { uploadMediaFile } from "@/services/file-storage"; import { nanoid } from "nanoid"; import { getDataUrlByteSize, readImageMeta } from "@/lib/image-utils"; import { canvasThemes, type CanvasBackgroundMode } from "@/lib/canvas-theme"; import { useAssetStore } from "@/stores/use-asset-store"; import { useThemeStore } from "@/stores/use-theme-store"; import { cropDataUrl, splitDataUrl, upscaleDataUrl } from "@/lib/canvas/canvas-image-data"; import { fitNodeSize, nodeSizeFromRatio } from "@/lib/canvas/canvas-node-size"; import { App, Button, Modal } from "antd"; import { NODE_DEFAULT_SIZE, getNodeSpec } from "@/constant/canvas"; import { ActiveConnectionPath, ConnectionPath } from "@/components/canvas/canvas-connections"; import { CanvasConfigComposer } from "@/components/canvas/canvas-config-composer"; import { CanvasConfigNodePanel } from "@/components/canvas/canvas-config-node-panel"; import { CanvasNodeContextMenu } from "@/components/canvas/canvas-context-menu"; import { CanvasNodeAngleDialog, type CanvasImageAngleParams } from "@/components/canvas/canvas-node-angle-dialog"; import { CanvasNodeCropDialog, type CanvasImageCropRect } from "@/components/canvas/canvas-node-crop-dialog"; import { CanvasNodeMaskEditDialog, type CanvasImageMaskEditPayload } from "@/components/canvas/canvas-node-mask-edit-dialog"; import { CanvasNodeSplitDialog, type CanvasImageSplitParams } from "@/components/canvas/canvas-node-split-dialog"; import { CanvasNodeUpscaleDialog, type CanvasImageUpscaleParams } from "@/components/canvas/canvas-node-upscale-dialog"; import { buildNodeGenerationContext, buildNodeGenerationInputs, buildNodeResponseMessages, hydrateNodeGenerationContext, type NodeGenerationInput } from "@/components/canvas/canvas-node-generation"; import { CanvasNodeHoverToolbar, CanvasNodeInfoModal } from "@/components/canvas/canvas-node-hover-toolbar"; import { InfiniteCanvas } from "@/components/canvas/infinite-canvas"; import { Minimap } from "@/components/canvas/canvas-mini-map"; import { CanvasNode } from "@/components/canvas/canvas-node"; import { CanvasNodePromptPanel, type CanvasNodeGenerationMode } from "@/components/canvas/canvas-node-prompt-panel"; import { CanvasToolbar } from "@/components/canvas/canvas-toolbar"; import { AssetPickerModal, type InsertAssetPayload } from "@/components/canvas/asset-picker-modal"; import { CanvasSidePanel } from "@/components/canvas/canvas-side-panel"; import { CanvasZoomControls } from "@/components/canvas/canvas-zoom-controls"; import { useAgentStore } from "@/stores/use-agent-store"; import { useCanvasStore } from "@/stores/canvas/use-canvas-store"; import { useAgentBridge } from "@/pages/canvas/hooks/use-agent-bridge"; import { usePluginHost } from "@/pages/canvas/hooks/use-plugin-host"; import { buildNodeMentionReferences, type CanvasResourceReference } from "@/lib/canvas/canvas-resource-references"; import { exportCanvasProjects } from "@/lib/canvas/canvas-export"; import { applyNodeConfigPatch, audioMetadata, buildAudioGenerationMetadata, buildImageGenerationMetadata, createCanvasNode, imageMetadata, videoMetadata } from "@/lib/canvas/canvas-node-factory"; import { findContainingGroupId, findGroupDropTarget, getConnectionTargetAnchor, isHiddenBatchChild, isHiddenBatchConnectionEndpoint, normalizeConnection, snapNodesIntoGroup } from "@/lib/canvas/canvas-node-geometry"; import { audioExtension, buildAngleLabel, buildAnglePrompt, buildGenerationConfig, findRetrySourceNode, generationReferenceUrls, getGenerationCount, getInputSummary, hydrateAssistantImages, hydrateCanvasImages, imageExtension, isAudioFile, isGenerationCanceled, resetInterruptedGeneration, resolveMetadataReferences, sourceNodeReferenceImages, } from "@/lib/canvas/canvas-generation-helpers"; import { getNodeDefinition, isBuiltinNodeType as isBuiltinType, useNodeRegistryVersion } from "@/lib/canvas/node-registry"; import { registerBuiltinNodes } from "@/components/canvas/nodes/builtin-nodes"; import { CanvasPluginManagerModal } from "@/components/canvas/canvas-plugin-manager-modal"; import { CanvasRefreshShell } from "@/components/canvas/canvas-refresh-shell"; import { CanvasTopBar } from "@/components/canvas/canvas-top-bar"; import { ConnectionCreateMenu, NodeCreateMenu, type PendingConnectionCreate } from "@/components/canvas/canvas-create-menus"; import { CanvasNodeType, type CanvasAssistantImage, type CanvasAssistantSession, type CanvasConnection, type CanvasNodeData, type CanvasNodeMetadata, type CanvasNodeTypeId, type ConnectionHandle, type ContextMenuState, type Position, type SelectionBox, type ViewportTransform, } from "@/types/canvas"; import type { ReferenceImage } from "@/types/image"; import type { ReferenceAudio } from "@/types/media"; // 内置节点注册到统一注册表(模块加载时执行一次) registerBuiltinNodes(); type CanvasClipboard = { nodes: CanvasNodeData[]; connections: CanvasConnection[]; }; type ConnectionDropTarget = { nodeId: string | null; isNearNode: boolean; }; type CanvasHistoryEntry = Pick & { chatSessions: CanvasAssistantSession[]; activeChatId: string | null; backgroundMode: CanvasBackgroundMode; showImageInfo: boolean; }; type CanvasGenerationRequest = { targetNodeId: string; originNodeId: string; runningNodeId: string; controller: AbortController; }; const VIDEO_NODE_MAX_WIDTH = 420; const VIDEO_NODE_MAX_HEIGHT = 420; // 稳定的空引用数组:避免每次渲染 `... || []` 产生新数组引用而击穿 CanvasNode 的 React.memo const EMPTY_REFERENCES: CanvasResourceReference[] = []; const CONNECTION_HANDLE_HIT_RADIUS = 40; const CONNECTION_NODE_HIT_PADDING = 32; const NODE_STATUS_IDLE = "idle" as const; const NODE_STATUS_LOADING = "loading" as const; const NODE_STATUS_SUCCESS = "success" as const; const NODE_STATUS_ERROR = "error" as const; const IMAGE_PROMPT_REVERSE_PRESET = `请根据参考图片反推一段适合用于 AI 生图的提示词。 要求: 1. 只输出提示词正文,不要解释。 2. 覆盖主体、构图、风格、光线、色彩、材质、镜头和氛围。 3. 尽量写成可直接用于生图模型的完整提示词。`; export default function CanvasPage() { const [mounted, setMounted] = useState(false); useEffect(() => { setMounted(true); }, []); if (!mounted) return ; return ; } function InfiniteCanvasPage() { const { message, modal } = App.useApp(); // 订阅节点注册表版本,插件动态注册/卸载后驱动画布重渲染 const nodeRegistryVersion = useNodeRegistryVersion((state) => state.version); const params = useParams<{ id: string }>(); const navigate = useNavigate(); const [searchParams] = useSearchParams(); const projectId = params.id || ""; const localAgentConnected = useAgentStore((state) => state.connected); const localAgentActivity = useAgentStore((state) => state.activity); const localAgentEnabled = useAgentStore((state) => state.enabled); const agentPanelOpen = useAgentStore((state) => state.panelOpen); const toggleAgentPanel = useAgentStore((state) => state.togglePanel); const openAgentPanel = useAgentStore((state) => state.openPanel); const containerRef = useRef(null); const imageInputRef = useRef(null); const uploadTargetRef = useRef<{ nodeId?: string; position?: Position } | null>(null); const clipboardRef = useRef(null); const historyRef = useRef<{ past: CanvasHistoryEntry[]; future: CanvasHistoryEntry[] }>({ past: [], future: [] }); const lastHistoryRef = useRef(null); const historyCommitTimerRef = useRef | null>(null); const viewportSaveTimerRef = useRef | null>(null); const applyingHistoryRef = useRef(false); const historyPausedRef = useRef(false); const didInitialCenterRef = useRef(false); const rafRef = useRef(null); const nodeDraggingRef = useRef(false); const dragRef = useRef<{ isDraggingNode: boolean; hasMoved: boolean; startX: number; startY: number; initialSelectedNodes: { id: string; x: number; y: number }[]; }>({ isDraggingNode: false, hasMoved: false, startX: 0, startY: 0, initialSelectedNodes: [], }); const config = useConfigStore((state) => state.config); const effectiveConfig = useEffectiveConfig(); const isAiConfigReady = useConfigStore((state) => state.isAiConfigReady); const openConfigDialog = useConfigStore((state) => state.openConfigDialog); const addAsset = useAssetStore((state) => state.addAsset); const cleanupAssetImages = useAssetStore((state) => state.cleanupImages); const hydrated = useCanvasStore((state) => state.hydrated); const createProject = useCanvasStore((state) => state.createProject); const openProject = useCanvasStore((state) => state.openProject); const updateProject = useCanvasStore((state) => state.updateProject); const renameProject = useCanvasStore((state) => state.renameProject); const deleteProjects = useCanvasStore((state) => state.deleteProjects); const currentProject = useCanvasStore((state) => state.projects.find((project) => project.id === projectId)); const theme = canvasThemes[useThemeStore((state) => state.theme)]; const [nodes, setNodes] = useState([]); const [connections, setConnections] = useState([]); const [chatSessions, setChatSessions] = useState([]); const [activeChatId, setActiveChatId] = useState(null); const [viewport, setViewport] = useState({ x: 0, y: 0, k: 1 }); const [size, setSize] = useState({ width: 1200, height: 720 }); const [selectedNodeIds, setSelectedNodeIds] = useState>(new Set()); const [selectedConnectionId, setSelectedConnectionId] = useState(null); const [hoveredNodeId, setHoveredNodeId] = useState(null); const [connectingParams, setConnectingParams] = useState(null); const [connectionTargetNodeId, setConnectionTargetNodeId] = useState(null); const [pendingConnectionCreate, setPendingConnectionCreate] = useState(null); const [mouseWorld, setMouseWorld] = useState({ x: 0, y: 0 }); const [selectionBox, setSelectionBox] = useState(null); const [contextMenu, setContextMenu] = useState(null); const [nodeCreatePosition, setNodeCreatePosition] = useState(null); const [runningNodeId, setRunningNodeId] = useState(null); const [isMiniMapOpen, setIsMiniMapOpen] = useState(false); const [backgroundMode, setBackgroundMode] = useState("lines"); const [showImageInfo, setShowImageInfo] = useState(false); const [clearConfirmOpen, setClearConfirmOpen] = useState(false); const [assetPickerOpen, setAssetPickerOpen] = useState(false); const [projectLoaded, setProjectLoaded] = useState(false); const [toolbarNodeId, setToolbarNodeId] = useState(null); const [nodeImageSettingsOpen, setNodeImageSettingsOpen] = useState(false); const [dialogNodeId, setDialogNodeId] = useState(null); const [editingNodeId, setEditingNodeId] = useState(null); const [editRequestNonce, setEditRequestNonce] = useState(0); const [infoNodeId, setInfoNodeId] = useState(null); const [pluginManagerOpen, setPluginManagerOpen] = useState(false); const [cropNodeId, setCropNodeId] = useState(null); const [maskEditNodeId, setMaskEditNodeId] = useState(null); const [splitNodeId, setSplitNodeId] = useState(null); const [upscaleNodeId, setUpscaleNodeId] = useState(null); const [superResolveNodeId, setSuperResolveNodeId] = useState(null); const [angleNodeId, setAngleNodeId] = useState(null); const [previewNodeId, setPreviewNodeId] = useState(null); const [titleEditing, setTitleEditing] = useState(false); const [titleDraft, setTitleDraft] = useState(""); const [historyState, setHistoryState] = useState({ canUndo: false, canRedo: false }); const [collapsingBatchIds, setCollapsingBatchIds] = useState>(new Set()); const [openingBatchIds, setOpeningBatchIds] = useState>(new Set()); const [isNodeDragging, setIsNodeDragging] = useState(false); const [dropTargetGroupId, setDropTargetGroupId] = useState(null); const nodesRef = useRef(nodes); const connectionsRef = useRef(connections); const selectedNodeIdsRef = useRef(selectedNodeIds); const viewportRef = useRef(viewport); const focusAnimRef = useRef(null); const generateNodeRef = useRef<((nodeId: string, mode: CanvasNodeGenerationMode, prompt: string) => Promise) | null>(null); const connectingParamsRef = useRef(connectingParams); const connectionTargetNodeIdRef = useRef(connectionTargetNodeId); const selectionBoxRef = useRef(selectionBox); const pendingConnectionCreateRef = useRef(pendingConnectionCreate); const generationRequestsRef = useRef(new Map()); const createHistoryEntry = useCallback( (): CanvasHistoryEntry => ({ nodes: nodesRef.current, connections: connectionsRef.current, chatSessions, activeChatId, backgroundMode, showImageInfo, }), [activeChatId, backgroundMode, chatSessions, showImageInfo], ); const cleanupCanvasFiles = useCallback( (extra?: unknown) => { cleanupAssetImages({ extra, history: historyRef.current, lastHistory: lastHistoryRef.current }); }, [cleanupAssetImages], ); const startGenerationRequest = useCallback((targetNodeId: string, originNodeId: string, runningId = originNodeId, controller = new AbortController()) => { const previous = generationRequestsRef.current.get(targetNodeId); if (previous?.controller !== controller) previous?.controller.abort(); generationRequestsRef.current.set(targetNodeId, { targetNodeId, originNodeId, runningNodeId: runningId, controller }); return controller; }, []); const finishGenerationRequest = useCallback((targetNodeId: string, controller: AbortController) => { const request = generationRequestsRef.current.get(targetNodeId); if (request?.controller === controller) generationRequestsRef.current.delete(targetNodeId); }, []); const stopGenerationByRunningId = useCallback((runningId: string) => { const affectedNodeIds = new Set(); generationRequestsRef.current.forEach((request) => { if (request.runningNodeId !== runningId) return; request.controller.abort(); generationRequestsRef.current.delete(request.targetNodeId); affectedNodeIds.add(request.targetNodeId); affectedNodeIds.add(request.originNodeId); }); setRunningNodeId((current) => (current === runningId ? null : current)); if (!affectedNodeIds.size) return; setNodes((prev) => prev.map((node) => (affectedNodeIds.has(node.id) && node.metadata?.status === NODE_STATUS_LOADING ? { ...node, metadata: { ...node.metadata, status: NODE_STATUS_IDLE, errorDetails: undefined } } : node))); }, []); const confirmStopGeneration = useCallback( (nodeId: string) => { modal.confirm({ title: "停止生成?", content: "当前生成请求会被中断,已经生成完成的内容会保留。", okText: "停止", cancelText: "继续生成", okButtonProps: { danger: true }, onOk: () => stopGenerationByRunningId(nodeId), }); }, [modal, stopGenerationByRunningId], ); useEffect(() => { if (!hydrated) return; setProjectLoaded(false); const project = openProject(projectId); if (!project) { navigate("/canvas", { replace: true }); return; } const restore = async () => { const restoredNodes = await hydrateCanvasImages(resetInterruptedGeneration(project.nodes)); const restoredSessions = await hydrateAssistantImages(project.chatSessions || []); setNodes(restoredNodes); setConnections(project.connections); setChatSessions(restoredSessions); setActiveChatId(project.activeChatId || null); setBackgroundMode(project.backgroundMode); setShowImageInfo(project.showImageInfo || false); setViewport(project.viewport); historyRef.current = { past: [], future: [] }; if (historyCommitTimerRef.current) { clearTimeout(historyCommitTimerRef.current); historyCommitTimerRef.current = null; } lastHistoryRef.current = { nodes: restoredNodes, connections: project.connections, chatSessions: restoredSessions, activeChatId: project.activeChatId || null, backgroundMode: project.backgroundMode, showImageInfo: project.showImageInfo || false, }; setHistoryState({ canUndo: false, canRedo: false }); setProjectLoaded(true); }; void restore(); }, [hydrated, navigate, openProject, projectId]); useEffect(() => { if (!projectLoaded || !["new", "recent", "choose"].includes(searchParams.get("mode") || "")) return; if (!searchParams.has("agentUrl")) openAgentPanel(); }, [openAgentPanel, projectLoaded, searchParams]); useEffect(() => { if (!projectLoaded || applyingHistoryRef.current || historyPausedRef.current) return; const next = createHistoryEntry(); const previous = lastHistoryRef.current; if ( previous?.nodes === next.nodes && previous.connections === next.connections && previous.chatSessions === next.chatSessions && previous.activeChatId === next.activeChatId && previous.backgroundMode === next.backgroundMode && previous.showImageInfo === next.showImageInfo ) return; if (historyCommitTimerRef.current) clearTimeout(historyCommitTimerRef.current); historyCommitTimerRef.current = setTimeout(() => { const current = createHistoryEntry(); const last = lastHistoryRef.current; if (!last) return; historyRef.current.past = [...historyRef.current.past.slice(-49), last]; historyRef.current.future = []; setHistoryState({ canUndo: true, canRedo: false }); lastHistoryRef.current = current; historyCommitTimerRef.current = null; }, 180); return () => { if (historyCommitTimerRef.current) { clearTimeout(historyCommitTimerRef.current); historyCommitTimerRef.current = null; } }; }, [activeChatId, backgroundMode, chatSessions, connections, createHistoryEntry, nodes, projectLoaded, showImageInfo]); useEffect(() => { if (!projectLoaded || historyPausedRef.current) return; updateProject(projectId, { nodes, connections, chatSessions, activeChatId, backgroundMode, showImageInfo }); }, [activeChatId, backgroundMode, chatSessions, connections, nodes, projectId, projectLoaded, showImageInfo, updateProject]); useEffect(() => { if (!dialogNodeId) setNodeImageSettingsOpen(false); }, [dialogNodeId]); useEffect(() => { if (!projectLoaded) return; if (viewportSaveTimerRef.current) clearTimeout(viewportSaveTimerRef.current); viewportSaveTimerRef.current = setTimeout(() => { updateProject(projectId, { viewport: viewportRef.current }); viewportSaveTimerRef.current = null; }, 500); return () => { if (viewportSaveTimerRef.current) clearTimeout(viewportSaveTimerRef.current); }; }, [projectId, projectLoaded, updateProject, viewport]); useLayoutEffect(() => { nodesRef.current = nodes; connectionsRef.current = connections; selectedNodeIdsRef.current = selectedNodeIds; viewportRef.current = viewport; connectingParamsRef.current = connectingParams; connectionTargetNodeIdRef.current = connectionTargetNodeId; pendingConnectionCreateRef.current = pendingConnectionCreate; }, [nodes, connections, selectedNodeIds, viewport, connectingParams, connectionTargetNodeId, pendingConnectionCreate]); useLayoutEffect(() => { selectionBoxRef.current = selectionBox; }, [selectionBox]); useEffect(() => { const el = containerRef.current; if (!el) return; const updateSize = () => { const rect = el.getBoundingClientRect(); setSize({ width: rect.width, height: rect.height }); if (!didInitialCenterRef.current) { didInitialCenterRef.current = true; setViewport({ x: rect.width / 2, y: rect.height / 2, k: 1 }); } }; updateSize(); const resizeObserver = new ResizeObserver(updateSize); resizeObserver.observe(el); return () => resizeObserver.disconnect(); }, []); const screenToCanvas = useCallback((clientX: number, clientY: number) => { const rect = containerRef.current?.getBoundingClientRect(); const currentViewport = viewportRef.current; const localX = clientX - (rect?.left || 0); const localY = clientY - (rect?.top || 0); return { x: (localX - currentViewport.x) / currentViewport.k, y: (localY - currentViewport.y) / currentViewport.k, }; }, []); const getCanvasCenter = useCallback(() => { const rect = containerRef.current?.getBoundingClientRect(); return screenToCanvas((rect?.left || 0) + (rect?.width || size.width) / 2, (rect?.top || 0) + (rect?.height || size.height) / 2); }, [screenToCanvas, size.height, size.width]); const setConnecting = useCallback((next: ConnectionHandle | null) => { connectingParamsRef.current = next; setConnectingParams(next); if (!next) { connectionTargetNodeIdRef.current = null; setConnectionTargetNodeId(null); } }, []); const keepNodeToolbar = useCallback( (nodeId: string) => { if (nodeDraggingRef.current || nodeImageSettingsOpen || !selectedNodeIdsRef.current.has(nodeId)) return; setToolbarNodeId(nodeId); }, [nodeImageSettingsOpen], ); const hideNodeToolbar = useCallback(() => {}, []); const connectNodes = useCallback( (current: ConnectionHandle, targetNodeId: string) => { if (current.nodeId === targetNodeId) return; const connection = normalizeConnection(current.nodeId, targetNodeId, nodesRef.current, current.handleType); if (!connection) { message.warning("配置节点之间不能连接"); return; } const { fromNodeId, toNodeId } = connection; const exists = connectionsRef.current.some((conn) => conn.fromNodeId === fromNodeId && conn.toNodeId === toNodeId); if (!exists) { setConnections((prev) => [...prev, { id: `conn-${Date.now()}`, fromNodeId, toNodeId }]); } setContextMenu(null); }, [message], ); const createConnectedNode = useCallback( (type: CanvasNodeType.Image | CanvasNodeType.Text | CanvasNodeType.Config | CanvasNodeType.Video | CanvasNodeType.Audio, pending: PendingConnectionCreate) => { const metadata = type === CanvasNodeType.Config ? { model: effectiveConfig.imageModel || effectiveConfig.model, size: effectiveConfig.size, count: getGenerationCount(effectiveConfig.canvasImageCount || effectiveConfig.count) } : undefined; const newNode = createCanvasNode(type, pending.position, metadata); const connection = normalizeConnection(pending.connection.nodeId, newNode.id, [...nodesRef.current, newNode], pending.connection.handleType); if (!connection) { message.warning("配置节点之间不能连接"); return; } setNodes((prev) => [...prev, newNode]); setConnections((prev) => [...prev, { id: nanoid(), ...connection }]); setSelectedNodeIds(new Set([newNode.id])); setSelectedConnectionId(null); if (type !== CanvasNodeType.Text && type !== CanvasNodeType.Audio) setDialogNodeId(newNode.id); setPendingConnectionCreate(null); setConnecting(null); }, [effectiveConfig.canvasImageCount, effectiveConfig.count, effectiveConfig.imageModel, effectiveConfig.model, effectiveConfig.size, message, setConnecting], ); const cancelPendingConnectionCreate = useCallback(() => { setPendingConnectionCreate(null); setConnecting(null); }, [setConnecting]); const getConnectionDropTarget = useCallback( (clientX: number, clientY: number, current: ConnectionHandle): ConnectionDropTarget => { const world = screenToCanvas(clientX, clientY); const scale = Math.max(viewportRef.current.k, 0.05); const padding = CONNECTION_NODE_HIT_PADDING / scale; const handleRadius = CONNECTION_HANDLE_HIT_RADIUS / scale; let isNearNode = false; let bestNodeId: string | null = null; let bestPriority = Number.POSITIVE_INFINITY; [...nodesRef.current] .filter((node) => !isHiddenBatchChild(node, nodesRef.current)) .reverse() .forEach((node) => { const anchor = getConnectionTargetAnchor(node, current); const dx = world.x - anchor.x; const dy = world.y - anchor.y; const hitsHandle = dx * dx + dy * dy <= handleRadius * handleRadius; const hitsInside = world.x >= node.position.x && world.x <= node.position.x + node.width && world.y >= node.position.y && world.y <= node.position.y + node.height; const hitsExpanded = world.x >= node.position.x - padding && world.x <= node.position.x + node.width + padding && world.y >= node.position.y - padding && world.y <= node.position.y + node.height + padding; if (!hitsHandle && !hitsInside && !hitsExpanded) return; isNearNode = true; if (node.id === current.nodeId || !normalizeConnection(current.nodeId, node.id, nodesRef.current, current.handleType)) return; const priority = hitsInside ? 0 : hitsHandle ? 1 : 2; if (priority < bestPriority) { bestNodeId = node.id; bestPriority = priority; } }); return { nodeId: bestNodeId, isNearNode }; }, [screenToCanvas], ); const visibleNodes = useMemo(() => { const padding = 280; const rect = containerRef.current?.getBoundingClientRect(); const width = rect?.width || size.width; const height = rect?.height || size.height; const viewLeft = -viewport.x / viewport.k - padding; const viewTop = -viewport.y / viewport.k - padding; const viewRight = viewLeft + width / viewport.k + padding * 2; const viewBottom = viewTop + height / viewport.k + padding * 2; return nodes.filter((node) => !isHiddenBatchChild(node, nodes, collapsingBatchIds) && node.position.x + node.width > viewLeft && node.position.x < viewRight && node.position.y + node.height > viewTop && node.position.y < viewBottom); }, [collapsingBatchIds, nodes, size.height, size.width, viewport.k, viewport.x, viewport.y]); const nodeById = useMemo(() => new Map(nodes.map((node) => [node.id, node])), [nodes]); // 工具条跟随「单选节点」:点击/新建/框选/键盘选中任一节点都会显示,不再仅靠精确点中触发。 // 多选时不显示;拖拽中由下方 isNodeDragging 守卫隐藏。 const singleSelectedNodeId = selectedNodeIds.size === 1 ? Array.from(selectedNodeIds)[0] : null; const toolbarNode = (toolbarNodeId ? nodeById.get(toolbarNodeId) || null : null) || (singleSelectedNodeId ? nodeById.get(singleSelectedNodeId) || null : null); const infoNode = infoNodeId ? nodeById.get(infoNodeId) || null : null; const cropNode = cropNodeId ? nodeById.get(cropNodeId) || null : null; const maskEditNode = maskEditNodeId ? nodeById.get(maskEditNodeId) || null : null; const splitNode = splitNodeId ? nodeById.get(splitNodeId) || null : null; const upscaleNode = upscaleNodeId ? nodeById.get(upscaleNodeId) || null : null; const superResolveNode = superResolveNodeId ? nodeById.get(superResolveNodeId) || null : null; const angleNode = angleNodeId ? nodeById.get(angleNodeId) || null : null; const previewNode = previewNodeId ? nodeById.get(previewNodeId) || null : null; const hasMultipleSelectedNodes = selectedNodeIds.size > 1; const activeNodeId = hasMultipleSelectedNodes ? null : hoveredNodeId || (selectedNodeIds.size === 1 ? Array.from(selectedNodeIds)[0] : null); const batchChildCountById = useMemo(() => { const map = new Map(); nodes.forEach((node) => { if (node.metadata?.isBatchRoot) map.set(node.id, node.metadata.batchChildIds?.length || 0); }); return map; }, [nodes]); const groupChildCountById = useMemo(() => { const map = new Map(); nodes.forEach((node) => { const groupId = node.metadata?.groupId; if (groupId) map.set(groupId, (map.get(groupId) || 0) + 1); }); return map; }, [nodes]); const batchMotionById = useMemo(() => { const map = new Map(); nodes.forEach((node) => { const rootId = node.metadata?.batchRootId; if (!rootId) return; const root = nodeById.get(rootId); const index = root?.metadata?.batchChildIds?.indexOf(node.id) ?? 0; const stackX = root ? root.position.x + 34 + index * 14 : node.position.x; const stackY = root ? root.position.y + 14 + index * 8 : node.position.y; map.set(node.id, { x: stackX - node.position.x, y: stackY - node.position.y, index: Math.max(index, 0) }); }); return map; }, [nodeById, nodes]); const relatedHighlight = useMemo(() => { const nodeIds = new Set(); const connectionIds = new Set(); if (!activeNodeId) return { nodeIds, connectionIds }; nodeIds.add(activeNodeId); connections.forEach((connection) => { if (connection.fromNodeId !== activeNodeId && connection.toNodeId !== activeNodeId) return; connectionIds.add(connection.id); nodeIds.add(connection.fromNodeId); nodeIds.add(connection.toNodeId); }); return { nodeIds, connectionIds }; }, [activeNodeId, connections]); const configInputsById = useMemo(() => { const map = new Map(); nodes.forEach((node) => { if (node.type !== CanvasNodeType.Config) return; map.set(node.id, buildNodeGenerationInputs(node.id, nodes, connections)); }); return map; }, [connections, nodes]); const mentionReferencesByNodeId = useMemo(() => { const map = new Map>(); nodes.forEach((node) => map.set(node.id, buildNodeMentionReferences(node, nodes, connections))); return map; }, [connections, nodes]); const { applyAgentOps } = useAgentBridge({ projectId, title: currentProject?.title, nodes, connections, selectedNodeIds, viewport, nodesRef, connectionsRef, selectedNodeIdsRef, viewportRef, generateNodeRef, setNodes, setConnections, setSelectedNodeIds, setSelectedConnectionId, setViewport, setContextMenu, }); const { pluginHost, renderPluginPanel, buildNodeToolbarItems } = usePluginHost({ effectiveConfig, isAiConfigReady, openConfigDialog, theme, nodesRef, connectionsRef, viewportRef, setNodes, setDialogNodeId, applyAgentOps, }); const createNode = useCallback( (type: CanvasNodeTypeId, position?: Position) => { const targetPosition = position || getCanvasCenter(); const configMetadata = type === CanvasNodeType.Config ? { model: effectiveConfig.imageModel || effectiveConfig.model, size: effectiveConfig.size, count: getGenerationCount(effectiveConfig.canvasImageCount || effectiveConfig.count), } : undefined; const newNode = createCanvasNode(type, targetPosition, configMetadata); setNodes((prev) => [...prev, newNode]); setSelectedNodeIds(new Set([newNode.id])); setSelectedConnectionId(null); const definition = getNodeDefinition(type); // 纯展示型插件节点(hidePanel)不弹面板;插件自定义 Panel 需显式 autoOpenPanel 才在新建时打开; // 声明了 useBuiltinPanel 的插件节点复用内置生成面板,新建即打开(与图片节点一致); // 内置的图片/视频/配置类节点保持原有「新建即打开生图面板」行为。 const wantsPanel = definition?.hidePanel ? false : definition?.Panel ? Boolean(definition.autoOpenPanel) : definition?.useBuiltinPanel ? true : isBuiltinType(type) && type !== CanvasNodeType.Text && type !== CanvasNodeType.Audio && type !== CanvasNodeType.Group; if (wantsPanel) setDialogNodeId(newNode.id); }, [effectiveConfig.canvasImageCount, effectiveConfig.count, effectiveConfig.imageModel, effectiveConfig.model, effectiveConfig.size, getCanvasCenter], ); const deleteNodes = useCallback( (ids: Set) => { if (!ids.size) return; const allIds = new Set(ids); nodesRef.current.forEach((node) => { if (ids.has(node.id)) node.metadata?.batchChildIds?.forEach((childId) => allIds.add(childId)); }); setNodes((prev) => { const next = prev.filter((node) => !allIds.has(node.id)); return next.map((node) => { const groupId = node.metadata?.groupId; if (groupId && allIds.has(groupId)) return { ...node, metadata: { ...node.metadata, groupId: undefined } }; const childIds = node.metadata?.batchChildIds?.filter((childId) => !allIds.has(childId)); if (!node.metadata?.isBatchRoot || childIds?.length === node.metadata.batchChildIds?.length) return node; const primaryImageId = childIds?.includes(node.metadata.primaryImageId || "") ? node.metadata.primaryImageId : childIds?.[0]; const primaryNode = next.find((item) => item.id === primaryImageId); return { ...node, metadata: { ...node.metadata, batchChildIds: childIds, primaryImageId, content: primaryNode?.metadata?.content || node.metadata.content, naturalWidth: primaryNode?.metadata?.naturalWidth || node.metadata.naturalWidth, naturalHeight: primaryNode?.metadata?.naturalHeight || node.metadata.naturalHeight, }, }; }); }); setConnections((prev) => prev.filter((conn) => !allIds.has(conn.fromNodeId) && !allIds.has(conn.toNodeId))); setSelectedNodeIds(new Set()); setSelectedConnectionId(null); setHoveredNodeId((current) => (current && allIds.has(current) ? null : current)); setToolbarNodeId((current) => (current && allIds.has(current) ? null : current)); setDialogNodeId((current) => (current && allIds.has(current) ? null : current)); setEditingNodeId((current) => (current && allIds.has(current) ? null : current)); setInfoNodeId((current) => (current && allIds.has(current) ? null : current)); setCropNodeId((current) => (current && allIds.has(current) ? null : current)); setMaskEditNodeId((current) => (current && allIds.has(current) ? null : current)); setAngleNodeId((current) => (current && allIds.has(current) ? null : current)); setPreviewNodeId((current) => (current && allIds.has(current) ? null : current)); setRunningNodeId((current) => (current && allIds.has(current) ? null : current)); setContextMenu((current) => (current?.type === "node" && allIds.has(current.nodeId) ? null : current)); cleanupCanvasFiles({ projectId, nodes: nodesRef.current.filter((node) => !allIds.has(node.id)), chatSessions }); }, [chatSessions, cleanupCanvasFiles, projectId], ); const deleteConnection = useCallback((connectionId: string) => { setConnections((prev) => prev.filter((conn) => conn.id !== connectionId)); setSelectedConnectionId((current) => (current === connectionId ? null : current)); setContextMenu((current) => (current?.type === "connection" && current.connectionId === connectionId ? null : current)); }, []); const deselectCanvas = useCallback(() => { cancelPendingConnectionCreate(); setSelectedNodeIds(new Set()); setSelectedConnectionId(null); setContextMenu(null); setSelectionBox(null); setHoveredNodeId(null); setToolbarNodeId(null); setDialogNodeId(null); setEditingNodeId(null); }, [cancelPendingConnectionCreate]); const clearCanvas = useCallback(() => { setNodes([]); setConnections([]); setInfoNodeId(null); setCropNodeId(null); setMaskEditNodeId(null); setAngleNodeId(null); setPreviewNodeId(null); setRunningNodeId(null); deselectCanvas(); setClearConfirmOpen(false); cleanupCanvasFiles({ projectId, nodes: [], chatSessions: [] }); }, [cleanupCanvasFiles, deselectCanvas, projectId]); const duplicateNode = useCallback((nodeId: string) => { const source = nodesRef.current.find((node) => node.id === nodeId); if (!source) return; const id = `${source.type}-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`; const next: CanvasNodeData = { ...source, id, title: `${source.title} Copy`, position: { x: source.position.x + 36, y: source.position.y + 36 }, }; setNodes((prev) => [...prev, next]); setSelectedNodeIds(new Set([id])); setSelectedConnectionId(null); if (next.type !== CanvasNodeType.Group) setDialogNodeId(id); }, []); const copySelectedNodes = useCallback(() => { const selectedIds = selectedNodeIdsRef.current; if (!selectedIds.size) return; const copiedNodes = nodesRef.current .filter((node) => selectedIds.has(node.id)) .map((node) => ({ ...node, position: { ...node.position }, metadata: node.metadata ? { ...node.metadata } : undefined, })); if (!copiedNodes.length) return; clipboardRef.current = { nodes: copiedNodes, connections: connectionsRef.current.filter((connection) => selectedIds.has(connection.fromNodeId) && selectedIds.has(connection.toNodeId)).map((connection) => ({ ...connection })), }; }, []); const pasteCopiedNodes = useCallback(() => { const clipboard = clipboardRef.current; if (!clipboard?.nodes.length) return false; const center = getCanvasCenter(); const bounds = clipboard.nodes.reduce( (acc, node) => ({ left: Math.min(acc.left, node.position.x), top: Math.min(acc.top, node.position.y), right: Math.max(acc.right, node.position.x + node.width), bottom: Math.max(acc.bottom, node.position.y + node.height), }), { left: Infinity, top: Infinity, right: -Infinity, bottom: -Infinity }, ); const dx = center.x - (bounds.left + bounds.right) / 2; const dy = center.y - (bounds.top + bounds.bottom) / 2; const idMap = new Map(); const nextNodes = clipboard.nodes.map((node, index) => { const id = `${node.type}-${Date.now()}-${index}-${Math.random().toString(36).slice(2, 7)}`; idMap.set(node.id, id); return { ...node, id, title: node.title.endsWith(" Copy") ? node.title : `${node.title} Copy`, position: { x: node.position.x + dx, y: node.position.y + dy, }, metadata: node.metadata ? { ...node.metadata } : undefined, }; }); const pastedNodes = nextNodes.map((node) => { const groupId = node.metadata?.groupId; if (!groupId) return node; return { ...node, metadata: { ...node.metadata, groupId: idMap.get(groupId) } }; }); const nextConnections = clipboard.connections.flatMap((connection, index) => { const fromNodeId = idMap.get(connection.fromNodeId); const toNodeId = idMap.get(connection.toNodeId); if (!fromNodeId || !toNodeId) return []; return [ { ...connection, id: `conn-${Date.now()}-${index}-${Math.random().toString(36).slice(2, 7)}`, fromNodeId, toNodeId, }, ]; }); setNodes((prev) => [...prev, ...pastedNodes]); setConnections((prev) => [...prev, ...nextConnections]); setSelectedNodeIds(new Set(pastedNodes.map((node) => node.id))); setSelectedConnectionId(null); setContextMenu(null); setDialogNodeId(pastedNodes[0]?.type === CanvasNodeType.Group ? null : pastedNodes[0]?.id || null); return true; }, [getCanvasCenter]); const resetViewport = useCallback(() => { setViewport({ x: size.width / 2, y: size.height / 2, k: 1 }); setContextMenu(null); }, [size.height, size.width]); const focusNode = useCallback( (nodeId: string) => { const node = nodesRef.current.find((item) => item.id === nodeId); if (!node) return; const worldX = node.position.x + node.width / 2; const worldY = node.position.y + node.height / 2; const k = Math.min(Math.max(Math.min((size.width * 0.6) / node.width, (size.height * 0.6) / node.height), 0.05), 1.5); const target = { x: size.width / 2 - worldX * k, y: size.height / 2 - worldY * k, k }; setSelectedNodeIds(new Set([nodeId])); setSelectedConnectionId(null); setContextMenu(null); if (focusAnimRef.current) cancelAnimationFrame(focusAnimRef.current); const start = { ...viewportRef.current }; const duration = 450; const easeOutCubic = (t: number) => 1 - Math.pow(1 - t, 3); let startTime: number | null = null; const step = (now: number) => { if (startTime === null) startTime = now; const progress = Math.min((now - startTime) / duration, 1); const t = easeOutCubic(progress); setViewport({ x: start.x + (target.x - start.x) * t, y: start.y + (target.y - start.y) * t, k: start.k + (target.k - start.k) * t }); focusAnimRef.current = progress < 1 ? requestAnimationFrame(step) : null; }; focusAnimRef.current = requestAnimationFrame(step); }, [size.height, size.width], ); useEffect(() => () => void (focusAnimRef.current && cancelAnimationFrame(focusAnimRef.current)), []); const setZoomScale = useCallback( (scale: number) => { const nextScale = Math.min(Math.max(scale, 0.05), 5); setViewport((prev) => ({ x: size.width / 2 - ((size.width / 2 - prev.x) / prev.k) * nextScale, y: size.height / 2 - ((size.height / 2 - prev.y) / prev.k) * nextScale, k: nextScale, })); setContextMenu(null); }, [size.height, size.width], ); const applyHistory = useCallback((entry: CanvasHistoryEntry) => { if (historyCommitTimerRef.current) { clearTimeout(historyCommitTimerRef.current); historyCommitTimerRef.current = null; } applyingHistoryRef.current = true; setNodes(entry.nodes); setConnections(entry.connections); setChatSessions(entry.chatSessions); setActiveChatId(entry.activeChatId); setBackgroundMode(entry.backgroundMode); setShowImageInfo(entry.showImageInfo); setSelectedNodeIds(new Set()); setSelectedConnectionId(null); setContextMenu(null); setTimeout(() => { lastHistoryRef.current = entry; applyingHistoryRef.current = false; setHistoryState({ canUndo: historyRef.current.past.length > 0, canRedo: historyRef.current.future.length > 0 }); }); }, []); const undoCanvas = useCallback(() => { const previous = historyRef.current.past.pop(); const current = lastHistoryRef.current; if (!previous || !current) return; historyRef.current.future.push(current); applyHistory(previous); }, [applyHistory]); const redoCanvas = useCallback(() => { const next = historyRef.current.future.pop(); const current = lastHistoryRef.current; if (!next || !current) return; historyRef.current.past.push(current); applyHistory(next); }, [applyHistory]); const createAndOpenProject = useCallback(() => { const id = createProject(`无限画布 ${useCanvasStore.getState().projects.length + 1}`); navigate(`/canvas/${id}`); }, [createProject, navigate]); const deleteCurrentProject = useCallback(() => { deleteProjects([projectId]); cleanupAssetImages(); navigate("/canvas"); }, [cleanupAssetImages, deleteProjects, navigate, projectId]); const exportCurrentProject = useCallback(async () => { const project = useCanvasStore.getState().projects.find((item) => item.id === projectId); if (!project) return message.error("未找到当前画布"); const hide = message.loading("正在导出当前画布…", 0); try { await exportCanvasProjects([project], project.title || "无限画布"); message.success("已导出当前画布"); } catch (error) { console.error(error); message.error("导出失败,请重试"); } finally { hide(); } }, [message, projectId]); const handleCanvasMouseDown = useCallback( (event: ReactPointerEvent) => { setContextMenu(null); setNodeCreatePosition(null); if (pendingConnectionCreateRef.current) cancelPendingConnectionCreate(); if (event.button !== 0) return; if (!event.ctrlKey && !event.metaKey) { setSelectionBox(null); setSelectedNodeIds(new Set()); setSelectedConnectionId(null); return; } const world = screenToCanvas(event.clientX, event.clientY); const nextSelectionBox = { startWorldX: world.x, startWorldY: world.y, currentWorldX: world.x, currentWorldY: world.y, additive: event.shiftKey, initialSelectedNodeIds: event.shiftKey ? Array.from(selectedNodeIdsRef.current) : [], }; selectionBoxRef.current = nextSelectionBox; setSelectionBox(nextSelectionBox); if (!event.shiftKey) { setSelectedNodeIds(new Set()); } setSelectedConnectionId(null); }, [cancelPendingConnectionCreate, screenToCanvas], ); // 仅处理「选中」的纯逻辑,供 body 冒泡拖拽入口与外层 capture 入口共用。 // 返回本次点击后的单选目标 id(多选/取消时为 null),用于同步工具条。 const selectNodeByEvent = useCallback((event: Pick, nodeId: string) => { const nextSelected = new Set(selectedNodeIdsRef.current); if (event.shiftKey || event.metaKey || event.ctrlKey) { if (nextSelected.has(nodeId)) nextSelected.delete(nodeId); else nextSelected.add(nodeId); } else if (!nextSelected.has(nodeId)) { nextSelected.clear(); nextSelected.add(nodeId); } setSelectedNodeIds(nextSelected); const soloId = nextSelected.size === 1 && nextSelected.has(nodeId) ? nodeId : null; setToolbarNodeId(soloId); return { nextSelected, soloId }; }, []); // capture 阶段选中:点击节点内部任意元素(含吞掉 mousedown 的 textarea/iframe)都能选中并弹出工具条。 // 只做选中,不启动拖拽 —— 拖拽仍由 body 的 onMouseDown(冒泡)负责,故编辑器内选词不会拖动节点。 // capture 必先于同一次事件的 body 冒泡触发,故把算好的选中集暂存,供紧随其后的拖拽入口复用,避免二次选中(shift 反选被抵消)。 const pendingSelectionRef = useRef | null>(null); const handleNodeSelectCapture = useCallback( (event: ReactMouseEvent, nodeId: string) => { if (event.button !== 0) return; setContextMenu(null); setHoveredNodeId(null); setSelectedConnectionId(null); const { nextSelected } = selectNodeByEvent(event, nodeId); pendingSelectionRef.current = nextSelected; }, [selectNodeByEvent], ); const handleNodeMouseDown = useCallback((event: ReactMouseEvent, nodeId: string) => { event.stopPropagation(); // 选中已由 capture 阶段完成;这里只负责建立拖拽。若因故没走 capture,则兜底再选一次。 const currentNodes = nodesRef.current; const nextSelected = pendingSelectionRef.current ?? selectNodeByEvent(event, nodeId).nextSelected; pendingSelectionRef.current = null; const dragIds = new Set(nextSelected); currentNodes.forEach((node) => { if (!nextSelected.has(node.id)) return; node.metadata?.batchChildIds?.forEach((childId) => dragIds.add(childId)); if (node.type === CanvasNodeType.Group) { currentNodes.forEach((child) => { if (child.metadata?.groupId === node.id) dragIds.add(child.id); }); } }); dragRef.current = { isDraggingNode: true, hasMoved: false, startX: event.clientX, startY: event.clientY, initialSelectedNodes: currentNodes.filter((node) => dragIds.has(node.id)).map((node) => ({ id: node.id, x: node.position.x, y: node.position.y })), }; historyPausedRef.current = true; nodeDraggingRef.current = true; setIsNodeDragging(true); }, []); const finishNodeDrag = useCallback((clientX?: number, clientY?: number) => { if (rafRef.current) { cancelAnimationFrame(rafRef.current); rafRef.current = null; } if (!dragRef.current.isDraggingNode) return; const wasClick = !dragRef.current.hasMoved && dragRef.current.initialSelectedNodes.length === 1; const clickedNodeId = dragRef.current.initialSelectedNodes[0]?.id; const currentViewport = viewportRef.current; const dx = clientX == null ? 0 : (clientX - dragRef.current.startX) / currentViewport.k; const dy = clientY == null ? 0 : (clientY - dragRef.current.startY) / currentViewport.k; const initialPositions = dragRef.current.initialSelectedNodes; historyPausedRef.current = false; nodeDraggingRef.current = false; setIsNodeDragging(false); setDropTargetGroupId(null); if (dragRef.current.hasMoved && clientX != null && clientY != null) { const movedIds = new Set(initialPositions.map((item) => item.id)); setNodes((prev) => { const moved = prev.map((node) => { const initial = initialPositions.find((item) => item.id === node.id); return initial ? { ...node, position: { x: initial.x + dx, y: initial.y + dy } } : node; }); const targetGroup = findGroupDropTarget(movedIds, moved); if (targetGroup) return snapNodesIntoGroup(movedIds, moved, targetGroup); return moved.map((node) => { if (!movedIds.has(node.id) || node.type === CanvasNodeType.Group) return node; const groupId = findContainingGroupId(node, moved); if (node.metadata?.groupId === groupId) return node; return { ...node, metadata: { ...node.metadata, groupId } }; }); }); } dragRef.current.isDraggingNode = false; dragRef.current.hasMoved = false; dragRef.current.initialSelectedNodes = []; if (wasClick && clickedNodeId) { const clickedNode = nodesRef.current.find((node) => node.id === clickedNodeId); const clickedDefinition = clickedNode ? getNodeDefinition(clickedNode.type) : undefined; if (clickedNode?.type === CanvasNodeType.Text) { setDialogNodeId((current) => (current === clickedNodeId ? current : null)); } else if (clickedDefinition?.hidePanel) { // 纯展示型插件节点:单击只选中,不弹下方面板 setDialogNodeId((current) => (current === clickedNodeId ? current : null)); } else if (clickedNode?.type !== CanvasNodeType.Group) { setDialogNodeId(clickedNodeId); } } }, []); const handleGlobalMouseMove = useCallback( (event: MouseEvent) => { const currentViewport = viewportRef.current; if (dragRef.current.isDraggingNode) { const dx = (event.clientX - dragRef.current.startX) / currentViewport.k; const dy = (event.clientY - dragRef.current.startY) / currentViewport.k; const initialPositions = dragRef.current.initialSelectedNodes; if (Math.abs(event.clientX - dragRef.current.startX) > 3 || Math.abs(event.clientY - dragRef.current.startY) > 3) { dragRef.current.hasMoved = true; } const movedIds = new Set(initialPositions.map((item) => item.id)); const previewNodes = nodesRef.current.map((node) => { const initial = initialPositions.find((item) => item.id === node.id); return initial ? { ...node, position: { x: initial.x + dx, y: initial.y + dy } } : node; }); setDropTargetGroupId(findGroupDropTarget(movedIds, previewNodes)?.id || null); if (rafRef.current) cancelAnimationFrame(rafRef.current); rafRef.current = requestAnimationFrame(() => { setNodes((prev) => prev.map((node) => { const initial = initialPositions.find((item) => item.id === node.id); return initial ? { ...node, position: { x: initial.x + dx, y: initial.y + dy } } : node; }), ); rafRef.current = null; }); return; } if (connectingParamsRef.current && !pendingConnectionCreateRef.current) { const dropTarget = getConnectionDropTarget(event.clientX, event.clientY, connectingParamsRef.current); connectionTargetNodeIdRef.current = dropTarget.nodeId; setConnectionTargetNodeId(dropTarget.nodeId); setMouseWorld(screenToCanvas(event.clientX, event.clientY)); } }, [finishNodeDrag, getConnectionDropTarget, screenToCanvas], ); const handleGlobalPointerMove = useCallback( (event: PointerEvent) => { const currentSelection = selectionBoxRef.current; if (!currentSelection) return; if (event.buttons === 0) { selectionBoxRef.current = null; setSelectionBox(null); return; } const world = screenToCanvas(event.clientX, event.clientY); const rectX = Math.min(currentSelection.startWorldX, world.x); const rectY = Math.min(currentSelection.startWorldY, world.y); const rectW = Math.abs(world.x - currentSelection.startWorldX); const rectH = Math.abs(world.y - currentSelection.startWorldY); const nextSelected = new Set(currentSelection.additive ? currentSelection.initialSelectedNodeIds : []); nodesRef.current .filter((node) => !isHiddenBatchChild(node, nodesRef.current)) .forEach((node) => { const intersects = rectX < node.position.x + node.width && rectX + rectW > node.position.x && rectY < node.position.y + node.height && rectY + rectH > node.position.y; if (intersects) nextSelected.add(node.id); }); const nextSelectionBox = { ...currentSelection, currentWorldX: world.x, currentWorldY: world.y }; selectionBoxRef.current = nextSelectionBox; setSelectionBox(nextSelectionBox); setSelectedNodeIds(nextSelected); }, [screenToCanvas], ); const handleGlobalMouseUp = useCallback( (event: MouseEvent) => { finishNodeDrag(event.clientX, event.clientY); selectionBoxRef.current = null; setSelectionBox(null); if (pendingConnectionCreateRef.current) return; const currentConnection = connectingParamsRef.current; if (currentConnection) { const dropTarget = getConnectionDropTarget(event.clientX, event.clientY, currentConnection); if (dropTarget.nodeId) { connectNodes(currentConnection, dropTarget.nodeId); setConnecting(null); } else if (dropTarget.isNearNode) { setConnecting(null); } else { setMouseWorld(screenToCanvas(event.clientX, event.clientY)); setPendingConnectionCreate({ connection: currentConnection, position: screenToCanvas(event.clientX, event.clientY) }); } } }, [connectNodes, finishNodeDrag, getConnectionDropTarget, screenToCanvas, setConnecting], ); useEffect(() => { const handlePointerUp = (event: PointerEvent) => finishNodeDrag(event.clientX, event.clientY); const cancelNodeDrag = () => finishNodeDrag(); window.addEventListener("mousemove", handleGlobalMouseMove); window.addEventListener("mouseup", handleGlobalMouseUp); window.addEventListener("pointerup", handlePointerUp); window.addEventListener("pointercancel", cancelNodeDrag); window.addEventListener("blur", cancelNodeDrag); window.addEventListener("pointermove", handleGlobalPointerMove); return () => { window.removeEventListener("mousemove", handleGlobalMouseMove); window.removeEventListener("mouseup", handleGlobalMouseUp); window.removeEventListener("pointerup", handlePointerUp); window.removeEventListener("pointercancel", cancelNodeDrag); window.removeEventListener("blur", cancelNodeDrag); window.removeEventListener("pointermove", handleGlobalPointerMove); }; }, [finishNodeDrag, handleGlobalMouseMove, handleGlobalMouseUp, handleGlobalPointerMove]); const createImageFileNode = useCallback(async (file: File, position: Position) => { const image = await uploadImage(file); const size = fitNodeSize(image.width, image.height); const id = `image-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`; const newNode: CanvasNodeData = { id, type: CanvasNodeType.Image, title: file.name, position: { x: position.x - size.width / 2, y: position.y - size.height / 2 }, width: size.width, height: size.height, metadata: imageMetadata(image), }; setNodes((prev) => [...prev, newNode]); setSelectedNodeIds(new Set([id])); setSelectedConnectionId(null); setDialogNodeId(id); }, []); const createVideoFileNode = useCallback(async (file: File, position: Position) => { const video = await uploadMediaFile(file, "video"); const size = fitNodeSize(video.width || 1280, video.height || 720, VIDEO_NODE_MAX_WIDTH, VIDEO_NODE_MAX_HEIGHT); const id = `video-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`; setNodes((prev) => [ ...prev, { id, type: CanvasNodeType.Video, title: file.name, position: { x: position.x - size.width / 2, y: position.y - size.height / 2 }, width: size.width, height: size.height, metadata: videoMetadata(video), }, ]); setSelectedNodeIds(new Set([id])); setSelectedConnectionId(null); setDialogNodeId(id); }, []); const createAudioFileNode = useCallback(async (file: File, position: Position) => { const audio = await uploadMediaFile(file, "audio"); const spec = NODE_DEFAULT_SIZE[CanvasNodeType.Audio]; const id = `audio-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`; setNodes((prev) => [ ...prev, { id, type: CanvasNodeType.Audio, title: file.name, position: { x: position.x - spec.width / 2, y: position.y - spec.height / 2 }, width: spec.width, height: spec.height, metadata: audioMetadata(audio), }, ]); setSelectedNodeIds(new Set([id])); setSelectedConnectionId(null); }, []); const createTextNodeFromClipboard = useCallback( (text: string) => { const trimmed = text.trim(); if (!trimmed) return false; const node = { ...createCanvasNode(CanvasNodeType.Text, getCanvasCenter(), { content: trimmed, status: NODE_STATUS_SUCCESS }), title: trimmed.slice(0, 32) || "剪切板文本", }; setNodes((prev) => [...prev, node]); setSelectedNodeIds(new Set([node.id])); setSelectedConnectionId(null); setContextMenu(null); setDialogNodeId(node.id); return true; }, [getCanvasCenter], ); const pasteSystemClipboard = useCallback(async () => { if (!navigator.clipboard) return; const items = await navigator.clipboard.read(); const imageItem = items.find((item) => item.types.some((type) => type.startsWith("image/"))); if (imageItem) { const imageType = imageItem.types.find((type) => type.startsWith("image/")); if (!imageType) return; const blob = await imageItem.getType(imageType); const file = new File([blob], "clipboard-image.png", { type: imageType }); void createImageFileNode(file, getCanvasCenter()); message.success("已从剪切板添加图片"); return; } const text = await navigator.clipboard.readText(); if (createTextNodeFromClipboard(text)) message.success("已从剪切板添加文本"); }, [createImageFileNode, createTextNodeFromClipboard, getCanvasCenter, message]); useEffect(() => { const handleKeyDown = (event: KeyboardEvent) => { const target = event.target instanceof Element ? event.target : null; if (event.target instanceof HTMLInputElement || event.target instanceof HTMLTextAreaElement || event.target instanceof HTMLSelectElement || target?.closest("[contenteditable='true'],[data-canvas-no-zoom]")) return; const key = event.key.toLowerCase(); const isModifierShortcut = event.metaKey || event.ctrlKey; if (isModifierShortcut && !event.altKey && key === "z") { event.preventDefault(); if (event.shiftKey) redoCanvas(); else undoCanvas(); return; } if (isModifierShortcut && !event.altKey && key === "y") { event.preventDefault(); redoCanvas(); return; } if (isModifierShortcut && !event.altKey && key === "a") { event.preventDefault(); setSelectedNodeIds(new Set(nodesRef.current.map((node) => node.id))); setSelectedConnectionId(null); setContextMenu(null); setSelectionBox(null); return; } if (isModifierShortcut && !event.altKey && key === "c") { event.preventDefault(); copySelectedNodes(); return; } if (isModifierShortcut && !event.altKey && key === "v") { event.preventDefault(); if (!pasteCopiedNodes()) void pasteSystemClipboard(); return; } if (event.key === "Delete" || event.key === "Backspace") { if (selectedNodeIdsRef.current.size) { deleteNodes(new Set(selectedNodeIdsRef.current)); } else if (selectedConnectionId) { deleteConnection(selectedConnectionId); } } if (event.key === "Escape") { setSelectedNodeIds(new Set()); setSelectedConnectionId(null); setContextMenu(null); setNodeCreatePosition(null); setSelectionBox(null); setConnecting(null); setHoveredNodeId(null); setToolbarNodeId(null); setDialogNodeId(null); setEditingNodeId(null); setInfoNodeId(null); setCropNodeId(null); setMaskEditNodeId(null); setPendingConnectionCreate(null); } }; window.addEventListener("keydown", handleKeyDown); return () => window.removeEventListener("keydown", handleKeyDown); }, [copySelectedNodes, deleteConnection, deleteNodes, pasteCopiedNodes, pasteSystemClipboard, redoCanvas, selectedConnectionId, setConnecting, undoCanvas]); const handleConnectStart = useCallback( (event: ReactMouseEvent, nodeId: string, handleType: "source" | "target") => { event.stopPropagation(); setMouseWorld(screenToCanvas(event.clientX, event.clientY)); setConnecting({ nodeId, handleType }); connectionTargetNodeIdRef.current = null; setConnectionTargetNodeId(null); setSelectedConnectionId(null); }, [screenToCanvas, setConnecting], ); const handleNodeResize = useCallback((nodeId: string, width: number, height: number, position?: Position) => { setNodes((prev) => prev.map((node) => (node.id === nodeId ? { ...node, width, height, position: position || node.position } : node))); }, []); const toggleNodeFreeResize = useCallback((nodeId: string) => { setNodes((prev) => prev.map((node) => { if (node.id !== nodeId) return node; const freeResize = !node.metadata?.freeResize; if (freeResize || node.type !== CanvasNodeType.Image) return { ...node, metadata: { ...node.metadata, freeResize } }; const ratio = (node.metadata?.naturalWidth || node.width) / (node.metadata?.naturalHeight || node.height || 1); const height = node.width / ratio; return { ...node, height, position: { x: node.position.x, y: node.position.y + node.height / 2 - height / 2 }, metadata: { ...node.metadata, freeResize } }; }), ); }, []); const handleNodeContentChange = useCallback((nodeId: string, content: string) => { setNodes((prev) => prev.map((node) => (node.id === nodeId ? { ...node, metadata: { ...node.metadata, content } } : node))); }, []); const handleNodeTitleChange = useCallback((nodeId: string, title: string) => { setNodes((prev) => prev.map((node) => (node.id === nodeId ? { ...node, title } : node))); }, []); const toggleBatchExpanded = useCallback((nodeId: string) => { const isExpanded = Boolean(nodesRef.current.find((node) => node.id === nodeId)?.metadata?.imageBatchExpanded); if (isExpanded) { setCollapsingBatchIds((prev) => new Set(prev).add(nodeId)); window.setTimeout(() => { setCollapsingBatchIds((prev) => { const next = new Set(prev); next.delete(nodeId); return next; }); }, 320); } else { setOpeningBatchIds((prev) => new Set(prev).add(nodeId)); window.setTimeout(() => { setOpeningBatchIds((prev) => { const next = new Set(prev); next.delete(nodeId); return next; }); }, 260); } setNodes((prev) => prev.map((node) => { if (node.id !== nodeId) return node; return { ...node, metadata: { ...node.metadata, imageBatchExpanded: !node.metadata?.imageBatchExpanded } }; }), ); }, []); const setBatchPrimary = useCallback((child: CanvasNodeData) => { const rootId = child.metadata?.batchRootId; if (!rootId || !child.metadata?.content) return; setNodes((prev) => prev.map((node) => node.id === rootId ? { ...node, width: child.width, height: child.height, metadata: { ...node.metadata, content: child.metadata?.content, primaryImageId: child.id, naturalWidth: child.metadata?.naturalWidth, naturalHeight: child.metadata?.naturalHeight, freeResize: child.metadata?.freeResize, }, } : node, ), ); }, []); const openTextEditor = useCallback((node: CanvasNodeData) => { if (node.type !== CanvasNodeType.Text) return; setSelectedNodeIds(new Set([node.id])); setSelectedConnectionId(null); setDialogNodeId(node.id); setEditingNodeId(node.id); setEditRequestNonce((value) => value + 1); }, []); const handleNodePromptChange = useCallback((nodeId: string, prompt: string) => { setNodes((prev) => prev.map((node) => (node.id === nodeId ? { ...node, metadata: { ...node.metadata, prompt } } : node))); }, []); const handleConfigNodeChange = useCallback((nodeId: string, patch: Partial) => { setNodes((prev) => prev.map((node) => (node.id === nodeId ? applyNodeConfigPatch(node, patch) : node))); }, []); const downloadNodeImage = useCallback((node: CanvasNodeData) => { if ((node.type !== CanvasNodeType.Image && node.type !== CanvasNodeType.Video && node.type !== CanvasNodeType.Audio) || !node.metadata?.content) return; saveAs(node.metadata.content, `canvas-${node.type}-${node.id}.${node.type === CanvasNodeType.Video ? "mp4" : node.type === CanvasNodeType.Audio ? audioExtension(node.metadata.mimeType) : imageExtension(node.metadata.content)}`); }, []); const saveNodeAsset = useCallback( async (node: CanvasNodeData) => { if (node.type === CanvasNodeType.Text) { const content = node.metadata?.content?.trim(); if (!content) return message.error("没有可保存的文本"); addAsset({ kind: "text", title: node.metadata?.prompt?.slice(0, 24) || "画布文本", coverUrl: "", tags: [], source: "Canvas", data: { content }, metadata: { source: "canvas", nodeId: node.id } }); message.success("已加入我的资产"); return; } if (node.type === CanvasNodeType.Video) { if (!node.metadata?.content) return message.error("没有可保存的视频"); addAsset({ kind: "video", title: node.metadata?.prompt?.slice(0, 24) || "画布视频", coverUrl: "", tags: [], source: "Canvas", data: { url: node.metadata.content, storageKey: node.metadata.storageKey, width: node.width, height: node.height, bytes: node.metadata.bytes || 0, mimeType: node.metadata.mimeType || "video/mp4" }, metadata: { source: "canvas", nodeId: node.id, prompt: node.metadata?.prompt }, }); message.success("已加入我的资产"); return; } if (!node.metadata?.content) return message.error("没有可保存的图片"); const dataUrl = node.metadata.storageKey ? "" : node.metadata.content; addAsset({ kind: "image", title: node.metadata?.prompt?.slice(0, 24) || "画布图片", coverUrl: node.metadata.content, tags: [], source: "Canvas", data: { dataUrl, storageKey: node.metadata.storageKey, width: node.metadata.naturalWidth || node.width, height: node.metadata.naturalHeight || node.height, bytes: node.metadata.bytes || getDataUrlByteSize(dataUrl), mimeType: node.metadata.mimeType || "image/png", }, metadata: { source: "canvas", nodeId: node.id, prompt: node.metadata?.prompt }, }); message.success("已加入我的资产"); }, [addAsset, message], ); const createImageReversePromptNodes = useCallback( (node: CanvasNodeData) => { if (node.type !== CanvasNodeType.Image || !node.metadata?.content) { message.warning("图片节点为空,无法反推提示词"); return; } const gap = 96; const textSpec = NODE_DEFAULT_SIZE[CanvasNodeType.Text]; const configSpec = NODE_DEFAULT_SIZE[CanvasNodeType.Config]; const centerY = node.position.y + node.height / 2; const textNode = { ...createCanvasNode(CanvasNodeType.Text, { x: node.position.x + node.width + gap + textSpec.width / 2, y: centerY }, { content: IMAGE_PROMPT_REVERSE_PRESET, prompt: IMAGE_PROMPT_REVERSE_PRESET, status: NODE_STATUS_SUCCESS, fontSize: 14 }), title: "反推提示词", }; const configNode = { ...createCanvasNode( CanvasNodeType.Config, { x: textNode.position.x + textNode.width + gap + configSpec.width / 2, y: centerY }, { generationMode: "text", model: effectiveConfig.textModel || effectiveConfig.model || defaultConfig.textModel, count: 1, composerContent: `参考图片:@[node:${node.id}]\n任务说明:@[node:${textNode.id}]`, }, ), title: "反推提示词配置", }; setNodes((prev) => [...prev, textNode, configNode]); setConnections((prev) => [...prev, { id: nanoid(), fromNodeId: node.id, toNodeId: configNode.id }, { id: nanoid(), fromNodeId: textNode.id, toNodeId: configNode.id }]); setSelectedNodeIds(new Set([configNode.id])); setSelectedConnectionId(null); setDialogNodeId(configNode.id); setContextMenu(null); }, [effectiveConfig.model, effectiveConfig.textModel, message], ); const cropImageNode = useCallback(async (node: CanvasNodeData, crop: CanvasImageCropRect) => { if (!node.metadata?.content) return; const cropped = await cropDataUrl(node.metadata.content, crop); const image = await uploadImage(cropped); const width = Math.min(node.width, Math.max(220, image.width)); const childId = nanoid(); const child: CanvasNodeData = { id: childId, type: CanvasNodeType.Image, title: "Cropped Image", position: { x: node.position.x + node.width + 96, y: node.position.y }, width, height: width * (image.height / image.width), metadata: { ...imageMetadata(image), prompt: node.metadata?.prompt, }, }; setNodes((prev) => [...prev, child]); setConnections((prev) => [...prev, { id: nanoid(), fromNodeId: node.id, toNodeId: childId }]); setSelectedNodeIds(new Set([childId])); setDialogNodeId(childId); setCropNodeId(null); }, []); const splitImageNode = useCallback( async (node: CanvasNodeData, params: CanvasImageSplitParams) => { if (!node.metadata?.content) return; setSplitNodeId(null); const pieces = await splitDataUrl(node.metadata.content, params); const gap = 16; const cellWidth = node.width / params.columns; const cellHeight = node.height / params.rows; const startX = node.position.x + node.width + 96; const startY = node.position.y; const childNodes = await Promise.all( pieces.map(async (piece) => { const image = await uploadImage(piece.dataUrl); const id = nanoid(); return { id, type: CanvasNodeType.Image, title: `${node.title || "图片"} ${piece.row + 1}-${piece.column + 1}`, position: { x: startX + piece.column * (cellWidth + gap), y: startY + piece.row * (cellHeight + gap) }, width: cellWidth, height: cellHeight, metadata: { ...imageMetadata(image), prompt: node.metadata?.prompt, }, } satisfies CanvasNodeData; }), ); setNodes((prev) => [...prev, ...childNodes]); setConnections((prev) => [...prev, ...childNodes.map((child) => ({ id: nanoid(), fromNodeId: node.id, toNodeId: child.id }))]); setSelectedNodeIds(new Set(childNodes.map((child) => child.id))); setSelectedConnectionId(null); setDialogNodeId(null); message.success(`已切分为 ${childNodes.length} 个子节点`); }, [message], ); const maskEditImageNode = useCallback( async (node: CanvasNodeData, payload: CanvasImageMaskEditPayload) => { if (!node.metadata?.content) return; const generationConfig = { ...buildGenerationConfig(effectiveConfig, node, "image"), count: "1", size: node.metadata?.size || "auto" }; if (!isAiConfigReady(generationConfig, generationConfig.model)) { openConfigDialog(true); return; } const userPrompt = payload.prompt.trim(); const prompt = `只修改蒙版透明区域,其他区域保持不变。${userPrompt}`; const childId = nanoid(); const source = { id: node.id, name: `${node.title || node.id}.png`, type: node.metadata.mimeType || "image/png", dataUrl: node.metadata.content, storageKey: node.metadata.storageKey }; const generationMetadata = buildImageGenerationMetadata("edit", generationConfig, 1, [source]); setMaskEditNodeId(null); setRunningNodeId(childId); setNodes((prev) => [ ...prev, { id: childId, type: CanvasNodeType.Image, title: userPrompt.slice(0, 32) || "局部编辑结果", position: { x: node.position.x + node.width + 96, y: node.position.y }, width: node.width, height: node.height, metadata: { prompt, status: NODE_STATUS_LOADING, ...generationMetadata }, }, ]); setConnections((prev) => [...prev, { id: nanoid(), fromNodeId: node.id, toNodeId: childId }]); setSelectedNodeIds(new Set([childId])); setSelectedConnectionId(null); setDialogNodeId(childId); const controller = startGenerationRequest(childId, node.id, childId); try { const image = await requestEdit(generationConfig, prompt, [source], { id: `${node.id}-mask`, name: "mask.png", type: "image/png", dataUrl: payload.maskDataUrl }, { signal: controller.signal }).then((items) => items[0]); const uploaded = await uploadImage(image.dataUrl); const size = fitNodeSize(uploaded.width, uploaded.height, node.width, node.height); setNodes((prev) => prev.map((item) => (item.id === childId ? { ...item, width: size.width, height: size.height, metadata: { ...item.metadata, ...imageMetadata(uploaded), prompt, ...generationMetadata } } : item))); } catch (error) { if (isGenerationCanceled(error)) return; const errorDetails = error instanceof Error ? error.message : "局部修改失败"; message.error(errorDetails); setNodes((prev) => prev.map((item) => (item.id === childId ? { ...item, metadata: { ...item.metadata, status: NODE_STATUS_ERROR, errorDetails } } : item))); } finally { finishGenerationRequest(childId, controller); setRunningNodeId(null); } }, [effectiveConfig, finishGenerationRequest, isAiConfigReady, message, openConfigDialog, startGenerationRequest], ); const upscaleImageNode = useCallback(async (node: CanvasNodeData, params: CanvasImageUpscaleParams) => { if (!node.metadata?.content) return; setUpscaleNodeId(null); const upscaled = await upscaleDataUrl(node.metadata.content, params); const image = await uploadImage(upscaled); const size = fitNodeSize(image.width, image.height); const childId = nanoid(); const child: CanvasNodeData = { id: childId, type: CanvasNodeType.Image, title: "Upscaled Image", position: { x: node.position.x + node.width + 96, y: node.position.y }, width: size.width, height: size.height, metadata: { ...imageMetadata(image), prompt: node.metadata?.prompt, }, }; setNodes((prev) => [...prev, child]); setConnections((prev) => [...prev, { id: nanoid(), fromNodeId: node.id, toNodeId: childId }]); setSelectedNodeIds(new Set([childId])); setDialogNodeId(childId); }, []); const generateAngleNode = useCallback( async (node: CanvasNodeData, params: CanvasImageAngleParams) => { if (!node.metadata?.content) return; const generationConfig = { ...buildGenerationConfig(effectiveConfig, node, "image"), count: "1" }; if (!isAiConfigReady(generationConfig, generationConfig.model)) { openConfigDialog(true); return; } const childId = nanoid(); const imageConfig = NODE_DEFAULT_SIZE[CanvasNodeType.Image]; const title = buildAngleLabel(params); const prompt = buildAnglePrompt(params); const generationMetadata = buildImageGenerationMetadata("edit", generationConfig, 1, [ { id: node.id, name: `${node.title || node.id}.png`, type: node.metadata.mimeType || "image/png", dataUrl: node.metadata.content, storageKey: node.metadata.storageKey }, ]); setAngleNodeId(null); setRunningNodeId(childId); setNodes((prev) => [ ...prev, { id: childId, type: CanvasNodeType.Image, title, position: { x: node.position.x + node.width + 96, y: node.position.y }, width: imageConfig.width, height: imageConfig.height, metadata: { prompt, status: NODE_STATUS_LOADING, ...generationMetadata }, }, ]); setConnections((prev) => [...prev, { id: nanoid(), fromNodeId: node.id, toNodeId: childId }]); setSelectedNodeIds(new Set([childId])); setDialogNodeId(childId); const controller = startGenerationRequest(childId, node.id, childId); try { const image = await requestEdit( generationConfig, prompt, [{ id: node.id, name: `${node.title || node.id}.png`, type: node.metadata.mimeType || "image/png", dataUrl: node.metadata.content, storageKey: node.metadata.storageKey }], undefined, { signal: controller.signal }, ).then((items) => items[0]); const uploaded = await uploadImage(image.dataUrl); const size = fitNodeSize(uploaded.width, uploaded.height, imageConfig.width, imageConfig.height); setNodes((prev) => prev.map((item) => (item.id === childId ? { ...item, width: size.width, height: size.height, metadata: { ...item.metadata, ...imageMetadata(uploaded), prompt, ...generationMetadata } } : item))); } catch (error) { if (isGenerationCanceled(error)) return; const errorDetails = error instanceof Error ? error.message : "生成失败"; setNodes((prev) => prev.map((item) => (item.id === childId ? { ...item, metadata: { ...item.metadata, status: NODE_STATUS_ERROR, errorDetails } } : item))); } finally { finishGenerationRequest(childId, controller); setRunningNodeId(null); } }, [effectiveConfig, finishGenerationRequest, openConfigDialog, startGenerationRequest], ); const handleFontSizeChange = useCallback((nodeId: string, fontSize: number) => { setNodes((prev) => prev.map((node) => (node.id === nodeId ? { ...node, metadata: { ...node.metadata, fontSize } } : node))); }, []); const handleUploadRequest = useCallback((nodeId?: string, position?: Position) => { uploadTargetRef.current = { nodeId, position }; imageInputRef.current?.click(); }, []); const handleImageInputChange = useCallback( async (event: ReactChangeEvent) => { const files = Array.from(event.target.files || []).filter( (f) => f.type.startsWith("image/") || f.type.startsWith("video/") || isAudioFile(f), ); if (!files.length) { uploadTargetRef.current = null; event.target.value = ""; return; } const target = uploadTargetRef.current; const basePosition = target?.position || screenToCanvas( (containerRef.current?.getBoundingClientRect().left || 0) + size.width / 2, (containerRef.current?.getBoundingClientRect().top || 0) + size.height / 2, ); const STAGGER = 40; // 多文件时的偏移间距 // 如果有替换目标节点,第一个文件替换它,其余在附近新建 if (target?.nodeId) { const [first, ...rest] = files; // 第一个文件:替换目标节点 if (isAudioFile(first)) { const audio = await uploadMediaFile(first, "audio"); const spec = NODE_DEFAULT_SIZE[CanvasNodeType.Audio]; setNodes((prev) => prev.map((node) => node.id === target.nodeId ? { ...node, type: CanvasNodeType.Audio, title: first.name, position: { x: node.position.x + node.width / 2 - spec.width / 2, y: node.position.y + node.height / 2 - spec.height / 2 }, width: spec.width, height: spec.height, metadata: { ...node.metadata, ...audioMetadata(audio), errorDetails: undefined }, } : node, ), ); setSelectedNodeIds(new Set([target.nodeId])); setSelectedConnectionId(null); } else if (first.type.startsWith("video/")) { const video = await uploadMediaFile(first, "video"); const nextSize = fitNodeSize(video.width || 1280, video.height || 720, VIDEO_NODE_MAX_WIDTH, VIDEO_NODE_MAX_HEIGHT); setNodes((prev) => prev.map((node) => node.id === target.nodeId ? { ...node, type: CanvasNodeType.Video, title: first.name, position: { x: node.position.x + node.width / 2 - nextSize.width / 2, y: node.position.y + node.height / 2 - nextSize.height / 2 }, width: nextSize.width, height: nextSize.height, metadata: { ...node.metadata, ...videoMetadata(video), errorDetails: undefined }, } : node, ), ); setSelectedNodeIds(new Set([target.nodeId])); setSelectedConnectionId(null); } else { const image = await uploadImage(first); const s = fitNodeSize(image.width, image.height); setNodes((prev) => prev.map((node) => node.id === target.nodeId ? { ...node, type: CanvasNodeType.Image, title: first.name, width: s.width, height: s.height, metadata: { ...node.metadata, ...imageMetadata(image), errorDetails: undefined, freeResize: false, isBatchRoot: undefined, batchRootId: undefined, batchChildIds: undefined, batchUsesReferenceImages: undefined, generationType: undefined, model: undefined, size: undefined, quality: undefined, count: undefined, references: undefined, primaryImageId: undefined, imageBatchExpanded: undefined, }, } : node, ), ); setSelectedNodeIds(new Set([target.nodeId])); setSelectedConnectionId(null); } // 剩余文件:在目标节点附近新建 for (let i = 0; i < rest.length; i++) { const offsetPos = { x: basePosition.x + (i + 1) * STAGGER, y: basePosition.y + (i + 1) * STAGGER }; const f = rest[i]; if (isAudioFile(f)) { void createAudioFileNode(f, offsetPos); } else if (f.type.startsWith("video/")) { void createVideoFileNode(f, offsetPos); } else { void createImageFileNode(f, offsetPos); } } } else { // 无替换目标:所有文件在画布中心附近新建 for (let i = 0; i < files.length; i++) { const offsetPos = { x: basePosition.x + i * STAGGER, y: basePosition.y + i * STAGGER }; const f = files[i]; if (isAudioFile(f)) { void createAudioFileNode(f, offsetPos); } else if (f.type.startsWith("video/")) { void createVideoFileNode(f, offsetPos); } else { void createImageFileNode(f, offsetPos); } } } uploadTargetRef.current = null; event.target.value = ""; }, [createAudioFileNode, createImageFileNode, createVideoFileNode, screenToCanvas, size.height, size.width], ); const handleDrop = useCallback( (event: ReactDragEvent) => { event.preventDefault(); const files = Array.from(event.dataTransfer.files).filter( (item) => item.type.startsWith("image/") || item.type.startsWith("video/") || isAudioFile(item), ); if (!files.length) return; const basePos = screenToCanvas(event.clientX, event.clientY); const STAGGER = 40; for (let i = 0; i < files.length; i++) { const pos = { x: basePos.x + i * STAGGER, y: basePos.y + i * STAGGER }; const f = files[i]; if (isAudioFile(f)) { void createAudioFileNode(f, pos); } else if (f.type.startsWith("video/")) { void createVideoFileNode(f, pos); } else { void createImageFileNode(f, pos); } } }, [createAudioFileNode, createImageFileNode, createVideoFileNode, screenToCanvas], ); const startTitleEditing = useCallback(() => { setTitleDraft(currentProject?.title || "未命名画布"); setTitleEditing(true); }, [currentProject?.title]); const finishTitleEditing = useCallback(() => { const nextTitle = titleDraft.trim(); if (nextTitle) renameProject(projectId, nextTitle); setTitleEditing(false); }, [projectId, renameProject, titleDraft]); const preventCanvasContextMenu = useCallback((event: ReactMouseEvent) => { if ((event.target as HTMLElement).closest("[data-node-id]")) return; event.preventDefault(); setContextMenu(null); }, []); const handleGenerateNode = useCallback( async (nodeId: string, mode: CanvasNodeGenerationMode, prompt: string) => { const sourceNode = nodesRef.current.find((node) => node.id === nodeId); const generationConfig = buildGenerationConfig(effectiveConfig, sourceNode, mode); if (!isAiConfigReady(generationConfig, generationConfig.model)) { openConfigDialog(true); return; } // 插件节点声明了 useBuiltinPanel.writeBackToSelf:复用内置面板生成,但结果写回节点自身。 // 目前支持 image 模式(全景等展示型节点),前缀由 useBuiltinPanel.promptPrefix 指定。 const builtinPanel = sourceNode ? getNodeDefinition(sourceNode.type)?.useBuiltinPanel : undefined; if (sourceNode && builtinPanel?.writeBackToSelf && builtinPanel.mode === "image") { const scene = prompt.trim(); if (!scene) return; setRunningNodeId(nodeId); const controller = startGenerationRequest(nodeId, nodeId, nodeId); setNodes((prev) => prev.map((node) => (node.id === nodeId ? { ...node, metadata: { ...node.metadata, prompt: scene, status: NODE_STATUS_LOADING, errorDetails: undefined } } : node))); try { const fullPrompt = (builtinPanel.promptPrefix || "") + scene; // 上游图片节点作为参考图(图生图);无上游则纯文生图 const upstreamNodes = connectionsRef.current .filter((conn) => conn.toNodeId === nodeId) .map((conn) => nodesRef.current.find((node) => node.id === conn.fromNodeId)) .filter((node): node is CanvasNodeData => Boolean(node)); const refs = upstreamNodes.flatMap((up) => typeof up.metadata?.content === "string" && up.metadata.content && up.type !== sourceNode.type ? [{ id: up.id, name: `${up.title || up.id}.png`, type: up.metadata.mimeType || "image/png", dataUrl: up.metadata.content, storageKey: up.metadata.storageKey }] : [], ); const image = refs.length ? await requestEdit({ ...generationConfig, count: "1" }, fullPrompt, refs, undefined, { signal: controller.signal }).then((items) => items[0]) : await requestGeneration({ ...generationConfig, count: "1" }, fullPrompt, { signal: controller.signal }).then((items) => items[0]); const uploaded = await uploadImage(image.dataUrl); setNodes((prev) => prev.map((node) => (node.id === nodeId ? { ...node, metadata: { ...node.metadata, ...imageMetadata(uploaded), prompt: scene, model: generationConfig.model, status: NODE_STATUS_SUCCESS, errorDetails: undefined } } : node)), ); setDialogNodeId(null); } catch (error) { if (!isGenerationCanceled(error)) { const errorDetails = error instanceof Error ? error.message : "生成失败"; message.error(errorDetails); setNodes((prev) => prev.map((node) => (node.id === nodeId ? { ...node, metadata: { ...node.metadata, status: NODE_STATUS_ERROR, errorDetails } } : node))); } } finally { finishGenerationRequest(nodeId, controller); } return; } setRunningNodeId(nodeId); const runController = startGenerationRequest(nodeId, nodeId, nodeId); const sourceTextContent = sourceNode?.type === CanvasNodeType.Text ? sourceNode.metadata?.content?.trim() || "" : ""; const editingTextNode = mode === "text" && Boolean(sourceTextContent); const generationContext = await hydrateNodeGenerationContext( buildNodeGenerationContext(nodeId, nodesRef.current, connectionsRef.current, editingTextNode ? `请根据要求修改以下文本。\n\n原文:\n${sourceTextContent}\n\n修改要求:\n${prompt}` : prompt), ); const effectivePrompt = generationContext.prompt.trim(); if (runController.signal.aborted) { finishGenerationRequest(nodeId, runController); setRunningNodeId(null); return; } const markSourceStatus = sourceNode?.type !== CanvasNodeType.Image && !editingTextNode; if (!effectivePrompt && (mode === "text" || mode === "audio")) { finishGenerationRequest(nodeId, runController); setRunningNodeId(null); return; } let pendingChildIds: string[] = []; if (markSourceStatus) setNodes((prev) => prev.map((node) => (node.id === nodeId ? { ...node, metadata: { ...node.metadata, ...(node.type === CanvasNodeType.Config ? {} : { prompt }), status: NODE_STATUS_LOADING, errorDetails: undefined } } : node))); try { if (mode === "image") { const count = getGenerationCount(generationConfig.count); const isConfigNode = sourceNode?.type === CanvasNodeType.Config; const isImageNode = sourceNode?.type === CanvasNodeType.Image; const isEmptyImageNode = isImageNode && !sourceNode?.metadata?.content; const sourceReference = isImageNode && sourceNode?.metadata?.content ? [{ id: sourceNode.id, name: `${sourceNode.title || sourceNode.id}.png`, type: sourceNode.metadata.mimeType || "image/png", dataUrl: sourceNode.metadata.content, storageKey: sourceNode.metadata.storageKey }] : []; const referenceImages = sourceReference.length ? sourceReference : generationContext.referenceImages; const generationType = referenceImages.length ? ("edit" as const) : ("generation" as const); const generationMetadata = buildImageGenerationMetadata(generationType, generationConfig, count, referenceImages); const parentConfig = NODE_DEFAULT_SIZE[isConfigNode ? CanvasNodeType.Config : isImageNode ? CanvasNodeType.Image : CanvasNodeType.Text]; const imageConfig = NODE_DEFAULT_SIZE[CanvasNodeType.Image]; const parentPosition = sourceNode?.position || { x: 0, y: 0 }; const gap = 96; const rowGap = 36; const rootId = isEmptyImageNode ? nodeId : nanoid(); const childIds = count > 1 ? Array.from({ length: count }, () => nanoid()) : []; const targetIds = count > 1 ? childIds : [rootId]; pendingChildIds = isEmptyImageNode ? childIds : [rootId, ...childIds]; const rootNode: CanvasNodeData = { id: rootId, type: CanvasNodeType.Image, title: effectivePrompt.slice(0, 32) || "Generated Image", position: { x: isEmptyImageNode ? parentPosition.x : parentPosition.x + parentConfig.width + gap, y: parentPosition.y + parentConfig.height / 2 - imageConfig.height / 2, }, width: isEmptyImageNode ? sourceNode?.width || imageConfig.width : imageConfig.width, height: isEmptyImageNode ? sourceNode?.height || imageConfig.height : imageConfig.height, metadata: { prompt: effectivePrompt, status: NODE_STATUS_LOADING, isBatchRoot: count > 1, batchChildIds: count > 1 ? childIds : undefined, batchUsesReferenceImages: referenceImages.length > 0, ...generationMetadata, imageBatchExpanded: count > 1 ? true : undefined, }, }; const childNodes: CanvasNodeData[] = childIds.map((id, index) => ({ id, type: CanvasNodeType.Image, title: effectivePrompt.slice(0, 32) || "Generated Image", position: { x: rootNode.position.x + rootNode.width + 120 + (index % 2) * (imageConfig.width + 36), y: rootNode.position.y + Math.floor(index / 2) * (imageConfig.height + rowGap), }, width: imageConfig.width, height: imageConfig.height, metadata: { prompt: effectivePrompt, status: NODE_STATUS_LOADING, batchRootId: count > 1 ? rootId : undefined, ...generationMetadata }, })); const batchConnections = [...(isEmptyImageNode ? [] : [{ id: nanoid(), fromNodeId: nodeId, toNodeId: rootId }]), ...childIds.map((childId) => ({ id: nanoid(), fromNodeId: rootId, toNodeId: childId }))]; setNodes((prev) => [ ...prev.map((node) => node.id === nodeId ? isConfigNode ? { ...node, metadata: { ...node.metadata, status: NODE_STATUS_LOADING, errorDetails: undefined }, } : isEmptyImageNode ? { ...node, position: rootNode.position, width: rootNode.width, height: rootNode.height, title: rootNode.title, metadata: { ...node.metadata, ...rootNode.metadata, errorDetails: undefined }, } : isImageNode ? { ...node, metadata: { ...node.metadata, status: NODE_STATUS_SUCCESS, errorDetails: undefined }, } : { ...node, type: CanvasNodeType.Text, title: prompt.slice(0, 32) || "Prompt", width: parentConfig.width, height: parentConfig.height, metadata: { ...node.metadata, content: prompt, prompt, status: NODE_STATUS_SUCCESS, fontSize: 14, errorDetails: undefined }, } : node, ), ...(isEmptyImageNode ? [] : [rootNode]), ...childNodes, ]); setConnections((prev) => [...prev, ...batchConnections]); setSelectedNodeIds(new Set([nodeId])); setSelectedConnectionId(null); setDialogNodeId(nodeId); const controller = runController; targetIds.forEach((targetId) => startGenerationRequest(targetId, nodeId, nodeId, controller)); if (count > 1) startGenerationRequest(rootId, nodeId, nodeId, controller); let hasSuccess = false; let hasFailure = false; let firstError = ""; await Promise.all( targetIds.map(async (targetId) => { try { const image = referenceImages.length ? await requestEdit({ ...generationConfig, count: "1" }, effectivePrompt, referenceImages, undefined, { signal: controller.signal }).then((items) => items[0]) : await requestGeneration({ ...generationConfig, count: "1" }, effectivePrompt, { signal: controller.signal }).then((items) => items[0]); const uploaded = await uploadImage(image.dataUrl); const imageSize = fitNodeSize(uploaded.width, uploaded.height, imageConfig.width, imageConfig.height); setNodes((prev) => { const root = prev.find((node) => node.id === rootId); return prev.map((node) => { if (node.id !== targetId && node.id !== rootId) return node; const center = { x: node.position.x + node.width / 2, y: node.position.y + node.height / 2 }; if (node.id === rootId && (targetId === rootId || !root?.metadata?.primaryImageId)) return { ...node, position: { x: center.x - imageSize.width / 2, y: center.y - imageSize.height / 2 }, width: imageSize.width, height: imageSize.height, metadata: { ...node.metadata, ...imageMetadata(uploaded), primaryImageId: targetId }, }; if (node.id === targetId) return { ...node, position: { x: center.x - imageSize.width / 2, y: center.y - imageSize.height / 2 }, width: imageSize.width, height: imageSize.height, metadata: { ...node.metadata, ...imageMetadata(uploaded) }, }; return node; }); }); hasSuccess = true; if (isConfigNode) setNodes((prev) => prev.map((node) => (node.id === nodeId ? { ...node, metadata: { ...node.metadata, status: NODE_STATUS_SUCCESS, errorDetails: undefined } } : node))); return true; } catch (error) { if (isGenerationCanceled(error)) return false; const errorDetails = error instanceof Error ? error.message : "生成失败"; if (!firstError) firstError = errorDetails; hasFailure = true; setNodes((prev) => prev.map((node) => (node.id === targetId ? { ...node, metadata: { ...node.metadata, status: NODE_STATUS_ERROR, errorDetails } } : node))); } finally { finishGenerationRequest(targetId, controller); } return false; }), ); if (count > 1) finishGenerationRequest(rootId, controller); if (controller.signal.aborted) { setNodes((prev) => prev.map((node) => (node.id === nodeId && isConfigNode && node.metadata?.status === NODE_STATUS_LOADING ? { ...node, metadata: { ...node.metadata, status: NODE_STATUS_IDLE, errorDetails: undefined } } : node))); return; } if (hasFailure) { message.error(hasSuccess ? "部分图片生成失败" : firstError || "生成失败"); } setNodes((prev) => prev.map((node) => node.id === nodeId && isConfigNode ? { ...node, metadata: { ...node.metadata, status: hasSuccess ? NODE_STATUS_SUCCESS : NODE_STATUS_ERROR, errorDetails: hasSuccess ? undefined : "生成失败" } } : node.id === nodeId && isEmptyImageNode ? { ...node, metadata: { ...node.metadata, status: hasSuccess ? NODE_STATUS_SUCCESS : NODE_STATUS_ERROR, errorDetails: hasSuccess ? undefined : "生成失败" } } : node.id === rootId && !hasSuccess && !targetIds.includes(node.id) ? { ...node, metadata: { ...node.metadata, status: NODE_STATUS_ERROR, errorDetails: "全部图片生成失败" } } : node, ), ); return; } if (mode === "video") { const spec = nodeSizeFromRatio(generationConfig.size, NODE_DEFAULT_SIZE[CanvasNodeType.Video].width, NODE_DEFAULT_SIZE[CanvasNodeType.Video].height) || NODE_DEFAULT_SIZE[CanvasNodeType.Video]; const isEmptyVideoNode = sourceNode?.type === CanvasNodeType.Video && !sourceNode.metadata?.content; const videoId = isEmptyVideoNode ? nodeId : nanoid(); const parent = sourceNode?.position || { x: 0, y: 0 }; const videoNode: CanvasNodeData = { id: videoId, type: CanvasNodeType.Video, title: effectivePrompt.slice(0, 32) || "Generated Video", position: isEmptyVideoNode ? sourceNode.position : { x: parent.x + (sourceNode?.width || spec.width) + 96, y: parent.y }, width: isEmptyVideoNode ? sourceNode.width : spec.width, height: isEmptyVideoNode ? sourceNode.height : spec.height, metadata: { prompt: effectivePrompt, status: NODE_STATUS_LOADING, model: generationConfig.model, size: generationConfig.size, seconds: generationConfig.videoSeconds, vquality: generationConfig.vquality, generateAudio: generationConfig.videoGenerateAudio, watermark: generationConfig.videoWatermark, references: generationReferenceUrls(generationContext), }, }; pendingChildIds = [videoId]; setNodes((prev) => isEmptyVideoNode ? prev.map((node) => (node.id === nodeId ? { ...node, ...videoNode } : node)) : [...prev.map((node) => (node.id === nodeId ? { ...node, metadata: { ...node.metadata, status: NODE_STATUS_SUCCESS } } : node)), videoNode], ); if (!isEmptyVideoNode) setConnections((prev) => [...prev, { id: nanoid(), fromNodeId: nodeId, toNodeId: videoId }]); const controller = startGenerationRequest(videoId, nodeId, nodeId, runController); try { const video = await storeGeneratedVideo( await requestVideoGeneration(generationConfig, effectivePrompt, generationContext.referenceImages, generationContext.referenceVideos, generationContext.referenceAudios, { signal: controller.signal }), ); const videoSize = fitNodeSize(video.width || spec.width, video.height || spec.height, VIDEO_NODE_MAX_WIDTH, VIDEO_NODE_MAX_HEIGHT); setNodes((prev) => prev.map((node) => node.id === videoId ? { ...node, width: videoSize.width, height: videoSize.height, position: { x: node.position.x + node.width / 2 - videoSize.width / 2, y: node.position.y + node.height / 2 - videoSize.height / 2 }, metadata: { ...node.metadata, ...videoMetadata(video), prompt: effectivePrompt, model: generationConfig.model, size: generationConfig.size, seconds: generationConfig.videoSeconds, vquality: generationConfig.vquality, generateAudio: generationConfig.videoGenerateAudio, watermark: generationConfig.videoWatermark, references: generationReferenceUrls(generationContext), }, } : node, ), ); } finally { finishGenerationRequest(videoId, controller); } return; } if (mode === "audio") { const spec = NODE_DEFAULT_SIZE[CanvasNodeType.Audio]; const isEmptyAudioNode = sourceNode?.type === CanvasNodeType.Audio && !sourceNode.metadata?.content; const audioId = isEmptyAudioNode ? nodeId : nanoid(); const parent = sourceNode?.position || { x: 0, y: 0 }; const audioNode: CanvasNodeData = { id: audioId, type: CanvasNodeType.Audio, title: effectivePrompt.slice(0, 32) || "Generated Audio", position: isEmptyAudioNode ? sourceNode.position : { x: parent.x + (sourceNode?.width || spec.width) + 96, y: parent.y + ((sourceNode?.height || spec.height) - spec.height) / 2 }, width: isEmptyAudioNode ? sourceNode.width : spec.width, height: isEmptyAudioNode ? sourceNode.height : spec.height, metadata: { prompt: effectivePrompt, status: NODE_STATUS_LOADING, ...buildAudioGenerationMetadata(generationConfig) }, }; pendingChildIds = [audioId]; setNodes((prev) => isEmptyAudioNode ? prev.map((node) => (node.id === nodeId ? { ...node, ...audioNode } : node)) : [...prev.map((node) => (node.id === nodeId ? { ...node, metadata: { ...node.metadata, status: NODE_STATUS_SUCCESS } } : node)), audioNode], ); if (!isEmptyAudioNode) setConnections((prev) => [...prev, { id: nanoid(), fromNodeId: nodeId, toNodeId: audioId }]); const controller = startGenerationRequest(audioId, nodeId, nodeId, runController); try { const audio = await storeGeneratedAudio(await requestAudioGeneration(generationConfig, effectivePrompt, { signal: controller.signal }), generationConfig.audioFormat); setNodes((prev) => prev.map((node) => (node.id === audioId ? { ...node, metadata: { ...node.metadata, ...audioMetadata(audio), prompt: effectivePrompt, ...buildAudioGenerationMetadata(generationConfig) } } : node))); } finally { finishGenerationRequest(audioId, controller); } return; } let streamed = ""; const isConfigNode = sourceNode?.type === CanvasNodeType.Config; const textCount = isConfigNode ? getGenerationCount(generationConfig.count) : 1; const parentConfig = NODE_DEFAULT_SIZE[isConfigNode ? CanvasNodeType.Config : CanvasNodeType.Text]; const textConfig = NODE_DEFAULT_SIZE[CanvasNodeType.Text]; const parentPosition = sourceNode?.position || { x: 0, y: 0 }; const childIds = isConfigNode || editingTextNode ? Array.from({ length: textCount }, () => nanoid()) : []; pendingChildIds = childIds; if (isConfigNode || editingTextNode) { const childNodes: CanvasNodeData[] = childIds.map((id, index) => ({ id, type: CanvasNodeType.Text, title: effectivePrompt.slice(0, 32) || "Generated Text", position: { x: parentPosition.x + parentConfig.width + 96, y: parentPosition.y + parentConfig.height / 2 - textConfig.height / 2 + (index - (textCount - 1) / 2) * (textConfig.height + 36), }, width: textConfig.width, height: textConfig.height, metadata: { prompt: effectivePrompt, status: NODE_STATUS_LOADING, fontSize: 14, model: generationConfig.model, reasoningEffort: generationConfig.reasoningEffort }, })); setNodes((prev) => [...prev.map((node) => (node.id === nodeId && isConfigNode ? { ...node, metadata: { ...node.metadata, status: NODE_STATUS_LOADING, errorDetails: undefined } } : node)), ...childNodes]); setConnections((prev) => [...prev, ...childIds.map((childId) => ({ id: nanoid(), fromNodeId: nodeId, toNodeId: childId }))]); } const controller = runController; const textTargetIds = childIds.length ? childIds : [nodeId]; textTargetIds.forEach((targetNodeId) => startGenerationRequest(targetNodeId, nodeId, nodeId, controller)); const answers = await Promise.all( textTargetIds.map((targetNodeId) => { let localStreamed = ""; return requestImageQuestion( generationConfig, buildNodeResponseMessages({ ...generationContext, prompt: effectivePrompt }), (text) => { localStreamed = text; streamed = text; if (isConfigNode) return; setNodes((prev) => prev.map((node) => (node.id === targetNodeId ? { ...node, type: CanvasNodeType.Text, metadata: { ...node.metadata, content: text, status: NODE_STATUS_LOADING } } : node))); }, { signal: controller.signal }, ) .then((answer) => ({ nodeId: targetNodeId, content: answer || localStreamed })) .finally(() => finishGenerationRequest(targetNodeId, controller)); }), ); if (controller.signal.aborted) return; const answerByNodeId = new Map(answers.map((item) => [item.nodeId, item.content])); setNodes((prev) => prev.map((node) => childIds.includes(node.id) ? { ...node, metadata: { ...node.metadata, content: answerByNodeId.get(node.id) || streamed, status: NODE_STATUS_SUCCESS } } : node.id === nodeId && isConfigNode ? { ...node, metadata: { ...node.metadata, status: NODE_STATUS_SUCCESS } } : node.id === nodeId && !editingTextNode ? { ...node, type: CanvasNodeType.Text, title: prompt.slice(0, 32) || "Generated Text", metadata: { ...node.metadata, content: answerByNodeId.get(node.id) || streamed, model: generationConfig.model, reasoningEffort: generationConfig.reasoningEffort, status: NODE_STATUS_SUCCESS }, } : node, ), ); } catch (error) { if (isGenerationCanceled(error)) return; const errorDetails = error instanceof Error ? error.message : "生成失败"; message.error(errorDetails); setNodes((prev) => prev.map((node) => (node.id === nodeId || pendingChildIds.includes(node.id) ? (node.id === nodeId && !markSourceStatus ? node : { ...node, metadata: { ...node.metadata, status: NODE_STATUS_ERROR, errorDetails } }) : node)), ); } finally { finishGenerationRequest(nodeId, runController); setRunningNodeId(null); } }, [effectiveConfig, finishGenerationRequest, isAiConfigReady, message, openConfigDialog, startGenerationRequest], ); useEffect(() => { generateNodeRef.current = handleGenerateNode; }, [handleGenerateNode]); const handleRetryNode = useCallback( async (node: CanvasNodeData) => { const sourceNode = findRetrySourceNode(node.id, nodesRef.current, connectionsRef.current) || node; const batchRoot = node.metadata?.batchRootId ? nodesRef.current.find((item) => item.id === node.metadata?.batchRootId) : null; const savedImageMetadata = node.type === CanvasNodeType.Image ? { ...batchRoot?.metadata, ...node.metadata } : undefined; const hasSavedImageMetadata = Boolean(savedImageMetadata?.generationType); const generationConfig = hasSavedImageMetadata && savedImageMetadata ? { ...effectiveConfig, model: savedImageMetadata.model || effectiveConfig.imageModel || effectiveConfig.model, quality: savedImageMetadata.quality || effectiveConfig.quality, size: savedImageMetadata.size || effectiveConfig.size, background: savedImageMetadata.background ?? effectiveConfig.background, count: "1", } : { ...buildGenerationConfig(effectiveConfig, sourceNode, node.type === CanvasNodeType.Text ? "text" : node.type === CanvasNodeType.Video ? "video" : node.type === CanvasNodeType.Audio ? "audio" : "image"), count: "1" }; if (!isAiConfigReady(generationConfig, generationConfig.model)) { openConfigDialog(true); return; } const context = hasSavedImageMetadata ? null : await hydrateNodeGenerationContext(buildNodeGenerationContext(sourceNode.id, nodesRef.current, connectionsRef.current, sourceNode.metadata?.prompt || node.metadata?.prompt || "")); const prompt = (savedImageMetadata?.prompt || context?.prompt || "").trim(); if (!prompt) { message.warning("找不到提示词,无法重试"); return; } const generationType = savedImageMetadata?.generationType; const useReferenceImages = generationType ? generationType === "edit" : Boolean(context?.referenceImages.length); const retryReferenceImages = hasSavedImageMetadata && savedImageMetadata ? await resolveMetadataReferences(savedImageMetadata) : useReferenceImages ? (context?.referenceImages.length ? context.referenceImages : sourceNodeReferenceImages(batchRoot || sourceNode)) : []; if (useReferenceImages && !retryReferenceImages) { message.error("参考图片已丢失,无法继续重试"); setNodes((prev) => prev.map((item) => (item.id === node.id ? { ...item, metadata: { ...item.metadata, status: NODE_STATUS_ERROR, errorDetails: "参考图片已丢失,无法继续重试" } } : item))); return; } const retryImages = retryReferenceImages || []; setRunningNodeId(node.id); setNodes((prev) => prev.map((item) => (item.id === node.id ? { ...item, metadata: { ...item.metadata, status: NODE_STATUS_LOADING, errorDetails: undefined } } : item))); const controller = startGenerationRequest(node.id, sourceNode.id, node.id); try { if (node.type === CanvasNodeType.Text) { if (!context) return; let streamed = ""; const answer = await requestImageQuestion( generationConfig, buildNodeResponseMessages({ ...context, prompt }), (text) => { streamed = text; setNodes((prev) => prev.map((item) => (item.id === node.id ? { ...item, type: CanvasNodeType.Text, metadata: { ...item.metadata, content: text, status: NODE_STATUS_LOADING } } : item))); }, { signal: controller.signal }, ); setNodes((prev) => prev.map((item) => (item.id === node.id ? { ...item, type: CanvasNodeType.Text, metadata: { ...item.metadata, content: answer || streamed, prompt, status: NODE_STATUS_SUCCESS } } : item))); return; } if (node.type === CanvasNodeType.Video) { const video = await storeGeneratedVideo(await requestVideoGeneration(generationConfig, prompt, retryImages, context?.referenceVideos || [], context?.referenceAudios || [], { signal: controller.signal })); const videoSize = fitNodeSize(video.width || node.width, video.height || node.height, VIDEO_NODE_MAX_WIDTH, VIDEO_NODE_MAX_HEIGHT); setNodes((prev) => prev.map((item) => item.id === node.id ? { ...item, width: videoSize.width, height: videoSize.height, position: { x: item.position.x + item.width / 2 - videoSize.width / 2, y: item.position.y + item.height / 2 - videoSize.height / 2 }, metadata: { ...item.metadata, ...videoMetadata(video), prompt, model: generationConfig.model, size: generationConfig.size, seconds: generationConfig.videoSeconds, vquality: generationConfig.vquality, generateAudio: generationConfig.videoGenerateAudio, watermark: generationConfig.videoWatermark, }, } : item, ), ); return; } if (node.type === CanvasNodeType.Audio) { const audio = await storeGeneratedAudio(await requestAudioGeneration(generationConfig, prompt, { signal: controller.signal }), generationConfig.audioFormat); setNodes((prev) => prev.map((item) => (item.id === node.id ? { ...item, metadata: { ...item.metadata, ...audioMetadata(audio), prompt, ...buildAudioGenerationMetadata(generationConfig) } } : item))); return; } const image = useReferenceImages ? await requestEdit(generationConfig, prompt, retryImages, undefined, { signal: controller.signal }).then((items) => items[0]) : await requestGeneration(generationConfig, prompt, { signal: controller.signal }).then((items) => items[0]); const uploadedImage = await uploadImage(image.dataUrl); const imageConfig = NODE_DEFAULT_SIZE[CanvasNodeType.Image]; const imageSize = fitNodeSize(uploadedImage.width, uploadedImage.height, imageConfig.width, imageConfig.height); const generationMetadata = savedImageMetadata?.generationType ? { generationType: savedImageMetadata.generationType, model: generationConfig.model, size: generationConfig.size, quality: generationConfig.quality, ...(generationConfig.background ? { background: generationConfig.background } : {}), count: savedImageMetadata.count || 1, references: savedImageMetadata.references, } : buildImageGenerationMetadata(useReferenceImages ? "edit" : "generation", generationConfig, 1, retryImages); setNodes((prev) => prev.map((item) => item.id === node.id ? { ...item, type: CanvasNodeType.Image, width: imageSize.width, height: imageSize.height, metadata: { ...item.metadata, ...imageMetadata(uploadedImage), prompt, ...generationMetadata }, } : item, ), ); } catch (error) { if (isGenerationCanceled(error)) return; const errorDetails = error instanceof Error ? error.message : "生成失败"; message.error(errorDetails); setNodes((prev) => prev.map((item) => (item.id === node.id ? { ...item, metadata: { ...item.metadata, status: NODE_STATUS_ERROR, errorDetails } } : item))); } finally { finishGenerationRequest(node.id, controller); setRunningNodeId(null); } }, [effectiveConfig, finishGenerationRequest, isAiConfigReady, message, openConfigDialog, startGenerationRequest], ); const generateImageFromTextNode = useCallback( (node: CanvasNodeData) => { const prompt = (node.metadata?.content || node.metadata?.prompt || "").trim(); if (!prompt) { message.warning("文本节点为空,无法生图"); return; } const sourceNode = nodesRef.current.find((item) => item.id === node.id); if (!sourceNode) return; const nodeSize = getNodeSpec(CanvasNodeType.Config); const configNode = createCanvasNode( CanvasNodeType.Config, { x: sourceNode.position.x + sourceNode.width + 96 + nodeSize.width / 2, y: sourceNode.position.y + sourceNode.height / 2, }, { prompt: "", model: effectiveConfig.imageModel || effectiveConfig.model, size: effectiveConfig.size, count: getGenerationCount(effectiveConfig.canvasImageCount || effectiveConfig.count), }, ); const connection = { id: nanoid(), fromNodeId: sourceNode.id, toNodeId: configNode.id }; const nextNodes = nodesRef.current.map((item) => (item.id === sourceNode.id ? { ...item, metadata: { ...item.metadata, content: prompt, prompt, status: NODE_STATUS_SUCCESS } } : item)).concat(configNode); const nextConnections = [...connectionsRef.current, connection]; nodesRef.current = nextNodes; connectionsRef.current = nextConnections; setNodes(nextNodes); setConnections(nextConnections); setSelectedNodeIds(new Set([configNode.id])); setSelectedConnectionId(null); setDialogNodeId(configNode.id); }, [effectiveConfig.canvasImageCount, effectiveConfig.count, effectiveConfig.imageModel, effectiveConfig.model, effectiveConfig.size, message], ); const insertAssistantImage = useCallback( async (image: CanvasAssistantImage) => { const storedImage = image.storageKey ? { url: image.dataUrl, storageKey: image.storageKey, width: 1, height: 1, bytes: 0, mimeType: "image/png" } : await uploadImage(image.dataUrl); const meta = storedImage.width === 1 && storedImage.height === 1 ? await readImageMeta(storedImage.url) : storedImage; const config = fitNodeSize(meta.width, meta.height); const center = screenToCanvas((containerRef.current?.getBoundingClientRect().left || 0) + size.width / 2, (containerRef.current?.getBoundingClientRect().top || 0) + size.height / 2); const id = `image-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`; const node: CanvasNodeData = { id, type: CanvasNodeType.Image, title: image.prompt.slice(0, 32) || "Generated Image", position: { x: center.x - config.width / 2, y: center.y - config.height / 2 }, width: config.width, height: config.height, metadata: { ...imageMetadata({ ...storedImage, width: meta.width, height: meta.height }), prompt: image.prompt }, }; setNodes((prev) => [...prev, node]); setSelectedNodeIds(new Set([id])); setSelectedConnectionId(null); setDialogNodeId(id); }, [screenToCanvas, size.height, size.width], ); const insertAssistantText = useCallback( (text: string, title?: string) => { const center = screenToCanvas((containerRef.current?.getBoundingClientRect().left || 0) + size.width / 2, (containerRef.current?.getBoundingClientRect().top || 0) + size.height / 2); const node = { ...createCanvasNode(CanvasNodeType.Text, center, { content: text, status: NODE_STATUS_SUCCESS }), title: title || text.slice(0, 32) || "Assistant Text", }; setNodes((prev) => [...prev, node]); setSelectedNodeIds(new Set([node.id])); setSelectedConnectionId(null); }, [screenToCanvas, size.height, size.width], ); const handleAssetInsert = useCallback( (payload: InsertAssetPayload) => { if (payload.kind === "text") { insertAssistantText(payload.content, payload.title); } else if (payload.kind === "video") { const spec = NODE_DEFAULT_SIZE[CanvasNodeType.Video]; const center = screenToCanvas((containerRef.current?.getBoundingClientRect().left || 0) + size.width / 2, (containerRef.current?.getBoundingClientRect().top || 0) + size.height / 2); const id = `video-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`; const nextSize = fitNodeSize(payload.width || spec.width, payload.height || spec.height, VIDEO_NODE_MAX_WIDTH, VIDEO_NODE_MAX_HEIGHT); setNodes((prev) => [ ...prev, { id, type: CanvasNodeType.Video, title: payload.title, position: { x: center.x - nextSize.width / 2, y: center.y - nextSize.height / 2 }, width: nextSize.width, height: nextSize.height, metadata: { content: payload.url, storageKey: payload.storageKey, status: NODE_STATUS_SUCCESS, naturalWidth: payload.width, naturalHeight: payload.height }, }, ]); setSelectedNodeIds(new Set([id])); } else { insertAssistantImage({ id: `asset-${Date.now()}`, prompt: payload.title, dataUrl: payload.dataUrl, storageKey: payload.storageKey }); } setAssetPickerOpen(false); }, [insertAssistantImage, insertAssistantText, screenToCanvas, size.height, size.width], ); // --- 传给 CanvasNode 的回调/渲染函数统一 memo 化 --- // CanvasNode 是 React.memo,但只要这些 prop 每次渲染都是新引用,memo 就失效, // 导致点击/悬停/移动视角时全部节点跟着重渲染(markdown 尤其明显)。全部 useCallback 后, // 未变化的节点不再重渲染。依赖里的 map/handler 均已 memo 化,纯交互时保持稳定。 const handleNodeHoverStart = useCallback((nodeId: string) => { if (nodeDraggingRef.current) return; setHoveredNodeId(nodeId); }, []); const handleNodeHoverEnd = useCallback((nodeId: string) => { setHoveredNodeId((current) => (current === nodeId ? null : current)); }, []); const handleNodeViewImage = useCallback((node: CanvasNodeData) => setPreviewNodeId(node.id), []); const handleNodeRetry = useCallback((node: CanvasNodeData) => void handleRetryNode(node), [handleRetryNode]); const handleNodeContextMenu = useCallback((event: ReactMouseEvent, nodeId: string) => { event.preventDefault(); event.stopPropagation(); setContextMenu({ type: "node", x: event.clientX, y: event.clientY, nodeId }); }, []); const renderNodePanel = useCallback( (panelNode: CanvasNodeData) => getNodeDefinition(panelNode.type)?.Panel ? ( renderPluginPanel(panelNode) ) : panelNode.type === CanvasNodeType.Config ? ( handleConfigNodeChange(panelNode.id, { composerContent })} onClose={() => setDialogNodeId(null)} /> ) : ( { setNodeImageSettingsOpen(open); if (open) setToolbarNodeId(null); }} /> ), [configInputsById, confirmStopGeneration, handleConfigNodeChange, handleGenerateNode, handleNodePromptChange, mentionReferencesByNodeId, renderPluginPanel, runningNodeId], ); const renderNodeContentPanel = useCallback( (contentNode: CanvasNodeData) => ( setDialogNodeId((current) => (current === contentNode.id ? null : contentNode.id))} onStop={confirmStopGeneration} onGenerate={(nodeId) => { const target = nodesRef.current.find((item) => item.id === nodeId); void handleGenerateNode(nodeId, target?.metadata?.generationMode || "image", target?.metadata?.composerContent ?? target?.metadata?.prompt ?? ""); }} /> ), [configInputsById, confirmStopGeneration, handleConfigNodeChange, handleGenerateNode, runningNodeId], ); if (!projectLoaded) return ; return (
setTitleEditing(false)} canUndo={historyState.canUndo} canRedo={historyState.canRedo} onHome={() => navigate("/")} onProjects={() => navigate("/canvas")} onCreateProject={createAndOpenProject} onDeleteProject={deleteCurrentProject} onExportProject={exportCurrentProject} onImportImage={() => handleUploadRequest()} onOpenPlugins={() => setPluginManagerOpen(true)} onUndo={undoCanvas} onRedo={redoCanvas} agentOpen={agentPanelOpen} compactAgentStatus={{ connected: localAgentConnected, enabled: localAgentEnabled, activity: localAgentActivity }} onToggleAgent={toggleAgentPanel} /> { setViewport(next); setContextMenu(null); }} onCanvasMouseDown={handleCanvasMouseDown} onCanvasDeselect={deselectCanvas} onCanvasDoubleClick={(event) => { setContextMenu(null); setNodeCreatePosition(screenToCanvas(event.clientX, event.clientY)); }} onContextMenu={preventCanvasContextMenu} onDrop={handleDrop} > {connections .filter((connection) => { const from = nodeById.get(connection.fromNodeId); const to = nodeById.get(connection.toNodeId); return Boolean(from && to && !isHiddenBatchConnectionEndpoint(from, nodes) && !isHiddenBatchConnectionEndpoint(to, nodes)); }) .map((connection) => { const from = nodeById.get(connection.fromNodeId); const to = nodeById.get(connection.toNodeId); if (!from || !to) return null; return ( { setSelectedConnectionId(connection.id); setSelectedNodeIds(new Set()); setContextMenu(null); }} onContextMenu={(event) => { setSelectedConnectionId(connection.id); setSelectedNodeIds(new Set()); setContextMenu({ type: "connection", x: event.clientX, y: event.clientY, connectionId: connection.id }); }} /> ); })} {connectingParams ? : null} {visibleNodes.map((node) => ( ))} {selectionBox ? (
) : null} {pendingConnectionCreate ? createConnectedNode(type, pendingConnectionCreate)} onClose={cancelPendingConnectionCreate} /> : null} {nodeCreatePosition ? ( { createNode(type, nodeCreatePosition); setNodeCreatePosition(null); }} onClose={() => setNodeCreatePosition(null)} /> ) : null} setInfoNodeId(node.id)} onEditText={openTextEditor} onDecreaseFont={(node) => handleFontSizeChange(node.id, Math.max(10, (node.metadata?.fontSize || 14) - 2))} onIncreaseFont={(node) => handleFontSizeChange(node.id, Math.min(32, (node.metadata?.fontSize || 14) + 2))} onToggleDialog={(node) => setDialogNodeId((current) => (current === node.id ? null : node.id))} onGenerateImage={generateImageFromTextNode} onUpload={(node) => handleUploadRequest(node.id)} onDownload={downloadNodeImage} onSaveAsset={(node) => void saveNodeAsset(node)} onMaskEdit={(node) => setMaskEditNodeId(node.id)} onCrop={(node) => setCropNodeId(node.id)} onSplit={(node) => setSplitNodeId(node.id)} onUpscale={(node) => setUpscaleNodeId(node.id)} onSuperResolve={(node) => setSuperResolveNodeId(node.id)} onAngle={(node) => setAngleNodeId(node.id)} onViewImage={(node) => setPreviewNodeId(node.id)} onReversePrompt={createImageReversePromptNodes} onRetry={(node) => void handleRetryNode(node)} onToggleFreeResize={(node) => toggleNodeFreeResize(node.id)} onDelete={(node) => deleteNodes(new Set([node.id]))} /> createNode(CanvasNodeType.Image)} onAddVideo={() => createNode(CanvasNodeType.Video)} onAddAudio={() => createNode(CanvasNodeType.Audio)} onAddText={() => createNode(CanvasNodeType.Text)} onAddConfig={() => createNode(CanvasNodeType.Config)} onAddGroup={() => createNode(CanvasNodeType.Group)} onAddExtensionNode={(type) => createNode(type)} onUndo={undoCanvas} onRedo={redoCanvas} onUpload={() => handleUploadRequest()} onDelete={() => deleteNodes(new Set(selectedNodeIds))} onClear={() => setClearConfirmOpen(true)} onDeselect={deselectCanvas} onBackgroundModeChange={setBackgroundMode} onShowImageInfoChange={setShowImageInfo} /> {isMiniMapOpen ? : null} setIsMiniMapOpen((value) => !value)} /> {contextMenu ? ( setContextMenu(null)} onDuplicate={() => { if (contextMenu.type !== "node") return; duplicateNode(contextMenu.nodeId); setContextMenu(null); }} onDelete={() => { if (contextMenu.type === "node") { deleteNodes(new Set([contextMenu.nodeId])); } else { deleteConnection(contextMenu.connectionId); } setContextMenu(null); }} /> ) : null} setInfoNodeId(null)} /> setPluginManagerOpen(false)} /> {cropNode?.metadata?.content ? setCropNodeId(null)} onConfirm={(crop) => void cropImageNode(cropNode!, crop)} /> : null} {maskEditNode?.metadata?.content ? ( setMaskEditNodeId(null)} onConfirm={(payload) => void maskEditImageNode(maskEditNode!, payload)} /> ) : null} {splitNode?.metadata?.content ? setSplitNodeId(null)} onConfirm={(params) => void splitImageNode(splitNode!, params)} /> : null} {upscaleNode?.metadata?.content ? ( setUpscaleNodeId(null)} onConfirm={(params) => void upscaleImageNode(upscaleNode!, params)} /> ) : null} setSuperResolveNodeId(null)}>
暂未实现
{angleNode?.metadata?.content ? setAngleNodeId(null)} onConfirm={(params) => void generateAngleNode(angleNode!, params)} /> : null} setPreviewNodeId(null)} footer={null} width="auto" styles={{ body: { padding: 0, display: "flex", justifyContent: "center", alignItems: "center", maxHeight: "80vh" } }} > {previewNode?.metadata?.content ? {previewNode.title : null} setClearConfirmOpen(false)} footer={ <> } >

这会删除当前画布上的所有节点和连线。

setAssetPickerOpen(false)} />
); }