fix(canvas): 剪贴板粘贴 - 外部复制后优先使用最新系统内容

Fixes #44
This commit is contained in:
ddCat
2026-07-28 19:26:20 +08:00
parent 9747c4b17e
commit 0d821cfa22
4 changed files with 140 additions and 60 deletions

View 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;
}

View File

@@ -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(

View File

@@ -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]);
}

View File

@@ -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,
};
}