diff --git a/web/src/lib/canvas/canvas-node-copy.ts b/web/src/lib/canvas/canvas-node-copy.ts new file mode 100644 index 000000000..9cf53bcfc --- /dev/null +++ b/web/src/lib/canvas/canvas-node-copy.ts @@ -0,0 +1,80 @@ +import { resetGenerationTaskMetadata } from "@/lib/canvas/canvas-project-generation"; +import type { CanvasNodeData, CanvasNodeMetadata, StoryboardRow } from "@/types/canvas"; + +function remapReferenceId(nodeId: string | undefined, idMap: ReadonlyMap) { + return nodeId ? idMap.get(nodeId) || nodeId : undefined; +} + +function remapOwnedNodeId(nodeId: string | undefined, idMap: ReadonlyMap) { + return nodeId ? idMap.get(nodeId) : undefined; +} + +function remapReferenceIds(nodeIds: string[] | undefined, idMap: ReadonlyMap) { + return nodeIds ? Array.from(new Set(nodeIds.map((nodeId) => idMap.get(nodeId) || nodeId))) : undefined; +} + +function copyStoryboardRow(row: StoryboardRow, idMap: ReadonlyMap): StoryboardRow { + const imageNodeId = remapOwnedNodeId(row.imageNodeId, idMap); + const videoNodeId = remapOwnedNodeId(row.videoNodeId, idMap); + const hasCopiedOutput = Boolean(imageNodeId || videoNodeId); + return { + ...row, + characters: (row.characters || []).map((character) => ({ + ...character, + characterImageNodeId: remapReferenceId(character.characterImageNodeId, idMap), + })), + referenceNodeIds: remapReferenceIds(row.referenceNodeIds || [], idMap) || [], + imageNodeId, + videoNodeId, + status: hasCopiedOutput ? row.status : "idle", + errorDetails: undefined, + }; +} + +// 副本只能继承内容和用户引用,运行中任务、批次及指向源生成结果的关系必须隔离。 +export function isolateCopiedNodeMetadata(node: CanvasNodeData, idMap: ReadonlyMap): CanvasNodeMetadata { + const metadata = resetGenerationTaskMetadata(node.metadata, node.metadata?.content ? "success" : "idle"); + delete metadata.generationBatches; + delete metadata.batchRootId; + delete metadata.batchChildIds; + delete metadata.isBatchRoot; + delete metadata.primaryImageId; + delete metadata.imageBatchExpanded; + delete metadata.batchUsesReferenceImages; + delete metadata.versionOfNodeId; + delete metadata.versionLabel; + delete metadata.versionPrimary; + + metadata.frame = node.metadata?.frame ? { ...node.metadata.frame } : undefined; + metadata.referenceSetId = remapOwnedNodeId(node.metadata?.referenceSetId, idMap); + metadata.referenceAssetNodeIds = node.metadata?.referenceAssetNodeIds + ?.map((nodeId) => remapOwnedNodeId(nodeId, idMap)) + .filter((nodeId): nodeId is string => Boolean(nodeId)); + metadata.videoStartFrameNodeId = remapReferenceId(node.metadata?.videoStartFrameNodeId, idMap); + metadata.videoEndFrameNodeId = remapReferenceId(node.metadata?.videoEndFrameNodeId, idMap); + metadata.directorPreviewNodeId = remapOwnedNodeId(node.metadata?.directorPreviewNodeId, idMap); + metadata.directorDepthNodeId = remapOwnedNodeId(node.metadata?.directorDepthNodeId, idMap); + metadata.directorNormalNodeId = remapOwnedNodeId(node.metadata?.directorNormalNodeId, idMap); + + const characterViewNodeIds = node.metadata?.characterViewNodeIds; + const copiedCharacterViewNodeIds = characterViewNodeIds ? { + front: remapOwnedNodeId(characterViewNodeIds.front, idMap), + side: remapOwnedNodeId(characterViewNodeIds.side, idMap), + back: remapOwnedNodeId(characterViewNodeIds.back, idMap), + } : undefined; + metadata.characterViewNodeIds = copiedCharacterViewNodeIds && Object.values(copiedCharacterViewNodeIds).some(Boolean) + ? copiedCharacterViewNodeIds + : undefined; + metadata.emotionEdit = node.metadata?.emotionEdit ? { + ...node.metadata.emotionEdit, + sourceNodeId: remapReferenceId(node.metadata.emotionEdit.sourceNodeId, idMap)!, + faceBox: { ...node.metadata.emotionEdit.faceBox }, + editRegion: node.metadata.emotionEdit.editRegion ? { ...node.metadata.emotionEdit.editRegion } : undefined, + } : undefined; + metadata.storyboard = node.metadata?.storyboard ? { + rows: node.metadata.storyboard.rows.map((row) => copyStoryboardRow(row, idMap)), + visibleColumns: [...node.metadata.storyboard.visibleColumns], + referenceNodeIds: remapReferenceIds(node.metadata.storyboard.referenceNodeIds, idMap) || [], + } : undefined; + return metadata; +} diff --git a/web/src/pages/canvas/project.tsx b/web/src/pages/canvas/project.tsx index 8afd9f4b8..ce369f192 100644 --- a/web/src/pages/canvas/project.tsx +++ b/web/src/pages/canvas/project.tsx @@ -590,7 +590,9 @@ function InfiniteCanvasPage() { duplicateNode, hasCopiedNodes, pasteCopiedNodes, + releaseCopiedNodesPastePriority, setPrimaryVersion, + shouldPreferCopiedNodes, toggleNodeLocked, } = useCanvasNodeOperations({ projectId, @@ -953,6 +955,7 @@ function InfiniteCanvasPage() { cancelSelectionBox, copySelectedNodes, pasteCopiedNodes, + shouldPreferCopiedNodes, pasteSystemClipboard, deleteNodes, deleteConnection, @@ -977,9 +980,10 @@ function InfiniteCanvasPage() { const pasteAtPosition = useCallback( (position: Position) => { + if (shouldPreferCopiedNodes() && pasteCopiedNodes(position)) return; void (async () => { try { - // 右键粘贴同样优先系统剪贴板图片,再回退内部节点。 + // 标记写入成功时仍优先系统图片,兼容截图和从外部应用复制的媒体。 const handled = await pasteSystemClipboard(position); if (!handled) pasteCopiedNodes(position); } catch { @@ -987,11 +991,12 @@ function InfiniteCanvasPage() { } })(); }, - [message, pasteCopiedNodes, pasteSystemClipboard], + [message, pasteCopiedNodes, pasteSystemClipboard, shouldPreferCopiedNodes], ); const copyNodeContentToClipboard = useCallback( async (node: CanvasNodeData | null) => { + releaseCopiedNodesPastePriority(); const content = node?.metadata?.content; if (!node || !content) { message.warning("没有可复制的内容"); @@ -1017,11 +1022,12 @@ function InfiniteCanvasPage() { message.error("复制失败,请检查浏览器剪贴板权限"); } }, - [message], + [message, releaseCopiedNodesPastePriority], ); const copyNodeMediaUrlToClipboard = useCallback( async (node: CanvasNodeData | null) => { + releaseCopiedNodesPastePriority(); try { const storageKey = node?.metadata?.storageKey; const content = node?.metadata?.content?.trim(); @@ -1030,13 +1036,13 @@ function InfiniteCanvasPage() { const mediaURL = mediaPath ? new URL(mediaPath, window.location.href).toString() : ""; if (!mediaURL) throw new Error("当前媒体只有本地内容,没有可复制的地址"); if (navigator.clipboard?.writeText) await navigator.clipboard.writeText(mediaURL); - else if (!copyToClipboard(mediaURL)) throw new Error("当前浏览器不支持写入剪贴板"); + else if (!await copyToClipboard(mediaURL)) throw new Error("当前浏览器不支持写入剪贴板"); message.success(node?.type === CanvasNodeType.Video ? "视频地址已复制" : "图片地址已复制"); } catch (error) { message.error(error instanceof Error ? error.message : "媒体地址复制失败"); } }, - [message], + [message, releaseCopiedNodesPastePriority], ); const handleCanvasContextMenu = useCallback( diff --git a/web/src/pages/canvas/use-canvas-keyboard.ts b/web/src/pages/canvas/use-canvas-keyboard.ts index 0133f9b18..7f75c5c89 100644 --- a/web/src/pages/canvas/use-canvas-keyboard.ts +++ b/web/src/pages/canvas/use-canvas-keyboard.ts @@ -23,6 +23,7 @@ type UseCanvasKeyboardOptions = { cancelSelectionBox: () => void; copySelectedNodes: () => void; pasteCopiedNodes: () => boolean; + shouldPreferCopiedNodes: () => boolean; pasteSystemClipboard: (position?: undefined, clipboardEvent?: ClipboardEvent | null) => Promise | boolean; deleteNodes: (ids: Set) => void; deleteConnection: (connectionId: string) => void; @@ -50,6 +51,7 @@ export function useCanvasKeyboard({ cancelSelectionBox, copySelectedNodes, pasteCopiedNodes, + shouldPreferCopiedNodes, pasteSystemClipboard, deleteNodes, deleteConnection, @@ -125,8 +127,9 @@ export function useCanvasKeyboard({ const handlePaste = (event: ClipboardEvent) => { 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; - // 系统剪贴板有图片时优先粘贴图片;否则再粘贴画布内部复制的节点。 + // 节点标记写入失败或仍在写入时避开旧系统图片,其余情况保持系统内容优先。 event.preventDefault(); + if (shouldPreferCopiedNodes() && pasteCopiedNodes()) return; void (async () => { const handled = await pasteSystemClipboard(undefined, event); if (!handled) pasteCopiedNodes(); @@ -139,5 +142,5 @@ export function useCanvasKeyboard({ window.removeEventListener("keydown", handleKeyDown, true); window.removeEventListener("paste", handlePaste, true); }; - }, [cancelSelectionBox, copySelectedNodes, deleteConnection, deleteNodes, deselectCanvas, fitCanvasContent, fitCanvasSelection, nodesRef, pasteCopiedNodes, pasteSystemClipboard, redoCanvas, saveCanvasProject, selectedConnectionId, selectedNodeIdsRef, setAnnotationNodeId, setContextMenu, setCropNodeId, setInfoNodeId, setMaskEditNodeId, setSelectedConnectionId, setSelectedNodeIds, setShortcutRequestNonce, undoCanvas, zoomToActualSize]); + }, [cancelSelectionBox, copySelectedNodes, deleteConnection, deleteNodes, deselectCanvas, fitCanvasContent, fitCanvasSelection, nodesRef, pasteCopiedNodes, pasteSystemClipboard, redoCanvas, saveCanvasProject, selectedConnectionId, selectedNodeIdsRef, setAnnotationNodeId, setContextMenu, setCropNodeId, setInfoNodeId, setMaskEditNodeId, setSelectedConnectionId, setSelectedNodeIds, setShortcutRequestNonce, shouldPreferCopiedNodes, undoCanvas, zoomToActualSize]); } diff --git a/web/src/pages/canvas/use-canvas-node-operations.ts b/web/src/pages/canvas/use-canvas-node-operations.ts index f052a0bcc..8b4535740 100644 --- a/web/src/pages/canvas/use-canvas-node-operations.ts +++ b/web/src/pages/canvas/use-canvas-node-operations.ts @@ -1,13 +1,15 @@ -import { useCallback, useRef, useState, type Dispatch, type SetStateAction } from "react"; +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 CanvasNodeMetadata, type ContextMenuState, type Position } from "@/types/canvas"; +import { CanvasNodeType, type CanvasConnection, type CanvasNodeData, type ContextMenuState, type Position } from "@/types/canvas"; import { cloneCanvasDrawing } from "@/lib/canvas/canvas-drawing-storage"; type CanvasClipboard = { @@ -30,9 +32,6 @@ type UseCanvasNodeOperationsOptions = { onNodesDeleted: (removedIds: Set, nextNodes: CanvasNodeData[], removedNodes: CanvasNodeData[]) => void; }; -const NODE_STATUS_IDLE = "idle" as const; -const NODE_STATUS_SUCCESS = "success" as const; - export function useCanvasNodeOperations({ projectId, nodesRef, @@ -50,8 +49,30 @@ export function useCanvasNodeOperations({ 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); @@ -256,7 +277,7 @@ export function useCanvasNodeOperations({ 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: CanvasNodeMetadata = { ...node.metadata, frame: node.metadata?.frame ? { ...node.metadata.frame } : undefined }; + const metadata = isolateCopiedNodeMetadata(node, idMap); if (node.type === CanvasNodeType.Drawing) { metadata.drawingId = `${idMap.get(node.id)}-document`; metadata.drawingRevision = 0; @@ -265,20 +286,6 @@ export function useCanvasNodeOperations({ metadata.drawingPageCount = 1; } if (node.id === source.id && versionRootId) { - delete metadata.taskId; - delete metadata.taskStatus; - delete metadata.taskProgress; - delete metadata.taskStage; - delete metadata.taskCreatedAt; - delete metadata.taskUpdatedAt; - delete metadata.errorDetails; - delete metadata.batchRootId; - delete metadata.batchChildIds; - delete metadata.isBatchRoot; - delete metadata.primaryImageId; - delete metadata.imageBatchExpanded; - delete metadata.batchUsesReferenceImages; - metadata.status = metadata.content ? NODE_STATUS_SUCCESS : NODE_STATUS_IDLE; metadata.versionOfNodeId = versionRootId; metadata.versionLabel = versionLabel; metadata.versionPrimary = false; @@ -338,17 +345,21 @@ export function useCanvasNodeOperations({ connections: connectionsRef.current.filter((connection) => copyIds.has(connection.fromNodeId) && copyIds.has(connection.toNodeId)).map((connection) => ({ ...connection })), }; setHasCopiedNodes(true); - // 写入系统剪贴板标记,覆盖旧图片,避免 Ctrl+V 仍优先粘贴系统图片。 + // 写入完成前或写入失败时优先内部节点,避免快速粘贴读到系统残留图片。 const marker = `open-ai-canvas-nodes:${Date.now()}:${copiedNodes.length}`; - try { - if (typeof ClipboardItem !== "undefined" && navigator.clipboard?.write) { - void navigator.clipboard.write([new ClipboardItem({ "text/plain": new Blob([marker], { type: "text/plain" }) })]); - } else if (navigator.clipboard?.writeText) { - void navigator.clipboard.writeText(marker); - } - } catch { - // 忽略剪贴板权限失败,内部剪贴板仍可用。 - } + 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(() => { @@ -370,29 +381,7 @@ export function useCanvasNodeOperations({ 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 = node.metadata ? { ...node.metadata, frame: node.metadata.frame ? { ...node.metadata.frame } : undefined } : undefined; - if (metadata) { - // 粘贴必须切断与源批次/任务的绑定,否则拖拽会通过 batchChildIds 带动旧结果。 - delete metadata.taskId; - delete metadata.taskStatus; - delete metadata.taskProgress; - delete metadata.taskStage; - delete metadata.taskCreatedAt; - delete metadata.taskUpdatedAt; - delete metadata.errorDetails; - delete metadata.generationErrorCode; - delete metadata.failedPromptFingerprint; - delete metadata.batchRootId; - delete metadata.batchChildIds; - delete metadata.isBatchRoot; - delete metadata.primaryImageId; - delete metadata.imageBatchExpanded; - delete metadata.batchUsesReferenceImages; - delete metadata.versionOfNodeId; - delete metadata.versionLabel; - delete metadata.versionPrimary; - metadata.status = metadata.content ? NODE_STATUS_SUCCESS : NODE_STATUS_IDLE; - } + const metadata = isolateCopiedNodeMetadata(node, idMap); if (node.type === CanvasNodeType.Drawing && metadata) { metadata.drawingId = `${idMap.get(node.id)}-document`; metadata.drawingRevision = 0; @@ -451,7 +440,9 @@ export function useCanvasNodeOperations({ duplicateNode, hasCopiedNodes, pasteCopiedNodes, + releaseCopiedNodesPastePriority, setPrimaryVersion, + shouldPreferCopiedNodes, toggleNodeLocked, }; }