fix(canvas): 复制粘贴与生成占位 - 系统图片优先并按比例显示占位框 (#45)
- 粘贴优先系统剪贴板图片,复制节点时写入标记避免旧图片抢占 - 粘贴/复制节点清理批次绑定,并补回参考图入边 - 生成中图片占位按设置比例显示 - Nano Banana / Imagen / Veo / Omni 匹配 Gemini 图标 Co-authored-by: Danborad <Danborad@users.noreply.github.com>
This commit is contained in:
@@ -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 "";
|
||||
}
|
||||
|
||||
|
||||
@@ -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 "";
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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 }))];
|
||||
|
||||
|
||||
@@ -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],
|
||||
);
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
|
||||
@@ -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]));
|
||||
|
||||
@@ -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>) => {
|
||||
|
||||
Reference in New Issue
Block a user