import { useCallback, useEffect, useRef, useState, type Dispatch, type SetStateAction } from "react"; import { App } from "antd"; import copyToClipboard from "copy-to-clipboard"; import { nanoid } from "nanoid"; import { FRAME_HEADER_HEIGHT, getFrameChildIds, getFrameChildren, isFrameNode } from "@/lib/canvas/canvas-frame"; import { alignCanvasNodes, layoutCanvasFlow, layoutCanvasNodes, nextCanvasVersionLabel, type CanvasAlignmentMode } from "@/lib/canvas/canvas-layout"; import { createCanvasNode, removeCanvasNodes } from "@/lib/canvas/canvas-project-domain"; import { getGenerationCount } from "@/lib/canvas/canvas-project-generation"; import { isolateCopiedNodeMetadata } from "@/lib/canvas/canvas-node-copy"; import { useEffectiveConfig } from "@/stores/use-config-store"; import { CanvasNodeType, type CanvasConnection, type CanvasNodeData, type ContextMenuState, type Position } from "@/types/canvas"; import { cloneCanvasDrawing } from "@/lib/canvas/canvas-drawing-storage"; type CanvasClipboard = { nodes: CanvasNodeData[]; connections: CanvasConnection[]; }; type UseCanvasNodeOperationsOptions = { projectId: string; nodesRef: { current: CanvasNodeData[] }; connectionsRef: { current: CanvasConnection[] }; selectedNodeIdsRef: { current: Set }; getCanvasCenter: () => Position; setNodes: Dispatch>; setConnections: Dispatch>; setSelectedNodeIds: Dispatch>>; setSelectedConnectionId: Dispatch>; setContextMenu: Dispatch>; setDialogNodeId: Dispatch>; onNodesDeleted: (removedIds: Set, nextNodes: CanvasNodeData[], removedNodes: CanvasNodeData[]) => void; }; export function useCanvasNodeOperations({ projectId, nodesRef, connectionsRef, selectedNodeIdsRef, getCanvasCenter, setNodes, setConnections, setSelectedNodeIds, setSelectedConnectionId, setContextMenu, setDialogNodeId, onNodesDeleted, }: UseCanvasNodeOperationsOptions) { const { message } = App.useApp(); const effectiveConfig = useEffectiveConfig(); const clipboardRef = useRef(null); const preferCopiedNodesRef = useRef(false); const markerWritePendingRef = useRef(false); const markerWriteSequenceRef = useRef(0); const [hasCopiedNodes, setHasCopiedNodes] = useState(false); const releaseCopiedNodesPastePriority = useCallback(() => { markerWriteSequenceRef.current += 1; markerWritePendingRef.current = false; preferCopiedNodesRef.current = false; }, []); const shouldPreferCopiedNodes = useCallback(() => markerWritePendingRef.current || preferCopiedNodesRef.current, []); useEffect(() => { window.addEventListener("copy", releaseCopiedNodesPastePriority); window.addEventListener("cut", releaseCopiedNodesPastePriority); window.addEventListener("blur", releaseCopiedNodesPastePriority); return () => { window.removeEventListener("copy", releaseCopiedNodesPastePriority); window.removeEventListener("cut", releaseCopiedNodesPastePriority); window.removeEventListener("blur", releaseCopiedNodesPastePriority); }; }, [releaseCopiedNodesPastePriority]); const commitNodes = useCallback((nextNodes: CanvasNodeData[]) => { nodesRef.current = nextNodes; setNodes(nextNodes); }, [nodesRef, setNodes]); const commitConnections = useCallback((nextConnections: CanvasConnection[]) => { connectionsRef.current = nextConnections; setConnections(nextConnections); }, [connectionsRef, setConnections]); const cloneDrawingForNode = useCallback((source: CanvasNodeData, target: CanvasNodeData, failureMessage: string) => { const sourceDrawingId = source.metadata?.drawingId; const targetDrawingId = target.metadata?.drawingId; if (!projectId || !sourceDrawingId || !targetDrawingId) return; void cloneCanvasDrawing(projectId, sourceDrawingId, targetDrawingId).then((saved) => { if (!saved) { if (source.metadata?.drawingShapeCount) message.warning("原绘图内容未在本机找到,已创建空白副本"); return; } // 克隆落盘后提升修订号,确保已经挂载的新卡片重新读取派生预览。 commitNodes(nodesRef.current.map((node) => node.id === target.id ? { ...node, metadata: { ...node.metadata, drawingRevision: saved.revision, drawingUpdatedAt: saved.updatedAt, drawingShapeCount: saved.shapeCount, drawingPageCount: saved.pageCount, }, } : node)); }).catch(() => message.error(failureMessage)); }, [commitNodes, message, nodesRef, projectId]); const selectNodes = useCallback((ids: Set) => { selectedNodeIdsRef.current = ids; setSelectedNodeIds(ids); setSelectedConnectionId(null); }, [selectedNodeIdsRef, setSelectedConnectionId, setSelectedNodeIds]); const createNode = useCallback((type: CanvasNodeType, position?: Position) => { const configMetadata = type === CanvasNodeType.Config ? { model: effectiveConfig.imageModel || effectiveConfig.model, size: effectiveConfig.size, count: getGenerationCount(effectiveConfig.canvasImageCount || effectiveConfig.count), } : undefined; const node = createCanvasNode(type, position || getCanvasCenter(), configMetadata); commitNodes([...nodesRef.current, node]); selectNodes(new Set([node.id])); if (type !== CanvasNodeType.Text && type !== CanvasNodeType.Script && type !== CanvasNodeType.Audio && type !== CanvasNodeType.Frame && type !== CanvasNodeType.Drawing) setDialogNodeId(node.id); }, [commitNodes, effectiveConfig.canvasImageCount, effectiveConfig.count, effectiveConfig.imageModel, effectiveConfig.model, effectiveConfig.size, getCanvasCenter, nodesRef, selectNodes, setDialogNodeId]); const arrangeSelectedNodes = useCallback((mode: "row" | "column" | "grid" | "flow") => { const selected = nodesRef.current.filter((node) => selectedNodeIdsRef.current.has(node.id) && !node.metadata?.locked && !isFrameNode(node)); if (selected.length < 2) return; const positions = mode === "flow" ? layoutCanvasFlow(selected, connectionsRef.current) : layoutCanvasNodes(selected, mode); commitNodes(nodesRef.current.map((node) => positions.has(node.id) ? { ...node, position: positions.get(node.id)! } : node)); message.success(mode === "flow" ? "已按连线整理" : "已整理选中节点"); }, [commitNodes, connectionsRef, message, nodesRef, selectedNodeIdsRef]); const alignSelectedNodes = useCallback((mode: CanvasAlignmentMode) => { const selected = nodesRef.current.filter((node) => selectedNodeIdsRef.current.has(node.id) && !node.metadata?.locked && !isFrameNode(node)); if (selected.length < 2 || ((mode === "distributeX" || mode === "distributeY") && selected.length < 3)) return; const positions = alignCanvasNodes(selected, mode); commitNodes(nodesRef.current.map((node) => positions.has(node.id) ? { ...node, position: positions.get(node.id)! } : node)); message.success(mode === "distributeX" || mode === "distributeY" ? "已等距分布选中节点" : "已对齐选中节点"); }, [commitNodes, message, nodesRef, selectedNodeIdsRef]); const createStoryboardGroup = useCallback(() => { const images = nodesRef.current .filter((node) => selectedNodeIdsRef.current.has(node.id) && !node.metadata?.locked && node.type === CanvasNodeType.Image && Boolean(node.metadata?.content)) .sort((a, b) => a.position.y - b.position.y || a.position.x - b.position.x); if (images.length < 2) { message.warning("请至少选择两张已有图片"); return; } const gap = 24; const padding = 24; const columns = Math.min(4, Math.ceil(Math.sqrt(images.length))); const rows = Math.ceil(images.length / columns); const cellWidth = Math.max(...images.map((node) => node.width)); const cellHeight = Math.max(...images.map((node) => node.height)); const left = Math.min(...images.map((node) => node.position.x)); const top = Math.min(...images.map((node) => node.position.y)); const frameWidth = padding * 2 + columns * cellWidth + (columns - 1) * gap; const frameHeight = FRAME_HEADER_HEIGHT + padding * 2 + rows * cellHeight + (rows - 1) * gap; const frame = createCanvasNode(CanvasNodeType.Frame, { x: left + frameWidth / 2 - padding, y: top + frameHeight / 2 - FRAME_HEADER_HEIGHT - padding }, { workflowKind: "storyboard", workflowTitle: "分镜组", frame: { collapsed: false, expandedWidth: frameWidth, expandedHeight: frameHeight }, }); frame.title = `分镜组 · ${images.length} 镜`; frame.position = { x: left - padding, y: top - FRAME_HEADER_HEIGHT - padding }; frame.width = frameWidth; frame.height = frameHeight; const imageIndex = new Map(images.map((node, index) => [node.id, index])); const nextNodes: CanvasNodeData[] = [ ...nodesRef.current.map((node) => { const index = imageIndex.get(node.id); if (index === undefined) return node; const column = index % columns; const row = Math.floor(index / columns); return { ...node, parentId: frame.id, position: { x: frame.position.x + padding + column * (cellWidth + gap) + (cellWidth - node.width) / 2, y: frame.position.y + FRAME_HEADER_HEIGHT + padding + row * (cellHeight + gap) + (cellHeight - node.height) / 2, }, metadata: { ...node.metadata, workflowKind: node.metadata?.workflowKind || "shot", shotIndex: node.metadata?.shotIndex || index + 1 }, }; }), frame, ]; commitNodes(nextNodes); selectNodes(new Set([frame.id])); message.success(`已创建 ${images.length} 镜分镜组`); }, [commitNodes, message, nodesRef, selectNodes, selectedNodeIdsRef]); const createReferenceGroup = useCallback(() => { const media = nodesRef.current .filter((node) => selectedNodeIdsRef.current.has(node.id) && !node.metadata?.locked && (node.type === CanvasNodeType.Image || node.type === CanvasNodeType.Video) && Boolean(node.metadata?.content)) .sort((a, b) => a.position.y - b.position.y || a.position.x - b.position.x); if (media.length < 2) { message.warning("请至少选择两个已有图片或视频节点"); return; } const gap = 20; const padding = 24; const columns = Math.min(3, Math.ceil(Math.sqrt(media.length))); const rows = Math.ceil(media.length / columns); const cellWidth = Math.max(...media.map((node) => node.width)); const cellHeight = Math.max(...media.map((node) => node.height)); const left = Math.min(...media.map((node) => node.position.x)); const top = Math.min(...media.map((node) => node.position.y)); const frameWidth = padding * 2 + columns * cellWidth + (columns - 1) * gap; const frameHeight = FRAME_HEADER_HEIGHT + padding * 2 + rows * cellHeight + (rows - 1) * gap; const frame = createCanvasNode(CanvasNodeType.Frame, { x: left + frameWidth / 2, y: top + frameHeight / 2 }, { workflowKind: "reference_set", workflowTitle: "引用组", referenceAssetNodeIds: media.map((node) => node.id), frame: { collapsed: false, expandedWidth: frameWidth, expandedHeight: frameHeight }, }); frame.title = `引用组 · ${media.length} 项`; frame.position = { x: left - padding, y: top - FRAME_HEADER_HEIGHT - padding }; frame.width = frameWidth; frame.height = frameHeight; const mediaIndex = new Map(media.map((node, index) => [node.id, index])); commitNodes([ ...nodesRef.current.map((node) => { const index = mediaIndex.get(node.id); if (index === undefined) return node; const column = index % columns; const row = Math.floor(index / columns); return { ...node, parentId: frame.id, position: { x: frame.position.x + padding + column * (cellWidth + gap) + (cellWidth - node.width) / 2, y: frame.position.y + FRAME_HEADER_HEIGHT + padding + row * (cellHeight + gap) + (cellHeight - node.height) / 2, }, metadata: { ...node.metadata, referenceSetId: frame.id }, }; }), frame, ]); selectNodes(new Set([frame.id])); message.success(`已创建 ${media.length} 项引用组,折叠后可作为路由节点`); }, [commitNodes, message, nodesRef, selectNodes, selectedNodeIdsRef]); const toggleNodeLocked = useCallback((nodeId: string) => { const target = nodesRef.current.find((node) => node.id === nodeId); if (!target) return; const locked = !target.metadata?.locked; commitNodes(nodesRef.current.map((node) => node.id === nodeId ? { ...node, metadata: { ...node.metadata, locked } } : node)); message.success(locked ? "节点已锁定位置和尺寸" : "节点已解锁"); }, [commitNodes, message, nodesRef]); const deleteNodes = useCallback((ids: Set) => { if (!ids.size) return; const result = removeCanvasNodes(nodesRef.current, ids); const removedNodes = nodesRef.current.filter((node) => result.removedIds.has(node.id)); const nextConnections = connectionsRef.current.filter((connection) => !result.removedIds.has(connection.fromNodeId) && !result.removedIds.has(connection.toNodeId)); commitNodes(result.nodes); commitConnections(nextConnections); selectNodes(new Set()); onNodesDeleted(result.removedIds, result.nodes, removedNodes); }, [commitConnections, commitNodes, connectionsRef, nodesRef, onNodesDeleted, selectNodes]); const deleteConnection = useCallback((connectionId: string) => { commitConnections(connectionsRef.current.filter((connection) => connection.id !== connectionId)); setSelectedConnectionId((current) => current === connectionId ? null : current); setContextMenu((current) => current?.type === "connection" && current.connectionId === connectionId ? null : current); }, [commitConnections, connectionsRef, setContextMenu, setSelectedConnectionId]); const duplicateNode = useCallback((nodeId: string) => { const source = nodesRef.current.find((node) => node.id === nodeId); if (!source) return; const sources = isFrameNode(source) ? [source, ...getFrameChildren(source.id, nodesRef.current)] : [source]; const idMap = new Map(sources.map((node, index) => [node.id, `${node.type}-${Date.now()}-${index}-${Math.random().toString(36).slice(2, 7)}`])); const versionRootId = isFrameNode(source) ? undefined : source.metadata?.versionOfNodeId || source.id; const versionLabel = versionRootId ? nextCanvasVersionLabel(versionRootId, nodesRef.current) : undefined; const copiedNodes = sources.map((node) => { const metadata = isolateCopiedNodeMetadata(node, idMap); if (node.type === CanvasNodeType.Drawing) { metadata.drawingId = `${idMap.get(node.id)}-document`; metadata.drawingRevision = 0; metadata.drawingUpdatedAt = undefined; metadata.drawingShapeCount = 0; metadata.drawingPageCount = 1; } if (node.id === source.id && versionRootId) { metadata.versionOfNodeId = versionRootId; metadata.versionLabel = versionLabel; metadata.versionPrimary = false; } return { ...node, id: idMap.get(node.id)!, title: node.id === source.id ? `${node.title.replace(/ · [A-Z]$/, "")} · ${versionLabel || "副本"}` : node.title, position: { x: node.position.x + 36, y: node.position.y + 36 }, parentId: node.parentId ? idMap.get(node.parentId) || node.parentId : undefined, metadata, }; }); const copiedIds = new Set(sources.map((node) => node.id)); const copiedConnections = connectionsRef.current .filter((connection) => copiedIds.has(connection.fromNodeId) && copiedIds.has(connection.toNodeId)) .map((connection) => ({ ...connection, id: nanoid(), fromNodeId: idMap.get(connection.fromNodeId)!, toNodeId: idMap.get(connection.toNodeId)! })); if (!isFrameNode(source)) { connectionsRef.current.filter((connection) => connection.toNodeId === source.id && !copiedIds.has(connection.fromNodeId)).forEach((connection) => copiedConnections.push({ ...connection, id: nanoid(), toNodeId: idMap.get(source.id)! })); } const id = idMap.get(source.id)!; const nextNodes = [ ...nodesRef.current.map((node) => node.id === source.id && versionRootId && !node.metadata?.versionLabel ? { ...node, title: `${node.title} · A`, metadata: { ...node.metadata, versionOfNodeId: versionRootId, versionLabel: "A", versionPrimary: true } } : node), ...copiedNodes, ]; commitNodes(nextNodes); commitConnections([...connectionsRef.current, ...copiedConnections]); selectNodes(new Set([id])); const sourceByTargetId = new Map(sources.map((sourceNode) => [idMap.get(sourceNode.id), sourceNode])); copiedNodes.filter((node) => node.type === CanvasNodeType.Drawing).forEach((targetNode) => { const sourceNode = sourceByTargetId.get(targetNode.id); if (sourceNode) cloneDrawingForNode(sourceNode, targetNode, "绘图副本保存失败,请重新复制"); }); if (!isFrameNode(source) && source.type !== CanvasNodeType.Drawing) setDialogNodeId(id); }, [cloneDrawingForNode, commitConnections, commitNodes, connectionsRef, nodesRef, selectNodes, setDialogNodeId]); const setPrimaryVersion = useCallback((nodeId: string) => { const target = nodesRef.current.find((node) => node.id === nodeId); if (!target) return; const rootId = target.metadata?.versionOfNodeId || target.id; commitNodes(nodesRef.current.map((node) => (node.metadata?.versionOfNodeId || node.id) === rootId ? { ...node, metadata: { ...node.metadata, versionPrimary: node.id === nodeId } } : node)); message.success(`已将 ${target.metadata?.versionLabel || target.title} 设为主版本`); }, [commitNodes, message, nodesRef]); const copyNodesToClipboard = useCallback((targetIds: Set) => { if (!targetIds.size) return; const copyIds = new Set(targetIds); nodesRef.current.forEach((node) => { if (targetIds.has(node.id) && isFrameNode(node)) getFrameChildIds(node.id, nodesRef.current).forEach((childId) => copyIds.add(childId)); }); const copiedNodes = nodesRef.current .filter((node) => copyIds.has(node.id)) .map((node) => ({ ...node, position: { ...node.position }, metadata: node.metadata ? { ...node.metadata, frame: node.metadata.frame ? { ...node.metadata.frame } : undefined } : undefined })); if (!copiedNodes.length) return; clipboardRef.current = { nodes: copiedNodes, connections: connectionsRef.current.filter((connection) => copyIds.has(connection.fromNodeId) && copyIds.has(connection.toNodeId)).map((connection) => ({ ...connection })), }; setHasCopiedNodes(true); // 写入完成前或写入失败时优先内部节点,避免快速粘贴读到系统残留图片。 const marker = `open-ai-canvas-nodes:${Date.now()}:${copiedNodes.length}`; const sequence = markerWriteSequenceRef.current + 1; markerWriteSequenceRef.current = sequence; markerWritePendingRef.current = true; preferCopiedNodesRef.current = true; void copyToClipboard(marker, { format: "text/plain" }).then((written) => { if (markerWriteSequenceRef.current !== sequence) return; markerWritePendingRef.current = false; preferCopiedNodesRef.current = !written; }, () => { if (markerWriteSequenceRef.current !== sequence) return; markerWritePendingRef.current = false; preferCopiedNodesRef.current = true; }); }, [connectionsRef, nodesRef]); const copySelectedNodes = useCallback(() => { copyNodesToClipboard(new Set(selectedNodeIdsRef.current)); }, [copyNodesToClipboard, selectedNodeIdsRef]); const pasteCopiedNodes = useCallback((position?: Position) => { const clipboard = clipboardRef.current; if (!clipboard?.nodes.length) return false; const center = position || getCanvasCenter(); const bounds = clipboard.nodes.reduce((current, node) => ({ left: Math.min(current.left, node.position.x), top: Math.min(current.top, node.position.y), right: Math.max(current.right, node.position.x + node.width), bottom: Math.max(current.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(clipboard.nodes.map((node, index) => [node.id, `${node.type}-${Date.now()}-${index}-${Math.random().toString(36).slice(2, 7)}`])); const copiedSourceIds = new Set(clipboard.nodes.map((node) => node.id)); const nextNodes = clipboard.nodes.map((node) => { const metadata = isolateCopiedNodeMetadata(node, idMap); if (node.type === CanvasNodeType.Drawing && metadata) { metadata.drawingId = `${idMap.get(node.id)}-document`; metadata.drawingRevision = 0; metadata.drawingUpdatedAt = undefined; metadata.drawingShapeCount = 0; metadata.drawingPageCount = 1; } return { ...node, id: idMap.get(node.id)!, title: node.title.endsWith(" Copy") ? node.title : `${node.title} Copy`, position: { x: node.position.x + dx, y: node.position.y + dy }, parentId: node.parentId ? idMap.get(node.parentId) : undefined, metadata, }; }); // 1) 剪贴板内部连线;2) 仍保留到画布上未复制参考节点的入边(只复制结果节点时常见)。 const nextConnections = clipboard.connections.flatMap((connection, index) => { const fromNodeId = idMap.get(connection.fromNodeId); const toNodeId = idMap.get(connection.toNodeId); return fromNodeId && toNodeId ? [{ ...connection, id: `conn-${Date.now()}-${index}-${Math.random().toString(36).slice(2, 7)}`, fromNodeId, toNodeId }] : []; }); connectionsRef.current.forEach((connection) => { if (!copiedSourceIds.has(connection.toNodeId) || copiedSourceIds.has(connection.fromNodeId)) return; const toNodeId = idMap.get(connection.toNodeId); if (!toNodeId) return; // 参考节点仍在画布上时,粘贴结果应重新挂上同一入边。 if (!nodesRef.current.some((node) => node.id === connection.fromNodeId)) return; nextConnections.push({ ...connection, id: nanoid(), toNodeId }); }); commitNodes([...nodesRef.current, ...nextNodes]); commitConnections([...connectionsRef.current, ...nextConnections]); const sourceByTargetId = new Map(clipboard.nodes.map((sourceNode) => [idMap.get(sourceNode.id), sourceNode])); nextNodes.filter((node) => node.type === CanvasNodeType.Drawing).forEach((targetNode) => { const sourceNode = sourceByTargetId.get(targetNode.id); if (sourceNode) cloneDrawingForNode(sourceNode, targetNode, "绘图副本保存失败,请重新粘贴"); }); const topLevelIds = new Set(nextNodes.filter((node) => !node.parentId).map((node) => node.id)); selectNodes(topLevelIds); setContextMenu(null); const primaryNode = nextNodes.find((node) => !node.parentId); setDialogNodeId(primaryNode && !isFrameNode(primaryNode) && primaryNode.type !== CanvasNodeType.Drawing ? primaryNode.id : null); return true; }, [cloneDrawingForNode, commitConnections, commitNodes, connectionsRef, getCanvasCenter, nodesRef, selectNodes, setContextMenu, setDialogNodeId]); return { alignSelectedNodes, arrangeSelectedNodes, copyNodesToClipboard, copySelectedNodes, createNode, createReferenceGroup, createStoryboardGroup, deleteConnection, deleteNodes, duplicateNode, hasCopiedNodes, pasteCopiedNodes, releaseCopiedNodesPastePriority, setPrimaryVersion, shouldPreferCopiedNodes, toggleNodeLocked, }; }