diff --git a/web/src/lib/canvas/canvas-node-copy.ts b/web/src/lib/canvas/canvas-node-copy.ts index 9cf53bcfc..9610e4092 100644 --- a/web/src/lib/canvas/canvas-node-copy.ts +++ b/web/src/lib/canvas/canvas-node-copy.ts @@ -45,6 +45,7 @@ export function isolateCopiedNodeMetadata(node: CanvasNodeData, idMap: ReadonlyM delete metadata.versionLabel; delete metadata.versionPrimary; + metadata.copiedFromNodeId = node.id; metadata.frame = node.metadata?.frame ? { ...node.metadata.frame } : undefined; metadata.referenceSetId = remapOwnedNodeId(node.metadata?.referenceSetId, idMap); metadata.referenceAssetNodeIds = node.metadata?.referenceAssetNodeIds diff --git a/web/src/pages/canvas/canvas-image-generation-executor.ts b/web/src/pages/canvas/canvas-image-generation-executor.ts index a853efc10..dd3968944 100644 --- a/web/src/pages/canvas/canvas-image-generation-executor.ts +++ b/web/src/pages/canvas/canvas-image-generation-executor.ts @@ -41,6 +41,7 @@ export async function executeImageGeneration({ const isConfigNode = sourceNode?.type === CanvasNodeType.Config; const isImageNode = sourceNode?.type === CanvasNodeType.Image; const reuseSourceNode = canGenerateImageInPlace(sourceNode); + const directCopiedBatch = count > 1 && isImageNode && Boolean(sourceNode?.metadata?.content) && (Boolean(sourceNode?.metadata?.copiedFromNodeId) || sourceNode?.title.endsWith(" Copy")); // 已有图片生成新结果并保留旧版本;参考图只来自入边,避免把旧结果误当成自身输入。 const referenceImages = generationContext.referenceImages; const generationType = referenceImages.length ? ("edit" as const) : ("generation" as const); @@ -57,10 +58,10 @@ export async function executeImageGeneration({ const parentPosition = sourceNode?.position || { x: 0, y: 0 }; const parentWidth = sourceNode?.width || parentConfig.width; const parentHeight = sourceNode?.height || parentConfig.height; - const rootId = reuseSourceNode ? nodeId : nanoid(); + const rootId = reuseSourceNode || directCopiedBatch ? nodeId : nanoid(); const childIds = count > 1 ? Array.from({ length: count }, () => nanoid()) : []; const targetIds = count > 1 ? childIds : [rootId]; - registerPendingNodeIds(reuseSourceNode ? childIds : [rootId, ...childIds]); + registerPendingNodeIds(reuseSourceNode || directCopiedBatch ? childIds : [rootId, ...childIds]); const rootWidth = outputNodeSize.width; const rootHeight = outputNodeSize.height; const preferredPosition = { @@ -97,9 +98,11 @@ export async function executeImageGeneration({ position: imageGenerationChildPosition(rootNode.position, rootNode.width, outputNodeSize, index), width: outputNodeSize.width, height: outputNodeSize.height, - metadata: { prompt: effectivePrompt, status: NODE_STATUS_LOADING, size: generationConfig.size, batchRootId: count > 1 ? rootId : undefined, ...generationMetadata, generationErrorCode: undefined, failedPromptFingerprint: undefined }, + metadata: { prompt: effectivePrompt, status: NODE_STATUS_LOADING, size: generationConfig.size, batchRootId: count > 1 && !directCopiedBatch ? rootId : undefined, ...generationMetadata, generationErrorCode: undefined, failedPromptFingerprint: undefined }, })); - const batchConnections = [...(reuseSourceNode ? [] : [{ id: nanoid(), fromNodeId: nodeId, toNodeId: rootId }]), ...childIds.map((childId) => ({ id: nanoid(), fromNodeId: rootId, toNodeId: childId }))]; + const batchConnections = directCopiedBatch + ? childIds.map((childId) => ({ id: nanoid(), fromNodeId: nodeId, toNodeId: childId })) + : [...(reuseSourceNode ? [] : [{ id: nanoid(), fromNodeId: nodeId, toNodeId: rootId }]), ...childIds.map((childId) => ({ id: nanoid(), fromNodeId: rootId, toNodeId: childId }))]; setNodes((current) => { return [ @@ -117,7 +120,7 @@ export async function executeImageGeneration({ metadata: { ...node.metadata, content: prompt, richText: undefined, prompt, status: NODE_STATUS_SUCCESS, fontSize: 14, errorDetails: undefined }, }; }), - ...(reuseSourceNode ? [] : [rootNode]), + ...(reuseSourceNode || directCopiedBatch ? [] : [rootNode]), ...childNodes, ]; }); @@ -127,7 +130,7 @@ export async function executeImageGeneration({ setDialogNodeId(nodeId); targetIds.forEach((targetId) => startGenerationRequest(targetId, nodeId, nodeId, controller)); - if (count > 1) startGenerationRequest(rootId, nodeId, nodeId, controller); + if (count > 1 && !directCopiedBatch) startGenerationRequest(rootId, nodeId, nodeId, controller); let hasSuccess = false; let hasFailure = false; let representativeFailure: GenerationFailureMetadata | undefined; @@ -150,9 +153,9 @@ export async function executeImageGeneration({ const uploaded = await uploadImage(image.dataUrl); const imageSize = imageSizeSource ? outputNodeSize : fitNodeSize(uploaded.width, uploaded.height, outputNodeSize.width, outputNodeSize.height); setNodes((current) => { - const root = current.find((node) => node.id === rootId); + const root = directCopiedBatch ? undefined : current.find((node) => node.id === rootId); return current.map((node) => { - if (node.id !== targetId && node.id !== rootId) return node; + if (node.id !== targetId && (!root || node.id !== rootId)) return node; const center = { x: node.position.x + node.width / 2, y: node.position.y + node.height / 2 }; const geometry = node.metadata?.locked ? {} : { position: { x: center.x - imageSize.width / 2, y: center.y - imageSize.height / 2 }, width: imageSize.width, height: imageSize.height }; if (node.id === rootId && (targetId === rootId || !root?.metadata?.primaryImageId)) return { ...node, ...geometry, metadata: { ...node.metadata, ...imageMetadata(uploaded), primaryImageId: targetId } }; @@ -175,7 +178,7 @@ export async function executeImageGeneration({ } }), ); - if (count > 1) finishGenerationRequest(rootId, controller); + if (count > 1 && !directCopiedBatch) finishGenerationRequest(rootId, controller); if (controller.signal.aborted) { setNodes((current) => current.map((node) => (node.id === nodeId && isConfigNode && node.metadata?.status === NODE_STATUS_LOADING ? { ...node, metadata: { ...node.metadata, status: NODE_STATUS_IDLE, errorDetails: undefined } } : node))); return; @@ -192,7 +195,7 @@ export async function executeImageGeneration({ ...(hasSuccess ? { errorDetails: undefined, generationErrorCode: undefined, failedPromptFingerprint: undefined } : representativeFailure || { errorDetails: "全部图片生成失败" }), }, } - : node.id === rootId && !hasSuccess + : !directCopiedBatch && node.id === rootId && !hasSuccess ? { ...node, metadata: { ...node.metadata, status: NODE_STATUS_ERROR, ...(representativeFailure || { errorDetails: "全部图片生成失败" }) } } : node, ), diff --git a/web/src/pages/canvas/project.tsx b/web/src/pages/canvas/project.tsx index 24fdbabc3..d65ef3a61 100644 --- a/web/src/pages/canvas/project.tsx +++ b/web/src/pages/canvas/project.tsx @@ -594,6 +594,7 @@ function InfiniteCanvasPage() { duplicateNode, hasCopiedNodes, pasteCopiedNodes, + restoreCopiedNodesFromText, releaseCopiedNodesPastePriority, setPrimaryVersion, shouldPreferCopiedNodes, @@ -960,6 +961,7 @@ function InfiniteCanvasPage() { cancelSelectionBox, copySelectedNodes, pasteCopiedNodes, + restoreCopiedNodesFromText, shouldPreferCopiedNodes, pasteSystemClipboard, deleteNodes, diff --git a/web/src/pages/canvas/use-canvas-generation-executor.ts b/web/src/pages/canvas/use-canvas-generation-executor.ts index df7fc653d..3f2381ad1 100644 --- a/web/src/pages/canvas/use-canvas-generation-executor.ts +++ b/web/src/pages/canvas/use-canvas-generation-executor.ts @@ -72,6 +72,20 @@ export function useCanvasGenerationExecutor({ return; } let generationConfig = buildGenerationConfig(effectiveConfig, sourceNode, mode); + const hasLiveBatchChildren = sourceNode?.type === CanvasNodeType.Image && (sourceNode.metadata?.batchChildIds || []).some((childId) => nodesRef.current.some((node) => node.id === childId && node.metadata?.batchRootId === sourceNode.id)); + const hasStaleImageBatchState = mode === "image" && sourceNode?.type === CanvasNodeType.Image && !sourceNode.metadata?.content && Boolean(sourceNode.metadata?.isBatchRoot || sourceNode.metadata?.batchChildIds?.length) && !hasLiveBatchChildren; + if (hasStaleImageBatchState) { + setNodes((current) => current.map((node) => { + if (node.id !== sourceNode.id) return node; + const metadata = { ...node.metadata }; + delete metadata.isBatchRoot; + delete metadata.batchChildIds; + delete metadata.primaryImageId; + delete metadata.imageBatchExpanded; + delete metadata.batchUsesReferenceImages; + return { ...node, metadata }; + })); + } if (!isAiConfigReady(generationConfig, generationConfig.model)) { navigateToSettings({ continueCreation: true }); return; diff --git a/web/src/pages/canvas/use-canvas-generation.ts b/web/src/pages/canvas/use-canvas-generation.ts index 623f35af3..3ce9cba79 100644 --- a/web/src/pages/canvas/use-canvas-generation.ts +++ b/web/src/pages/canvas/use-canvas-generation.ts @@ -160,8 +160,11 @@ export function useCanvasGeneration({ projectId, domainProjectId, projectLoaded, }, [nodesRef, setNodes]); const recoverInterruptedGenerationTasks = useCallback(async () => { - const recoveryNodes = nodesRef.current.filter((node) => node.metadata?.status === NODE_STATUS_LOADING || node.metadata?.errorDetails === "页面刷新后生成已中断,请重新生成。" || Boolean(node.metadata?.taskId && node.metadata.status !== NODE_STATUS_SUCCESS)); - if (!recoveryNodes.length) return; + const recoveryNodes = nodesRef.current.filter((node) => { + const aggregateBatchRoot = node.metadata?.isBatchRoot && node.metadata.batchChildIds?.length && !node.metadata.taskId; + if (aggregateBatchRoot) return false; + return node.metadata?.status === NODE_STATUS_LOADING || node.metadata?.errorDetails === "页面刷新后生成已中断,请重新生成。" || Boolean(node.metadata?.taskId && node.metadata.status !== NODE_STATUS_SUCCESS); + }); const taskIds = Array.from(new Set(recoveryNodes.map((node) => node.metadata?.taskId).filter((id): id is string => Boolean(id)))); const tasks = (await Promise.all(taskIds.map((id) => queryGenerationTask(id).catch(() => undefined)))).filter((task): task is GenerationTask => Boolean(task)); if (recoveryNodes.some((node) => !node.metadata?.taskId)) { @@ -194,6 +197,30 @@ export function useCanvasGeneration({ projectId, domainProjectId, projectLoaded, recoveringTaskIdsRef.current.delete(task.id); } })); + setNodes((current) => current.map((node) => { + if (!node.metadata?.isBatchRoot || !node.metadata.batchChildIds?.length || node.metadata.taskId) return node; + const children = node.metadata.batchChildIds.map((id) => current.find((item) => item.id === id)).filter(Boolean) as CanvasNodeData[]; + const primary = children.find((item) => item.id === node.metadata?.primaryImageId && item.metadata?.content) || children.find((item) => item.metadata?.content); + const loading = children.some((item) => item.metadata?.status === NODE_STATUS_LOADING); + const failed = children.find((item) => item.metadata?.status === NODE_STATUS_ERROR); + return { + ...node, + metadata: { + ...node.metadata, + ...(primary ? { + content: primary.metadata?.content, + storageKey: primary.metadata?.storageKey, + mimeType: primary.metadata?.mimeType, + bytes: primary.metadata?.bytes, + naturalWidth: primary.metadata?.naturalWidth, + naturalHeight: primary.metadata?.naturalHeight, + primaryImageId: primary.id, + } : {}), + status: primary ? NODE_STATUS_SUCCESS : loading ? NODE_STATUS_LOADING : NODE_STATUS_ERROR, + errorDetails: primary ? undefined : failed?.metadata?.errorDetails || "全部图片生成失败", + }, + }; + })); }, [applyGenerationTaskResult, bindGenerationTask, nodesRef, projectId, setNodes]); useEffect(() => { diff --git a/web/src/pages/canvas/use-canvas-keyboard.ts b/web/src/pages/canvas/use-canvas-keyboard.ts index 6e0eeac71..590ba5f99 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; + restoreCopiedNodesFromText: (value: string) => boolean; shouldPreferCopiedNodes: () => boolean; pasteSystemClipboard: (position?: undefined, clipboardEvent?: ClipboardEvent | null) => Promise | boolean; deleteNodes: (ids: Set) => void; @@ -53,6 +54,7 @@ export function useCanvasKeyboard({ cancelSelectionBox, copySelectedNodes, pasteCopiedNodes, + restoreCopiedNodesFromText, shouldPreferCopiedNodes, pasteSystemClipboard, deleteNodes, @@ -90,7 +92,9 @@ export function useCanvasKeyboard({ if (!event.repeat) void saveCanvasProject(); return; } - if (isTextEditingTarget || target?.closest("[data-canvas-no-zoom]")) return; + if (isTextEditingTarget) return; + const isCanvasControlTarget = Boolean(target?.closest("[data-canvas-no-zoom]")); + if (isCanvasControlTarget && !(isModifierShortcut && !event.altKey && (key === "c" || key === "v"))) return; if (event.key === "?" && !isModifierShortcut && !event.altKey) { event.preventDefault(); setShortcutRequestNonce((value) => value + 1); @@ -128,8 +132,14 @@ export function useCanvasKeyboard({ return; } if (isModifierShortcut && !event.altKey && key === "v") { - // 不在 keydown 直接粘贴节点:交给 paste 事件,优先系统剪贴板图片。 - // 这里只做标记,真正逻辑在 paste 监听器里。 + // 有些浏览器/焦点状态不会继续派发 paste 事件;内部节点复制必须有 keydown 兜底。 + if (shouldPreferCopiedNodes()) { + event.preventDefault(); + if (pasteCopiedNodes()) return; + void navigator.clipboard?.readText?.().then((text) => { + if (restoreCopiedNodesFromText(text)) pasteCopiedNodes(); + }).catch(() => undefined); + } return; } if (event.key === "Delete" || event.key === "Backspace") { @@ -147,9 +157,11 @@ 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; + if (event.target instanceof HTMLInputElement || event.target instanceof HTMLTextAreaElement || event.target instanceof HTMLSelectElement || target?.closest("[contenteditable='true']")) return; // 节点标记写入失败或仍在写入时避开旧系统图片,其余情况保持系统内容优先。 event.preventDefault(); + const text = event.clipboardData?.getData("text/plain") || ""; + if (text && restoreCopiedNodesFromText(text) && pasteCopiedNodes()) return; if (shouldPreferCopiedNodes() && pasteCopiedNodes()) return; void (async () => { const handled = await pasteSystemClipboard(undefined, event); @@ -163,5 +175,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, shouldPreferCopiedNodes, undoCanvas, zoomCanvasIn, zoomCanvasOut, zoomToActualSize]); + }, [cancelSelectionBox, copySelectedNodes, deleteConnection, deleteNodes, deselectCanvas, fitCanvasContent, fitCanvasSelection, nodesRef, pasteCopiedNodes, pasteSystemClipboard, redoCanvas, restoreCopiedNodesFromText, saveCanvasProject, selectedConnectionId, selectedNodeIdsRef, setAnnotationNodeId, setContextMenu, setCropNodeId, setInfoNodeId, setMaskEditNodeId, setSelectedConnectionId, setSelectedNodeIds, setShortcutRequestNonce, shouldPreferCopiedNodes, undoCanvas, zoomCanvasIn, zoomCanvasOut, zoomToActualSize]); } diff --git a/web/src/pages/canvas/use-canvas-node-operations.ts b/web/src/pages/canvas/use-canvas-node-operations.ts index e90f24f15..8af2f5a4a 100644 --- a/web/src/pages/canvas/use-canvas-node-operations.ts +++ b/web/src/pages/canvas/use-canvas-node-operations.ts @@ -15,6 +15,10 @@ type CanvasClipboard = { connections: CanvasConnection[]; }; +const CANVAS_NODES_CLIPBOARD_PREFIX = "open-ai-canvas-nodes:"; +const CANVAS_NODES_JSON_CLIPBOARD_PREFIX = "open-ai-canvas-nodes-json:"; +const CANVAS_NODES_CLIPBOARD_STORAGE_KEY = "open-ai-canvas:nodes-clipboard"; + type UseCanvasNodeOperationsOptions = { projectId: string; nodesRef: { current: CanvasNodeData[] }; @@ -49,9 +53,11 @@ export function useCanvasNodeOperations({ const preferCopiedNodesRef = useRef(false); const markerWritePendingRef = useRef(false); const markerWriteSequenceRef = useRef(0); + const internalMarkerWriteRef = useRef(false); const [hasCopiedNodes, setHasCopiedNodes] = useState(false); const releaseCopiedNodesPastePriority = useCallback(() => { + if (internalMarkerWriteRef.current) return; markerWriteSequenceRef.current += 1; markerWritePendingRef.current = false; preferCopiedNodesRef.current = false; @@ -330,26 +336,33 @@ export function useCanvasNodeOperations({ .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 })), - }; + const copiedConnections = connectionsRef.current.filter((connection) => copyIds.has(connection.fromNodeId) && copyIds.has(connection.toNodeId)).map((connection) => ({ ...connection })); + clipboardRef.current = { nodes: copiedNodes, connections: copiedConnections }; + try { + sessionStorage.setItem(CANVAS_NODES_CLIPBOARD_STORAGE_KEY, JSON.stringify(clipboardRef.current)); + } catch { + // 大型媒体节点可能超过浏览器存储配额,当前页面内仍可正常粘贴。 + } setHasCopiedNodes(true); // 写入完成前或写入失败时优先内部节点,避免快速粘贴读到系统残留图片。 - const marker = `open-ai-canvas-nodes:${Date.now()}:${copiedNodes.length}`; + const marker = `${CANVAS_NODES_CLIPBOARD_PREFIX}${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; - }, () => { + try { + internalMarkerWriteRef.current = true; + copyToClipboard(marker, { format: "text/plain" }); + internalMarkerWriteRef.current = false; if (markerWriteSequenceRef.current !== sequence) return; markerWritePendingRef.current = false; preferCopiedNodesRef.current = true; - }); + } catch { + internalMarkerWriteRef.current = false; + if (markerWriteSequenceRef.current !== sequence) return; + markerWritePendingRef.current = false; + preferCopiedNodesRef.current = true; + } }, [connectionsRef, nodesRef]); const copySelectedNodes = useCallback(() => { @@ -417,6 +430,25 @@ export function useCanvasNodeOperations({ return true; }, [cloneDrawingForNode, commitConnections, commitNodes, connectionsRef, getCanvasCenter, nodesRef, selectNodes, setContextMenu, setDialogNodeId]); + const restoreCopiedNodesFromText = useCallback((value: string) => { + const isMarker = value.startsWith(CANVAS_NODES_CLIPBOARD_PREFIX); + const isLegacyJSON = value.startsWith(CANVAS_NODES_JSON_CLIPBOARD_PREFIX); + if (!isMarker && !isLegacyJSON) return false; + try { + const serialized = isLegacyJSON ? value.slice(CANVAS_NODES_JSON_CLIPBOARD_PREFIX.length) : sessionStorage.getItem(CANVAS_NODES_CLIPBOARD_STORAGE_KEY); + if (!serialized) return false; + const parsed = JSON.parse(serialized) as Partial; + if (!parsed.nodes?.length) return false; + clipboardRef.current = { nodes: parsed.nodes, connections: parsed.connections || [] }; + setHasCopiedNodes(true); + preferCopiedNodesRef.current = true; + markerWritePendingRef.current = false; + return true; + } catch { + return false; + } + }, []); + return { alignSelectedNodes, arrangeSelectedNodes, @@ -430,6 +462,7 @@ export function useCanvasNodeOperations({ duplicateNode, hasCopiedNodes, pasteCopiedNodes, + restoreCopiedNodesFromText, releaseCopiedNodesPastePriority, setPrimaryVersion, shouldPreferCopiedNodes, diff --git a/web/src/pages/canvas/use-canvas-render-model.ts b/web/src/pages/canvas/use-canvas-render-model.ts index 080b11c76..312a6841c 100644 --- a/web/src/pages/canvas/use-canvas-render-model.ts +++ b/web/src/pages/canvas/use-canvas-render-model.ts @@ -175,10 +175,12 @@ export function useCanvasRenderModel({ const batchChildCountById = useMemo(() => { const map = new Map(); nodes.forEach((node) => { - if (node.metadata?.isBatchRoot) map.set(node.id, node.metadata.batchChildIds?.length || 0); + if (!node.metadata?.isBatchRoot) return; + const liveChildCount = (node.metadata.batchChildIds || []).filter((childId) => nodeById.get(childId)?.metadata?.batchRootId === node.id).length; + map.set(node.id, liveChildCount); }); return map; - }, [nodes]); + }, [nodeById, nodes]); const frameChildrenById = useMemo(() => { const map = new Map(); nodes.forEach((node) => { diff --git a/web/src/pages/canvas/use-canvas-upload.ts b/web/src/pages/canvas/use-canvas-upload.ts index 2bedf1852..36506be46 100644 --- a/web/src/pages/canvas/use-canvas-upload.ts +++ b/web/src/pages/canvas/use-canvas-upload.ts @@ -366,7 +366,10 @@ export function useCanvasUpload({ }, [message, nodesRef, persistMediaNode, selectInsertedNode, setNodes, startUploadStatus]); const pasteSystemClipboard = useCallback(async (position?: Position, clipboardEvent?: ClipboardEvent | null) => { - const isNodeMarker = (value: string) => value.trim().startsWith("open-ai-canvas-nodes:"); + const isNodeMarker = (value: string) => { + const trimmed = value.trim(); + return trimmed.startsWith("open-ai-canvas-nodes:") || trimmed.startsWith("open-ai-canvas-nodes-json:"); + }; const pasteImageFile = async (file: File) => { const selected = nodesRef.current.filter((node) => selectedNodeIdsRef.current.has(node.id)); if (selected.length === 1 && selected[0].type === CanvasNodeType.Image) { diff --git a/web/src/types/canvas.ts b/web/src/types/canvas.ts index a425af359..0ff5d8d78 100644 --- a/web/src/types/canvas.ts +++ b/web/src/types/canvas.ts @@ -204,6 +204,7 @@ export type CanvasNodeMetadata = { versionOfNodeId?: string; versionLabel?: string; versionPrimary?: boolean; + copiedFromNodeId?: string; directorSceneId?: string; directorShotId?: string; directorPreviewNodeId?: string;