From ccf6412a7c9acfd1555d35d28719d8419c1fc453 Mon Sep 17 00:00:00 2001 From: yuki Date: Sun, 19 Jul 2026 12:54:39 +0800 Subject: [PATCH] =?UTF-8?q?feat(canvas):=20=E7=94=BB=E5=B8=83=E5=B7=A5?= =?UTF-8?q?=E5=85=B7=E6=A0=8F=E4=B8=8A=E4=BC=A0=E5=92=8C=E6=8B=96=E6=8B=BD?= =?UTF-8?q?=E6=94=AF=E6=8C=81=E5=A4=9A=E9=80=89=E6=96=87=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- web/src/pages/canvas/project.tsx | 166 ++++++++++++++++++++----------- 1 file changed, 106 insertions(+), 60 deletions(-) diff --git a/web/src/pages/canvas/project.tsx b/web/src/pages/canvas/project.tsx index 97963e5..0aadd43 100644 --- a/web/src/pages/canvas/project.tsx +++ b/web/src/pages/canvas/project.tsx @@ -1847,13 +1847,31 @@ function InfiniteCanvasPage() { const handleImageInputChange = useCallback( async (event: ReactChangeEvent) => { - const file = event.target.files?.[0]; - const target = uploadTargetRef.current; - if (!file || (!file.type.startsWith("image/") && !file.type.startsWith("video/") && !isAudioFile(file))) return; + const files = Array.from(event.target.files || []).filter( + (f) => f.type.startsWith("image/") || f.type.startsWith("video/") || isAudioFile(f), + ); + if (!files.length) { + uploadTargetRef.current = null; + event.target.value = ""; + return; + } + const target = uploadTargetRef.current; + const basePosition = + target?.position || + screenToCanvas( + (containerRef.current?.getBoundingClientRect().left || 0) + size.width / 2, + (containerRef.current?.getBoundingClientRect().top || 0) + size.height / 2, + ); + const STAGGER = 40; // 多文件时的偏移间距 + + // 如果有替换目标节点,第一个文件替换它,其余在附近新建 if (target?.nodeId) { - if (isAudioFile(file)) { - const audio = await uploadMediaFile(file, "audio"); + const [first, ...rest] = files; + + // 第一个文件:替换目标节点 + if (isAudioFile(first)) { + const audio = await uploadMediaFile(first, "audio"); const spec = NODE_DEFAULT_SIZE[CanvasNodeType.Audio]; setNodes((prev) => prev.map((node) => @@ -1861,7 +1879,7 @@ function InfiniteCanvasPage() { ? { ...node, type: CanvasNodeType.Audio, - title: file.name, + title: first.name, position: { x: node.position.x + node.width / 2 - spec.width / 2, y: node.position.y + node.height / 2 - spec.height / 2 }, width: spec.width, height: spec.height, @@ -1872,12 +1890,8 @@ function InfiniteCanvasPage() { ); setSelectedNodeIds(new Set([target.nodeId])); setSelectedConnectionId(null); - uploadTargetRef.current = null; - event.target.value = ""; - return; - } - if (file.type.startsWith("video/")) { - const video = await uploadMediaFile(file, "video"); + } else if (first.type.startsWith("video/")) { + const video = await uploadMediaFile(first, "video"); const nextSize = fitNodeSize(video.width || 1280, video.height || 720, VIDEO_NODE_MAX_WIDTH, VIDEO_NODE_MAX_HEIGHT); setNodes((prev) => prev.map((node) => @@ -1885,7 +1899,7 @@ function InfiniteCanvasPage() { ? { ...node, type: CanvasNodeType.Video, - title: file.name, + title: first.name, position: { x: node.position.x + node.width / 2 - nextSize.width / 2, y: node.position.y + node.height / 2 - nextSize.height / 2 }, width: nextSize.width, height: nextSize.height, @@ -1896,50 +1910,69 @@ function InfiniteCanvasPage() { ); setSelectedNodeIds(new Set([target.nodeId])); setSelectedConnectionId(null); - setDialogNodeId(target.nodeId); - uploadTargetRef.current = null; - event.target.value = ""; - return; + } else { + const image = await uploadImage(first); + const s = fitNodeSize(image.width, image.height); + setNodes((prev) => + prev.map((node) => + node.id === target.nodeId + ? { + ...node, + type: CanvasNodeType.Image, + title: first.name, + width: s.width, + height: s.height, + metadata: { + ...node.metadata, + ...imageMetadata(image), + errorDetails: undefined, + freeResize: false, + isBatchRoot: undefined, + batchRootId: undefined, + batchChildIds: undefined, + batchUsesReferenceImages: undefined, + generationType: undefined, + model: undefined, + size: undefined, + quality: undefined, + count: undefined, + references: undefined, + primaryImageId: undefined, + imageBatchExpanded: undefined, + }, + } + : node, + ), + ); + setSelectedNodeIds(new Set([target.nodeId])); + setSelectedConnectionId(null); + } + + // 剩余文件:在目标节点附近新建 + for (let i = 0; i < rest.length; i++) { + const offsetPos = { x: basePosition.x + (i + 1) * STAGGER, y: basePosition.y + (i + 1) * STAGGER }; + const f = rest[i]; + if (isAudioFile(f)) { + void createAudioFileNode(f, offsetPos); + } else if (f.type.startsWith("video/")) { + void createVideoFileNode(f, offsetPos); + } else { + void createImageFileNode(f, offsetPos); + } } - const image = await uploadImage(file); - const size = fitNodeSize(image.width, image.height); - setNodes((prev) => - prev.map((node) => - node.id === target.nodeId - ? { - ...node, - type: CanvasNodeType.Image, - title: file.name, - width: size.width, - height: size.height, - metadata: { - ...node.metadata, - ...imageMetadata(image), - errorDetails: undefined, - freeResize: false, - isBatchRoot: undefined, - batchRootId: undefined, - batchChildIds: undefined, - batchUsesReferenceImages: undefined, - generationType: undefined, - model: undefined, - size: undefined, - quality: undefined, - count: undefined, - references: undefined, - primaryImageId: undefined, - imageBatchExpanded: undefined, - }, - } - : node, - ), - ); - setSelectedNodeIds(new Set([target.nodeId])); - setSelectedConnectionId(null); - setDialogNodeId(target.nodeId); } else { - const position = target?.position || screenToCanvas((containerRef.current?.getBoundingClientRect().left || 0) + size.width / 2, (containerRef.current?.getBoundingClientRect().top || 0) + size.height / 2); - void (isAudioFile(file) ? createAudioFileNode(file, position) : file.type.startsWith("video/") ? createVideoFileNode(file, position) : createImageFileNode(file, position)); + // 无替换目标:所有文件在画布中心附近新建 + for (let i = 0; i < files.length; i++) { + const offsetPos = { x: basePosition.x + i * STAGGER, y: basePosition.y + i * STAGGER }; + const f = files[i]; + if (isAudioFile(f)) { + void createAudioFileNode(f, offsetPos); + } else if (f.type.startsWith("video/")) { + void createVideoFileNode(f, offsetPos); + } else { + void createImageFileNode(f, offsetPos); + } + } } uploadTargetRef.current = null; @@ -1951,11 +1984,24 @@ function InfiniteCanvasPage() { const handleDrop = useCallback( (event: ReactDragEvent) => { event.preventDefault(); - const file = Array.from(event.dataTransfer.files).find((item) => item.type.startsWith("image/") || item.type.startsWith("video/") || isAudioFile(item)); - if (!file) return; + const files = Array.from(event.dataTransfer.files).filter( + (item) => item.type.startsWith("image/") || item.type.startsWith("video/") || isAudioFile(item), + ); + if (!files.length) return; - const pos = screenToCanvas(event.clientX, event.clientY); - void (isAudioFile(file) ? createAudioFileNode(file, pos) : file.type.startsWith("video/") ? createVideoFileNode(file, pos) : createImageFileNode(file, pos)); + const basePos = screenToCanvas(event.clientX, event.clientY); + const STAGGER = 40; + for (let i = 0; i < files.length; i++) { + const pos = { x: basePos.x + i * STAGGER, y: basePos.y + i * STAGGER }; + const f = files[i]; + if (isAudioFile(f)) { + void createAudioFileNode(f, pos); + } else if (f.type.startsWith("video/")) { + void createVideoFileNode(f, pos); + } else { + void createImageFileNode(f, pos); + } + } }, [createAudioFileNode, createImageFileNode, createVideoFileNode, screenToCanvas], ); @@ -2928,7 +2974,7 @@ function InfiniteCanvasPage() { /> ) : null} - + setInfoNodeId(null)} /> setPluginManagerOpen(false)} />