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

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

No files matched your search

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