80
web/src/lib/canvas/canvas-node-copy.ts
Normal file
80
web/src/lib/canvas/canvas-node-copy.ts
Normal file
@@ -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<string, string>) {
|
||||
return nodeId ? idMap.get(nodeId) || nodeId : undefined;
|
||||
}
|
||||
|
||||
function remapOwnedNodeId(nodeId: string | undefined, idMap: ReadonlyMap<string, string>) {
|
||||
return nodeId ? idMap.get(nodeId) : undefined;
|
||||
}
|
||||
|
||||
function remapReferenceIds(nodeIds: string[] | undefined, idMap: ReadonlyMap<string, string>) {
|
||||
return nodeIds ? Array.from(new Set(nodeIds.map((nodeId) => idMap.get(nodeId) || nodeId))) : undefined;
|
||||
}
|
||||
|
||||
function copyStoryboardRow(row: StoryboardRow, idMap: ReadonlyMap<string, string>): 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<string, string>): 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;
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -23,6 +23,7 @@ type UseCanvasKeyboardOptions = {
|
||||
cancelSelectionBox: () => void;
|
||||
copySelectedNodes: () => void;
|
||||
pasteCopiedNodes: () => boolean;
|
||||
shouldPreferCopiedNodes: () => boolean;
|
||||
pasteSystemClipboard: (position?: undefined, clipboardEvent?: ClipboardEvent | null) => Promise<boolean> | boolean;
|
||||
deleteNodes: (ids: Set<string>) => 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]);
|
||||
}
|
||||
|
||||
@@ -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<string>, 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<CanvasClipboard | null>(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,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user