fix(canvas): 复制粘贴与生成占位 - 系统图片优先并按比例显示占位框 (#45)

- 粘贴优先系统剪贴板图片,复制节点时写入标记避免旧图片抢占
- 粘贴/复制节点清理批次绑定,并补回参考图入边
- 生成中图片占位按设置比例显示
- Nano Banana / Imagen / Veo / Omni 匹配 Gemini 图标

Co-authored-by: Danborad <Danborad@users.noreply.github.com>
This commit is contained in:
Danborad
2026-07-28 19:15:30 +08:00
committed by GitHub
parent 38877cc237
commit 9747c4b17e
8 changed files with 204 additions and 52 deletions

View File

@@ -936,13 +936,25 @@ function AgentModelIcon({ model }: { model: string }) {
}
function resolveModelIcon(model: string) {
// 与 model-picker 保持同一套厂商图标规则。
const name = model.toLowerCase();
if (name.includes("claude") || name.includes("anthropic")) return "/icons/claude.svg";
if (name.includes("gemini") || name.includes("google")) return "/icons/gemini.svg";
if (name.includes("gpt") || name.includes("openai")) return "/icons/openai.svg";
if (
name.includes("gemini") ||
name.includes("google") ||
name.includes("nano banana") ||
name.includes("nanobanana") ||
name.includes("imagen") ||
name.includes("veo") ||
name.includes("omni flash") ||
name.includes("omni-flash")
) {
return "/icons/gemini.svg";
}
if (name.includes("gpt") || name.includes("openai") || name.includes("dall-e") || name.includes("dalle")) return "/icons/openai.svg";
if (name.includes("grok")) return "/icons/grok.svg";
if (name.includes("deepseek")) return "/icons/deepseek.svg";
if (name.includes("glm")) return "/icons/glm.svg";
if (name.includes("glm") || name.includes("chatglm")) return "/icons/glm.svg";
return "";
}

View File

@@ -136,13 +136,15 @@ function ModelPrice({ price, compact = false }: { price: { value: number; unit:
function modelMenuMeta(model: string, capability?: ModelCapability): { description: string; time?: string } {
const name = modelOptionName(model).toLowerCase();
if (capability === "image") {
if (name.includes("nano banana") || name.includes("nanobanana") || name.includes("imagen")) return { description: "Gemini 高质量图片生成,适合角色和商业成片" };
if (name.includes("nano") || name.includes("pro")) return { description: "高质量图片生成,适合角色和商业成片" };
if (name.includes("seedream")) return { description: "快速出图,适合批量探索风格" };
if (name.includes("gpt") || name.includes("image")) return { description: "通用图片模型,提示词理解稳定" };
return { description: "图片生成模型" };
}
if (capability === "video") {
if (name.includes("seedance") || name.includes("veo") || name.includes("sora")) return { description: "镜头生成与图生视频,适合成片流程", time: "3m" };
if (name.includes("veo") || name.includes("omni flash") || name.includes("omni-flash")) return { description: "Gemini 镜头生成与图生视频,适合成片流程", time: "3m" };
if (name.includes("seedance") || name.includes("sora")) return { description: "镜头生成与图生视频,适合成片流程", time: "3m" };
return { description: "视频生成模型", time: "3m" };
}
if (capability === "audio") return { description: "语音、音效或音乐生成", time: "20s" };
@@ -157,13 +159,25 @@ export function ModelIcon({ model }: { model: string }) {
return icon ? <img src={icon} alt="" className="size-3.5 shrink-0 dark:invert" /> : <Cpu className="size-3.5 shrink-0 opacity-70" />;
}
function resolveModelIcon(model: string) {
export function resolveModelIcon(model: string) {
const name = model.toLowerCase();
if (name.includes("claude") || name.includes("anthropic")) return "/icons/claude.svg";
if (name.includes("gemini") || name.includes("google")) return "/icons/gemini.svg";
if (name.includes("gpt") || name.includes("openai")) return "/icons/openai.svg";
if (name.includes("grok") || name.includes("grok")) return "/icons/grok.svg";
if (name.includes("deepseek") || name.includes("deepseek")) return "/icons/deepseek.svg";
if (name.includes("glm") || name.includes("glm")) return "/icons/glm.svg";
// Flow2API 短名:Nano Banana / Imagen / Veo / Omni 均属 Google Gemini 系。
if (
name.includes("gemini") ||
name.includes("google") ||
name.includes("nano banana") ||
name.includes("nanobanana") ||
name.includes("imagen") ||
name.includes("veo") ||
name.includes("omni flash") ||
name.includes("omni-flash")
) {
return "/icons/gemini.svg";
}
if (name.includes("gpt") || name.includes("openai") || name.includes("dall-e") || name.includes("dalle")) return "/icons/openai.svg";
if (name.includes("grok")) return "/icons/grok.svg";
if (name.includes("deepseek")) return "/icons/deepseek.svg";
if (name.includes("glm") || name.includes("chatglm")) return "/icons/glm.svg";
return "";
}

View File

@@ -6,10 +6,14 @@ export function fitNodeSize(width: number, height: number, maxWidth = 640, maxHe
}
export function nodeSizeFromRatio(size: string, baseWidth: number, baseHeight: number) {
const match = size?.match(/^(\d+)(?:x|:)(\d+)/);
const raw = String(size || "").trim();
if (!raw || raw.toLowerCase() === "auto") return null;
// 支持 16:9 / 1024x576 / 16:9-2k
const match = raw.match(/^(\d+(?:\.\d+)?)(?:x|:)(\d+(?:\.\d+)?)/i);
if (!match) return null;
const width = Number(match[1]);
const height = Number(match[2]);
if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) return null;
const ratio = width / Math.max(1, height);
if (ratio < 0.25 || ratio > 4) return { width: baseWidth, height: baseHeight };
return ratio >= baseWidth / baseHeight ? { width: baseWidth, height: baseWidth / ratio } : { width: baseHeight * ratio, height: baseHeight };

View File

@@ -2,7 +2,7 @@ import { nanoid } from "nanoid";
import { NODE_DEFAULT_SIZE } from "@/constant/canvas";
import { imageMetadata } from "@/lib/canvas/canvas-generation-task-sync";
import { fitNodeSize } from "@/lib/canvas/canvas-node-size";
import { fitNodeSize, nodeSizeFromRatio } from "@/lib/canvas/canvas-node-size";
import { prepareInPlaceMediaVersion } from "@/lib/canvas/canvas-media-versions";
import { buildImageGenerationMetadata, getGenerationCount, isGenerationCanceled, runBackendCanvasGenerationTask } from "@/lib/canvas/canvas-project-generation";
import { CONTENT_MODERATION_ERROR_CODE, generationFailureMetadata, type GenerationFailureMetadata } from "@/lib/generation-error";
@@ -45,12 +45,17 @@ export async function executeImageGeneration({
const generationType = referenceImages.length ? ("edit" as const) : ("generation" as const);
const generationMetadata = buildImageGenerationMetadata(generationType, generationConfig, count, referenceImages);
const parentConfig = NODE_DEFAULT_SIZE[isConfigNode ? CanvasNodeType.Config : isImageNode ? CanvasNodeType.Image : CanvasNodeType.Text];
const imageConfig = NODE_DEFAULT_SIZE[CanvasNodeType.Image];
const imageDefaults = NODE_DEFAULT_SIZE[CanvasNodeType.Image];
// 生成中占位框按设置比例显示,避免 16:9 任务显示成默认 340x240。
const imageConfig = nodeSizeFromRatio(generationConfig.size || "auto", imageDefaults.width, imageDefaults.height) || imageDefaults;
const parentPosition = sourceNode?.position || { x: 0, y: 0 };
const rootId = isImageNode ? nodeId : nanoid();
const childIds = count > 1 ? Array.from({ length: count }, () => nanoid()) : [];
const targetIds = count > 1 ? childIds : [rootId];
registerPendingNodeIds(isEmptyImageNode ? childIds : [rootId, ...childIds]);
// 空节点可直接按目标比例改尺寸;已有内容的原位重生保留原尺寸,避免布局跳动。
const rootWidth = isImageNode && !isEmptyImageNode ? sourceNode?.width || imageConfig.width : imageConfig.width;
const rootHeight = isImageNode && !isEmptyImageNode ? sourceNode?.height || imageConfig.height : imageConfig.height;
const rootNode: CanvasNodeData = {
id: rootId,
@@ -60,13 +65,14 @@ export async function executeImageGeneration({
? parentPosition
: {
x: parentPosition.x + parentConfig.width + 96,
y: parentPosition.y + parentConfig.height / 2 - imageConfig.height / 2,
y: parentPosition.y + parentConfig.height / 2 - rootHeight / 2,
},
width: isImageNode ? sourceNode?.width || imageConfig.width : imageConfig.width,
height: isImageNode ? sourceNode?.height || imageConfig.height : imageConfig.height,
width: rootWidth,
height: rootHeight,
metadata: {
prompt: effectivePrompt,
status: NODE_STATUS_LOADING,
size: generationConfig.size,
isBatchRoot: count > 1,
batchChildIds: count > 1 ? childIds : undefined,
batchUsesReferenceImages: referenceImages.length > 0,
@@ -87,7 +93,7 @@ export async function executeImageGeneration({
},
width: imageConfig.width,
height: imageConfig.height,
metadata: { prompt: effectivePrompt, status: NODE_STATUS_LOADING, batchRootId: count > 1 ? rootId : undefined, ...generationMetadata, generationErrorCode: undefined, failedPromptFingerprint: undefined },
metadata: { prompt: effectivePrompt, status: NODE_STATUS_LOADING, size: generationConfig.size, batchRootId: count > 1 ? rootId : undefined, ...generationMetadata, generationErrorCode: undefined, failedPromptFingerprint: undefined },
}));
const batchConnections = [...(isImageNode ? [] : [{ id: nanoid(), fromNodeId: nodeId, toNodeId: rootId }]), ...childIds.map((childId) => ({ id: nanoid(), fromNodeId: rootId, toNodeId: childId }))];

View File

@@ -977,8 +977,15 @@ function InfiniteCanvasPage() {
const pasteAtPosition = useCallback(
(position: Position) => {
if (pasteCopiedNodes(position)) return;
void pasteSystemClipboard(position).catch(() => message.warning("无法读取剪贴板内容"));
void (async () => {
try {
// 右键粘贴同样优先系统剪贴板图片,再回退内部节点。
const handled = await pasteSystemClipboard(position);
if (!handled) pasteCopiedNodes(position);
} catch {
if (!pasteCopiedNodes(position)) message.warning("无法读取剪贴板内容");
}
})();
},
[message, pasteCopiedNodes, pasteSystemClipboard],
);

View File

@@ -23,7 +23,7 @@ type UseCanvasKeyboardOptions = {
cancelSelectionBox: () => void;
copySelectedNodes: () => void;
pasteCopiedNodes: () => boolean;
pasteSystemClipboard: () => unknown;
pasteSystemClipboard: (position?: undefined, clipboardEvent?: ClipboardEvent | null) => Promise<boolean> | boolean;
deleteNodes: (ids: Set<string>) => void;
deleteConnection: (connectionId: string) => void;
deselectCanvas: () => void;
@@ -105,8 +105,8 @@ export function useCanvasKeyboard({
return;
}
if (isModifierShortcut && !event.altKey && key === "v") {
event.preventDefault();
if (!pasteCopiedNodes()) void pasteSystemClipboard();
// 不在 keydown 直接粘贴节点:交给 paste 事件,优先系统剪贴板图片。
// 这里只做标记,真正逻辑在 paste 监听器里。
return;
}
if (event.key === "Delete" || event.key === "Backspace") {
@@ -122,7 +122,22 @@ 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();
void (async () => {
const handled = await pasteSystemClipboard(undefined, event);
if (!handled) pasteCopiedNodes();
})();
};
window.addEventListener("keydown", handleKeyDown, true);
return () => window.removeEventListener("keydown", handleKeyDown, true);
window.addEventListener("paste", handlePaste, true);
return () => {
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]);
}

View File

@@ -272,6 +272,12 @@ export function useCanvasNodeOperations({
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;
@@ -332,6 +338,17 @@ 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 {
// 忽略剪贴板权限失败,内部剪贴板仍可用。
}
}, [connectionsRef, nodesRef]);
const copySelectedNodes = useCallback(() => {
@@ -351,21 +368,61 @@ export function useCanvasNodeOperations({
const dx = center.x - (bounds.left + bounds.right) / 2;
const dy = center.y - (bounds.top + bounds.bottom) / 2;
const idMap = new Map(clipboard.nodes.map((node, index) => [node.id, `${node.type}-${Date.now()}-${index}-${Math.random().toString(36).slice(2, 7)}`]));
const nextNodes = clipboard.nodes.map((node) => ({
...node,
id: idMap.get(node.id)!,
title: node.title.endsWith(" Copy") ? node.title : `${node.title} Copy`,
position: { x: node.position.x + dx, y: node.position.y + dy },
parentId: node.parentId ? idMap.get(node.parentId) : undefined,
metadata: node.type === CanvasNodeType.Drawing
? { ...node.metadata, frame: node.metadata?.frame ? { ...node.metadata.frame } : undefined, drawingId: `${idMap.get(node.id)}-document`, drawingRevision: 0, drawingUpdatedAt: undefined, drawingShapeCount: 0, drawingPageCount: 1 }
: node.metadata ? { ...node.metadata, frame: node.metadata.frame ? { ...node.metadata.frame } : undefined } : undefined,
}));
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;
}
if (node.type === CanvasNodeType.Drawing && metadata) {
metadata.drawingId = `${idMap.get(node.id)}-document`;
metadata.drawingRevision = 0;
metadata.drawingUpdatedAt = undefined;
metadata.drawingShapeCount = 0;
metadata.drawingPageCount = 1;
}
return {
...node,
id: idMap.get(node.id)!,
title: node.title.endsWith(" Copy") ? node.title : `${node.title} Copy`,
position: { x: node.position.x + dx, y: node.position.y + dy },
parentId: node.parentId ? idMap.get(node.parentId) : undefined,
metadata,
};
});
// 1) 剪贴板内部连线;2) 仍保留到画布上未复制参考节点的入边(只复制结果节点时常见)。
const nextConnections = clipboard.connections.flatMap((connection, index) => {
const fromNodeId = idMap.get(connection.fromNodeId);
const toNodeId = idMap.get(connection.toNodeId);
return fromNodeId && toNodeId ? [{ ...connection, id: `conn-${Date.now()}-${index}-${Math.random().toString(36).slice(2, 7)}`, fromNodeId, toNodeId }] : [];
});
connectionsRef.current.forEach((connection) => {
if (!copiedSourceIds.has(connection.toNodeId) || copiedSourceIds.has(connection.fromNodeId)) return;
const toNodeId = idMap.get(connection.toNodeId);
if (!toNodeId) return;
// 参考节点仍在画布上时,粘贴结果应重新挂上同一入边。
if (!nodesRef.current.some((node) => node.id === connection.fromNodeId)) return;
nextConnections.push({ ...connection, id: nanoid(), toNodeId });
});
commitNodes([...nodesRef.current, ...nextNodes]);
commitConnections([...connectionsRef.current, ...nextConnections]);
const sourceByTargetId = new Map(clipboard.nodes.map((sourceNode) => [idMap.get(sourceNode.id), sourceNode]));

View File

@@ -367,28 +367,65 @@ export function useCanvasUpload({
}
}, [message, nodesRef, persistMediaNode, selectInsertedNode, setNodes, startUploadStatus]);
const pasteSystemClipboard = useCallback(async (position?: Position) => {
if (!navigator.clipboard) return;
if (navigator.clipboard.read) {
const items = await navigator.clipboard.read();
const imageItem = items.find((item) => item.types.some((type) => type.startsWith("image/")));
if (imageItem) {
const imageType = imageItem.types.find((type) => type.startsWith("image/"));
if (!imageType) return;
const blob = await imageItem.getType(imageType);
const file = new File([blob], "clipboard-image.png", { type: imageType });
const selected = nodesRef.current.filter((node) => selectedNodeIdsRef.current.has(node.id));
if (selected.length === 1 && selected[0].type === CanvasNodeType.Image) {
if (await replaceNodeMedia(selected[0].id, file)) message.success("已用剪切板图片替换,可撤销恢复");
return;
const pasteSystemClipboard = useCallback(async (position?: Position, clipboardEvent?: ClipboardEvent | null) => {
const isNodeMarker = (value: string) => value.trim().startsWith("open-ai-canvas-nodes:");
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) {
if (await replaceNodeMedia(selected[0].id, file)) message.success("已用剪切板图片替换,可撤销恢复");
return true;
}
const inserted = await createImageFileNode(file, position || getCanvasCenter());
if (inserted) message.success("已从剪切板添加图片");
return Boolean(inserted);
};
// 1) paste 事件里的图片文件(截图/资源管理器复制)优先。
const filesFromEvent = clipboardEvent
? Array.from(clipboardEvent.clipboardData?.items || [])
.filter((item) => item.kind === "file" && item.type.startsWith("image/"))
.map((item) => item.getAsFile())
.filter((file): file is File => Boolean(file))
: [];
if (filesFromEvent.length) return pasteImageFile(filesFromEvent[0]);
// 2) 若系统文本是节点标记,说明最近一次复制是画布节点,不要再读旧图片。
const eventText = clipboardEvent?.clipboardData?.getData("text/plain") || "";
if (isNodeMarker(eventText)) return false;
// 3) 异步读系统剪贴板:有图片才导入;若文本是节点标记则让位给节点粘贴。
if (navigator.clipboard?.read) {
try {
const items = await navigator.clipboard.read();
const textItem = items.find((item) => item.types.includes("text/plain"));
if (textItem) {
const textBlob = await textItem.getType("text/plain");
const text = await textBlob.text();
if (isNodeMarker(text)) return false;
}
const inserted = await createImageFileNode(file, position || getCanvasCenter());
if (inserted) message.success("已从剪切板添加图片");
return;
const imageItem = items.find((item) => item.types.some((type) => type.startsWith("image/")));
if (imageItem) {
const imageType = imageItem.types.find((type) => type.startsWith("image/"));
if (!imageType) return false;
const blob = await imageItem.getType(imageType);
return pasteImageFile(new File([blob], "clipboard-image.png", { type: imageType }));
}
} catch {
// 无权限时继续文本分支。
}
}
const text = await navigator.clipboard.readText();
if (createTextNodeFromClipboard(text, position)) message.success("已从剪切板添加文本");
try {
const text = eventText || (navigator.clipboard?.readText ? await navigator.clipboard.readText() : "");
if (isNodeMarker(text)) return false;
if (createTextNodeFromClipboard(text, position)) {
message.success("已从剪切板添加文本");
return true;
}
} catch {
// ignore
}
return false;
}, [createImageFileNode, createTextNodeFromClipboard, getCanvasCenter, message, nodesRef, replaceNodeMedia, selectedNodeIdsRef]);
const handleImageInputChange = useCallback(async (event: ChangeEvent<HTMLInputElement>) => {