fix(canvas): 节点复制 - 修复内部粘贴和批量状态残留 (#60)
Co-authored-by: Danborad <Danborad@users.noreply.github.com>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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> | boolean;
|
||||
deleteNodes: (ids: Set<string>) => 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]);
|
||||
}
|
||||
|
||||
@@ -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<CanvasClipboard>;
|
||||
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,
|
||||
|
||||
@@ -175,10 +175,12 @@ export function useCanvasRenderModel({
|
||||
const batchChildCountById = useMemo(() => {
|
||||
const map = new Map<string, number>();
|
||||
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<string, CanvasNodeData[]>();
|
||||
nodes.forEach((node) => {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -204,6 +204,7 @@ export type CanvasNodeMetadata = {
|
||||
versionOfNodeId?: string;
|
||||
versionLabel?: string;
|
||||
versionPrimary?: boolean;
|
||||
copiedFromNodeId?: string;
|
||||
directorSceneId?: string;
|
||||
directorShotId?: string;
|
||||
directorPreviewNodeId?: string;
|
||||
|
||||
Reference in New Issue
Block a user