diff --git a/CHANGELOG.md b/CHANGELOG.md index 03ef87f..1186f54 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased ++ [优化] 画布多图生成结果合并为单个图片节点。 + ## v0.14.0 - 2026-08-05 + [新增] 文档站支持英文默认与简体中文切换,正文、导航、搜索及 LLM 文档接口按语言独立展示。 diff --git a/docs/content/docs/development/canvas-data-structure.mdx b/docs/content/docs/development/canvas-data-structure.mdx index 6c5764f..86979a7 100644 --- a/docs/content/docs/development/canvas-data-structure.mdx +++ b/docs/content/docs/development/canvas-data-structure.mdx @@ -48,9 +48,9 @@ type CanvasConnection = { }; ``` -Node positions and sizes use canvas-world coordinates. Edges store only node IDs; their paths are calculated while rendering. Deleting a node removes connected edges, and deleting an image-batch root also removes its child nodes. +Node positions and sizes use canvas-world coordinates. Edges store only node IDs; their paths are calculated while rendering. Deleting a node removes its connected edges. -Common metadata includes content, prompt, status, generation mode, model, size, count, natural dimensions, batch relationships, input order, `storageKey`, MIME type, and byte size. Image and video `content` values are normally session-scoped `blob:` URLs; `storageKey` is the persistent identity. +Common metadata includes content, prompt, status, generation mode, model, size, count, natural dimensions, image results, input order, `storageKey`, MIME type, and byte size. A multi-image generation remains one image node: `images` stores every result and `primaryImageId` selects the result mirrored by the node-level `content` and image metadata. Image and video `content` values are normally session-scoped `blob:` URLs; `storageKey` is the persistent identity. ## Assistant sessions diff --git a/docs/content/docs/development/canvas-data-structure.zh-CN.mdx b/docs/content/docs/development/canvas-data-structure.zh-CN.mdx index cb4d33d..d1daa73 100644 --- a/docs/content/docs/development/canvas-data-structure.zh-CN.mdx +++ b/docs/content/docs/development/canvas-data-structure.zh-CN.mdx @@ -77,6 +77,16 @@ type CanvasNodeData = { `metadata` 当前常用字段: ```ts +type CanvasNodeImage = { + id: string; + content: string; + storageKey: string; + naturalWidth: number; + naturalHeight: number; + bytes: number; + mimeType: string; +}; + type CanvasNodeMetadata = { content?: string; prompt?: string; @@ -90,11 +100,8 @@ type CanvasNodeMetadata = { naturalWidth?: number; naturalHeight?: number; freeResize?: boolean; - isBatchRoot?: boolean; - batchRootId?: string; - batchChildIds?: string[]; + images?: CanvasNodeImage[]; primaryImageId?: string; - imageBatchExpanded?: boolean; inputOrder?: string[]; storageKey?: string; mimeType?: string; @@ -108,7 +115,7 @@ type CanvasNodeMetadata = { - 视频节点:`content` 是当前可播放的视频 URL,通常是 `blob:` URL;`storageKey` 指向本地视频 Blob;`bytes/mimeType` 保存文件信息。 - 文本节点:`content` 保存文本内容;`fontSize` 保存字体大小;`prompt/status/errorDetails` 保存生成状态。 - 生成配置节点:`generationMode/model/size/count/inputOrder` 保存生成配置;`generationMode` 可选择文本、图片或视频;上游输入通过 `connections` 计算。 -- 图片组节点:根节点用 `isBatchRoot/batchChildIds/primaryImageId/imageBatchExpanded` 记录批量生成结果;子图节点用 `batchRootId` 指回根节点。 +- 多图图片节点:批量生成仍只保存为一个图片节点,`images` 记录全部结果,`primaryImageId` 指定由节点级 `content` 和图片元数据展示的主图;展开状态仅属于当前界面,不写入项目数据。 ## 连线结构 diff --git a/docs/content/docs/progress/pending-test.mdx b/docs/content/docs/progress/pending-test.mdx index e454fed..4b7f29d 100644 --- a/docs/content/docs/progress/pending-test.mdx +++ b/docs/content/docs/progress/pending-test.mdx @@ -10,6 +10,7 @@ The current release needs manual verification in these areas: - English and Simplified Chinese switching across navigation, settings, titles, descriptions, Ant Design components, and persisted preferences. - Global Dropdown, Menu, Select, Cascader, and TreeSelect popup backgrounds, hover states, and selected states in both light and dark themes. - Canvas node resize stability, prompt scrolling, generated-prompt restoration, image editing, drag-and-drop references, and generation configuration. +- Multi-image canvas generation: generating N images should create one image node with exactly N stored results and one incoming connection; the collapsed count control should remain clearly legible over any image, hide the floating toolbar when clicked, and expand the N images upward and to the right; every secondary image should keep a clearly legible primary-image action visible in both themes, and clicking the empty canvas should collapse the group. - Canvas Agent startup, MCP status, model and permission controls, approvals, version reporting, diagnostics, streaming responses, history consistency, multi-tab isolation, and local Skill management. - Agent header tabs in English and Chinese at different panel widths; labels should collapse to icons and counts before either edge is clipped. - Agent message layout, Markdown, code blocks, attachments, token statistics, progress timelines, command groups, and scroll-follow behavior. diff --git a/docs/content/docs/progress/pending-test.zh-CN.mdx b/docs/content/docs/progress/pending-test.zh-CN.mdx index db268d1..57bef17 100644 --- a/docs/content/docs/progress/pending-test.zh-CN.mdx +++ b/docs/content/docs/progress/pending-test.zh-CN.mdx @@ -48,6 +48,7 @@ description: 当前版本已实现但仍需人工验证的变更项 - 画布节点提示词:图片等节点下方的提示词超过输入区域高度后,鼠标悬停在输入区域内滚动应查看提示词内容,不应缩放画布。 - 画布节点提示词回显:单图或多图生成完成后,切换选择生成结果的根节点及任一子节点,下方输入区域都应继续显示生成时的提示词;切换到其他节点再选回来后也不应清空。 - 画布生成配置:连接文本节点后连续点击生成(包含失败重试),组装提示词不应重复追加上游文本;在生图、文本、视频、音频之间切换时,实际请求应始终使用当前面板展示的对应类型模型。 +- 画布多图生成:生成 N 张图片后应只创建一个包含 N 张结果的图片节点,并且来源到结果只保留一条连线;收起状态下右上角应显示醒目的「N 张」数量提示,在任意图片背景上都清晰可辨;点击数量后,浮动工具条应暂时隐藏,主图与其余图片应从当前节点向右上方整齐展开且总数严格为 N;其余图片的「设为主图」应常驻显示,并在亮色、深色主题及不同图片背景上保持清晰;点击画布空白处应自动收起并恢复工具条。 - 画布左侧元素列表:点击元素整行应平滑定位并选中对应节点;有内容的图片元素应显示预览按钮,点击后打开大图弹窗且不触发画布定位。 - 配置与用户偏好:导出 JSON 后应包含渠道、默认模型、生成偏好、提示词来源和 WebDAV 配置;在修改当前配置后重新导入该文件,应恢复导出时的设置,错误 JSON 文件应提示格式不正确。配置文件包含 API Key 和 WebDAV 凭据,不应公开分享。 - 模型渠道协议:渠道编辑可选择「火山方舟」并自动填入方舟接口地址;任意名称的生图模型应按方舟 JSON 格式提交参考图,任意名称的视频模型应按方舟任务格式提交和查询,不再依赖模型名包含 `doubao`、`seedream` 或 `seedance`;1080p 不应再因模型名包含 `fast` 被禁用,参考视频应允许最大 200MB、总像素 409600-8295044,并继续校验官方宽高、比例和时长限制。 diff --git a/web/src/components/canvas/canvas-node-hover-toolbar.tsx b/web/src/components/canvas/canvas-node-hover-toolbar.tsx index da760d0..7ee928c 100644 --- a/web/src/components/canvas/canvas-node-hover-toolbar.tsx +++ b/web/src/components/canvas/canvas-node-hover-toolbar.tsx @@ -219,7 +219,7 @@ export function CanvasNodeInfoModal({ node, open, onClose }: { node: CanvasNodeD const { t } = useTranslation(); const [view, setView] = useState<"info" | "json">("info"); const imageBytes = node?.type === CanvasNodeType.Image && node.metadata?.content ? getDataUrlByteSize(node.metadata.content) : 0; - const batchCount = node?.type === CanvasNodeType.Image ? node.metadata?.batchChildIds?.length || 0 : 0; + const batchCount = node?.type === CanvasNodeType.Image ? node.metadata?.images?.length || 0 : 0; const json = useMemo(() => { if (!node) return ""; return JSON.stringify( diff --git a/web/src/components/canvas/canvas-node.tsx b/web/src/components/canvas/canvas-node.tsx index 0ba753d..16f890f 100644 --- a/web/src/components/canvas/canvas-node.tsx +++ b/web/src/components/canvas/canvas-node.tsx @@ -8,7 +8,7 @@ import { getNodeDefinition } from "@/lib/canvas/node-registry"; import { buildNodeContext } from "@/lib/canvas/plugin-node-context"; import { useThemeStore } from "@/stores/use-theme-store"; import { CanvasResourceMentionTextarea } from "./canvas-resource-mention-textarea"; -import { CanvasNodeType, type CanvasNodeData, type Position } from "@/types/canvas"; +import { CanvasNodeType, type CanvasNodeData, type CanvasNodeImage, type Position } from "@/types/canvas"; import type { CanvasNodeContext, CanvasPluginHost } from "@/types/canvas-plugin"; import type { CanvasResourceReference } from "@/lib/canvas/canvas-resource-references"; import { useTranslation } from "react-i18next"; @@ -32,14 +32,9 @@ type CanvasNodeProps = { registryVersion?: number; renderPanel?: (node: CanvasNodeData) => ReactNode; renderNodeContent?: (node: CanvasNodeData) => ReactNode; - batchCount?: number; groupChildCount?: number; isGroupDropTarget?: boolean; batchExpanded?: boolean; - batchClosing?: boolean; - batchOpening?: boolean; - batchRecovering?: boolean; - batchMotion?: { x: number; y: number; index: number }; onMouseDown: (event: React.MouseEvent, nodeId: string) => void; onSelectCapture?: (event: React.MouseEvent, nodeId: string) => void; onHoverStart: (nodeId: string) => void; @@ -51,7 +46,7 @@ type CanvasNodeProps = { onContentChange: (nodeId: string, content: string) => void; onTitleChange: (nodeId: string, title: string) => void; onToggleBatch?: (nodeId: string) => void; - onSetBatchPrimary?: (node: CanvasNodeData) => void; + onSetBatchPrimary?: (nodeId: string, imageId: string) => void; onRetry?: (node: CanvasNodeData) => void; onGenerateImage?: (node: CanvasNodeData) => void; onViewImage?: (node: CanvasNodeData) => void; @@ -66,8 +61,6 @@ type NodeContentRendererProps = { isBatchRoot: boolean; batchCount: number; batchExpanded: boolean; - batchOpening: boolean; - batchRecovering: boolean; renderNodeContent?: (node: CanvasNodeData) => ReactNode; pluginContext?: CanvasNodeContext | null; onContentChange: (nodeId: string, content: string) => void; @@ -76,7 +69,7 @@ type NodeContentRendererProps = { onRetry?: (node: CanvasNodeData) => void; onGenerateImage?: (node: CanvasNodeData) => void; onToggleBatch?: () => void; - onSetBatchPrimary?: () => void; + onSetBatchPrimary?: (imageId: string) => void; groupChildCount: number; }; @@ -95,14 +88,9 @@ export const CanvasNode = React.memo(function CanvasNode({ pluginHost, renderPanel, renderNodeContent, - batchCount = 0, groupChildCount = 0, isGroupDropTarget = false, batchExpanded = false, - batchClosing = false, - batchOpening = false, - batchRecovering = false, - batchMotion, onMouseDown, onSelectCapture, onHoverStart, @@ -132,17 +120,17 @@ export const CanvasNode = React.memo(function CanvasNode({ const hasVideoContent = data.type === CanvasNodeType.Video && Boolean(data.metadata?.content); const hasAudioContent = data.type === CanvasNodeType.Audio && Boolean(data.metadata?.content); const isGroup = data.type === CanvasNodeType.Group; - const isBatchRoot = data.type === CanvasNodeType.Image && Boolean(data.metadata?.isBatchRoot) && batchCount > 1; + const batchCount = data.type === CanvasNodeType.Image ? data.metadata?.images?.length || 0 : 0; + const isBatchRoot = batchCount > 1; // Nodes with the interaction/move toggle ignore content pointer events in move mode and allow interaction in interactive mode. // forceInteractive states such as editing stay interactive, as do empty nodes so their upload and generation actions remain usable. const supportsInteractionToggle = Boolean(definition?.interactionToggle); const forceInteractive = supportsInteractionToggle ? Boolean(definition?.forceInteractive?.(data)) : false; const contentInteractive = !supportsInteractionToggle || forceInteractive || !data.metadata?.content ? true : Boolean(data.metadata?.interactive); - const isBatchChild = data.type === CanvasNodeType.Image && Boolean(data.metadata?.batchRootId); // Transparent nodes such as SVGs blend into the canvas while retaining outlines for selected or related states. const transparentBg = Boolean(definition?.transparentBackground); const isActive = isConnectionTarget || isSelected || isFocusRelated; - const imageBorderColor = isActive ? selectionBlue : isRelated && !isBatchChild ? theme.node.muted : "transparent"; + const imageBorderColor = isActive ? selectionBlue : isRelated ? theme.node.muted : "transparent"; const textareaRef = useRef(null); const titleInputRef = useRef(null); const resizeRef = useRef({ @@ -362,7 +350,7 @@ export const CanvasNode = React.memo(function CanvasNode({ background: isGroup ? `${theme.toolbar.panel}66` : hasImageContent || hasVideoContent || transparentBg ? "transparent" : theme.node.fill, borderColor: isGroup ? (isGroupDropTarget || isActive ? selectionBlue : theme.node.stroke) : hasImageContent ? imageBorderColor : isActive ? selectionBlue : isRelated ? theme.node.muted : transparentBg ? "transparent" : theme.node.stroke, borderStyle: isGroup ? "dashed" : "solid", - boxShadow: isGroupDropTarget ? `0 0 0 2px ${selectionBlue}66, inset 0 0 0 999px ${selectionBlue}10` : isActive ? `0 0 0 1px ${selectionBlue}55` : isRelated && !isBatchChild ? `0 0 0 1px ${theme.node.muted}55, 0 18px 48px rgba(0,0,0,.14)` : undefined, + boxShadow: isGroupDropTarget ? `0 0 0 2px ${selectionBlue}66, inset 0 0 0 999px ${selectionBlue}10` : isActive ? `0 0 0 1px ${selectionBlue}55` : isRelated ? `0 0 0 1px ${theme.node.muted}55, 0 18px 48px rgba(0,0,0,.14)` : undefined, }} onMouseDown={(event) => onMouseDown(event, data.id)} onDoubleClick={(event) => { @@ -391,11 +379,6 @@ export const CanvasNode = React.memo(function CanvasNode({ { background: isGroup ? "transparent" : hasImageContent || hasVideoContent || transparentBg ? "transparent" : theme.node.fill, pointerEvents: contentInteractive ? undefined : "none", - "--batch-from-x": `${batchMotion?.x || 0}px`, - "--batch-from-y": `${batchMotion?.y || 0}px`, - "--batch-from-rotate": `${6 + (batchMotion?.index || 0) * 4}deg`, - animation: data.metadata?.batchRootId ? (batchClosing ? "canvas-batch-child-out 260ms cubic-bezier(.4,0,.2,1) both" : "canvas-batch-child-in 340ms cubic-bezier(.2,.85,.18,1) both") : undefined, - animationDelay: data.metadata?.batchRootId ? `${batchClosing ? 0 : 45 + (batchMotion?.index || 0) * 24}ms` : undefined, } as React.CSSProperties } > @@ -407,8 +390,6 @@ export const CanvasNode = React.memo(function CanvasNode({ isBatchRoot={isBatchRoot} batchCount={batchCount} batchExpanded={batchExpanded} - batchOpening={batchOpening} - batchRecovering={batchRecovering} renderNodeContent={renderNodeContent} pluginContext={pluginContext} mentionReferences={mentionReferences} @@ -417,7 +398,7 @@ export const CanvasNode = React.memo(function CanvasNode({ onRetry={onRetry} onGenerateImage={onGenerateImage} onToggleBatch={() => onToggleBatch?.(data.id)} - onSetBatchPrimary={() => onSetBatchPrimary?.(data)} + onSetBatchPrimary={(imageId) => onSetBatchPrimary?.(data.id, imageId)} groupChildCount={groupChildCount} /> @@ -582,40 +563,21 @@ function TextContent({ node, theme, isEditingContent, textareaRef, mentionRefere } function ImageNodeContent(props: NodeContentRendererProps) { - if (!props.node.metadata?.content && props.isBatchRoot) { - const content = - props.node.metadata?.status === "loading" ? ( - - ) : props.node.metadata?.status === "error" ? ( - - ) : ( - - ); - return ( - - {content} - - ); - } if (!props.node.metadata?.content) return ; return ( ); } -function EmptyImageContent({ theme, isBatchRoot, batchCount, batchExpanded, batchOpening, batchRecovering, onToggleBatch }: NodeContentRendererProps) { +function EmptyImageContent({ theme }: NodeContentRendererProps) { const { t } = useTranslation(); - const content = ( + return (
@@ -623,13 +585,6 @@ function EmptyImageContent({ theme, isBatchRoot, batchCount, batchExpanded, batc {t("canvas.node.emptyImage")}
); - if (isBatchRoot) - return ( - - {content} - - ); - return content; } function VideoNodeContent({ node, theme }: NodeContentRendererProps) { @@ -666,29 +621,29 @@ function AudioNodeContent({ node, theme }: NodeContentRendererProps) { function ImageContent({ node, - isBatchRoot, - batchCount, batchExpanded, - batchOpening, - batchRecovering, onToggleBatch, onSetBatchPrimary, }: { node: CanvasNodeData; - isBatchRoot: boolean; - batchCount: number; batchExpanded: boolean; - batchOpening: boolean; - batchRecovering: boolean; onToggleBatch?: () => void; - onSetBatchPrimary?: () => void; + onSetBatchPrimary?: (imageId: string) => void; }) { const theme = canvasThemes[useThemeStore((state) => state.theme)]; const { t } = useTranslation(); - const isBatchChild = Boolean(node.metadata?.batchRootId); + const images = node.metadata?.images || []; + const batchCount = images.length; + const isBatchRoot = batchCount > 1; + const primaryImageId = node.metadata?.primaryImageId || images[0]?.id; return ( - + + {batchExpanded + ? images + .filter((image) => image.id !== primaryImageId) + .map((image, index) => onSetBatchPrimary?.(image.id)} />) + : null}
{ event.stopPropagation(); @@ -711,30 +666,64 @@ function ImageContent({ onMouseDown={(event) => event.stopPropagation()} onPointerDown={(event) => event.stopPropagation()} > - {batchCount} - - - ) : null} - {isBatchChild ? ( - ) : null} ); } +function ExpandedImageCard({ node, image, index, onSetPrimary }: { node: CanvasNodeData; image: CanvasNodeImage; index: number; onSetPrimary: () => void }) { + const theme = canvasThemes[useThemeStore((state) => state.theme)]; + const { t } = useTranslation(); + const count = node.metadata?.images?.length || 0; + const rows = Math.ceil(count / 2); + const rootSlot = (rows - 1) * 2; + const slot = index >= rootSlot ? index + 1 : index; + const column = slot % 2; + const row = Math.floor(slot / 2); + const x = column * (node.width + 18); + const y = (row - rows + 1) * (node.height + 18); + + return ( +
event.stopPropagation()} + onPointerDown={(event) => event.stopPropagation()} + onDoubleClick={(event) => event.stopPropagation()} + > + {node.title} + +
+ ); +} + function ImageInfoBar({ node }: { node: CanvasNodeData }) { const width = Math.round(node.metadata?.naturalWidth || node.width); const height = Math.round(node.metadata?.naturalHeight || node.height); @@ -749,7 +738,7 @@ function ImageInfoBar({ node }: { node: CanvasNodeData }) { ); } -function BatchFrame({ batchCount, batchExpanded, batchOpening, batchRecovering, onToggleBatch, children }: { batchCount: number; batchExpanded: boolean; batchOpening: boolean; batchRecovering: boolean; onToggleBatch?: () => void; children: ReactNode }) { +function BatchFrame({ batchCount, batchExpanded, onToggleBatch, children }: { batchCount: number; batchExpanded: boolean; onToggleBatch?: () => void; children: ReactNode }) { const theme = canvasThemes[useThemeStore((state) => state.theme)]; const isBatchRoot = batchCount > 1; return ( @@ -774,9 +763,8 @@ function BatchFrame({ batchCount, batchExpanded, batchOpening, batchRecovering, inset: 0, background: `linear-gradient(135deg, ${theme.node.panel}, ${theme.node.fill})`, borderColor: theme.node.stroke, - opacity: batchExpanded && !batchOpening ? 0.34 : 1, - transform: - batchOpening || batchRecovering ? `translate(${54 + index * 22}px, ${20 + index * 12}px) rotate(${8 + index * 5}deg) scale(.98)` : `translate(${34 + index * 18}px, ${14 + index * 10}px) rotate(${6 + index * 4}deg)`, + opacity: batchExpanded ? 0 : 1, + transform: `translate(${30 + index * 14}px, ${12 + index * 8}px) rotate(${5 + index * 3}deg)`, zIndex: -index - 1, }} /> diff --git a/web/src/lib/canvas/canvas-generation-helpers.ts b/web/src/lib/canvas/canvas-generation-helpers.ts index a70c2f5..53beab1 100644 --- a/web/src/lib/canvas/canvas-generation-helpers.ts +++ b/web/src/lib/canvas/canvas-generation-helpers.ts @@ -48,7 +48,8 @@ export async function hydrateCanvasImages(nodes: CanvasNodeData[]) { const content = node.metadata?.content; if ((node.type === CanvasNodeType.Video || node.type === CanvasNodeType.Audio) && node.metadata?.storageKey) return { ...node, metadata: { ...node.metadata, content: await resolveMediaUrl(node.metadata.storageKey, content) } }; if (node.type !== CanvasNodeType.Image || !content) return node; - if (node.metadata?.storageKey) return { ...node, metadata: { ...node.metadata, content: await resolveImageUrl(node.metadata.storageKey, content) } }; + const images = await Promise.all((node.metadata.images || []).map(async (image) => ({ ...image, content: await resolveImageUrl(image.storageKey, image.content) }))); + if (node.metadata?.storageKey) return { ...node, metadata: { ...node.metadata, content: await resolveImageUrl(node.metadata.storageKey, content), images } }; if (!content.startsWith("data:image/")) return node; return { ...node, metadata: { ...node.metadata, ...imageMetadata(await uploadImage(content)) } }; }), diff --git a/web/src/lib/canvas/canvas-node-geometry.ts b/web/src/lib/canvas/canvas-node-geometry.ts index 02c5d92..abd0b65 100644 --- a/web/src/lib/canvas/canvas-node-geometry.ts +++ b/web/src/lib/canvas/canvas-node-geometry.ts @@ -74,18 +74,3 @@ export function normalizeConnection(firstNodeId: string, secondNodeId: string, n if (first.type === CanvasNodeType.Config) return { fromNodeId: first.id, toNodeId: second.id }; return { fromNodeId: first.id, toNodeId: second.id }; } - -export function isHiddenBatchChild(node: CanvasNodeData, nodes: CanvasNodeData[], collapsingBatchIds?: Set) { - const rootId = node.metadata?.batchRootId; - if (!rootId) return false; - const root = nodes.find((item) => item.id === rootId); - if (root && collapsingBatchIds?.has(rootId)) return false; - return Boolean(root && !root.metadata?.imageBatchExpanded); -} - -export function isHiddenBatchConnectionEndpoint(node: CanvasNodeData, nodes: CanvasNodeData[]) { - const rootId = node.metadata?.batchRootId; - if (!rootId) return false; - const root = nodes.find((item) => item.id === rootId); - return Boolean(root && !root.metadata?.imageBatchExpanded); -} diff --git a/web/src/pages/canvas/project.tsx b/web/src/pages/canvas/project.tsx index 264ed2b..5f0e819 100644 --- a/web/src/pages/canvas/project.tsx +++ b/web/src/pages/canvas/project.tsx @@ -46,7 +46,7 @@ import { usePluginHost } from "@/pages/canvas/hooks/use-plugin-host"; import { buildNodeMentionReferences, type CanvasResourceReference } from "@/lib/canvas/canvas-resource-references"; import { exportCanvasProjects } from "@/lib/canvas/canvas-export"; import { applyNodeConfigPatch, audioMetadata, buildAudioGenerationMetadata, buildImageGenerationMetadata, createCanvasNode, imageMetadata, videoMetadata } from "@/lib/canvas/canvas-node-factory"; -import { findContainingGroupId, findGroupDropTarget, getConnectionTargetAnchor, isHiddenBatchChild, isHiddenBatchConnectionEndpoint, normalizeConnection, snapNodesIntoGroup } from "@/lib/canvas/canvas-node-geometry"; +import { findContainingGroupId, findGroupDropTarget, getConnectionTargetAnchor, normalizeConnection, snapNodesIntoGroup } from "@/lib/canvas/canvas-node-geometry"; import { audioExtension, buildAngleLabel, @@ -77,6 +77,7 @@ import { type CanvasAssistantSession, type CanvasConnection, type CanvasNodeData, + type CanvasNodeImage, type CanvasNodeMetadata, type CanvasNodeTypeId, type ConnectionHandle, @@ -233,8 +234,7 @@ function InfiniteCanvasPage() { const [titleEditing, setTitleEditing] = useState(false); const [titleDraft, setTitleDraft] = useState(""); const [historyState, setHistoryState] = useState({ canUndo: false, canRedo: false }); - const [collapsingBatchIds, setCollapsingBatchIds] = useState>(new Set()); - const [openingBatchIds, setOpeningBatchIds] = useState>(new Set()); + const [expandedImageNodeId, setExpandedImageNodeId] = useState(null); const [isNodeDragging, setIsNodeDragging] = useState(false); const [isNodeResizing, setIsNodeResizing] = useState(false); const [dropTargetGroupId, setDropTargetGroupId] = useState(null); @@ -532,7 +532,6 @@ function InfiniteCanvasPage() { let bestPriority = Number.POSITIVE_INFINITY; [...nodesRef.current] - .filter((node) => !isHiddenBatchChild(node, nodesRef.current)) .reverse() .forEach((node) => { const anchor = getConnectionTargetAnchor(node, current); @@ -568,8 +567,8 @@ function InfiniteCanvasPage() { const viewRight = viewLeft + width / viewport.k + padding * 2; const viewBottom = viewTop + height / viewport.k + padding * 2; - return nodes.filter((node) => !isHiddenBatchChild(node, nodes, collapsingBatchIds) && node.position.x + node.width > viewLeft && node.position.x < viewRight && node.position.y + node.height > viewTop && node.position.y < viewBottom); - }, [collapsingBatchIds, nodes, size.height, size.width, viewport.k, viewport.x, viewport.y]); + return nodes.filter((node) => node.position.x + node.width > viewLeft && node.position.x < viewRight && node.position.y + node.height > viewTop && node.position.y < viewBottom); + }, [nodes, size.height, size.width, viewport.k, viewport.x, viewport.y]); const nodeById = useMemo(() => new Map(nodes.map((node) => [node.id, node])), [nodes]); // The toolbar follows a single selected node selected by click, creation, marquee, or keyboard. @@ -586,13 +585,6 @@ function InfiniteCanvasPage() { const previewNode = previewNodeId ? nodeById.get(previewNodeId) || null : null; const hasMultipleSelectedNodes = selectedNodeIds.size > 1; const activeNodeId = hasMultipleSelectedNodes ? null : hoveredNodeId || (selectedNodeIds.size === 1 ? Array.from(selectedNodeIds)[0] : null); - const batchChildCountById = useMemo(() => { - const map = new Map(); - nodes.forEach((node) => { - if (node.metadata?.isBatchRoot) map.set(node.id, node.metadata.batchChildIds?.length || 0); - }); - return map; - }, [nodes]); const groupChildCountById = useMemo(() => { const map = new Map(); nodes.forEach((node) => { @@ -601,19 +593,6 @@ function InfiniteCanvasPage() { }); return map; }, [nodes]); - const batchMotionById = useMemo(() => { - const map = new Map(); - nodes.forEach((node) => { - const rootId = node.metadata?.batchRootId; - if (!rootId) return; - const root = nodeById.get(rootId); - const index = root?.metadata?.batchChildIds?.indexOf(node.id) ?? 0; - const stackX = root ? root.position.x + 34 + index * 14 : node.position.x; - const stackY = root ? root.position.y + 14 + index * 8 : node.position.y; - map.set(node.id, { x: stackX - node.position.x, y: stackY - node.position.y, index: Math.max(index, 0) }); - }); - return map; - }, [nodeById, nodes]); const relatedHighlight = useMemo(() => { const nodeIds = new Set(); const connectionIds = new Set(); @@ -712,29 +691,12 @@ function InfiniteCanvasPage() { (ids: Set) => { if (!ids.size) return; const allIds = new Set(ids); - nodesRef.current.forEach((node) => { - if (ids.has(node.id)) node.metadata?.batchChildIds?.forEach((childId) => allIds.add(childId)); - }); setNodes((prev) => { const next = prev.filter((node) => !allIds.has(node.id)); return next.map((node) => { const groupId = node.metadata?.groupId; if (groupId && allIds.has(groupId)) return { ...node, metadata: { ...node.metadata, groupId: undefined } }; - const childIds = node.metadata?.batchChildIds?.filter((childId) => !allIds.has(childId)); - if (!node.metadata?.isBatchRoot || childIds?.length === node.metadata.batchChildIds?.length) return node; - const primaryImageId = childIds?.includes(node.metadata.primaryImageId || "") ? node.metadata.primaryImageId : childIds?.[0]; - const primaryNode = next.find((item) => item.id === primaryImageId); - return { - ...node, - metadata: { - ...node.metadata, - batchChildIds: childIds, - primaryImageId, - content: primaryNode?.metadata?.content || node.metadata.content, - naturalWidth: primaryNode?.metadata?.naturalWidth || node.metadata.naturalWidth, - naturalHeight: primaryNode?.metadata?.naturalHeight || node.metadata.naturalHeight, - }, - }; + return node; }); }); setConnections((prev) => prev.filter((conn) => !allIds.has(conn.fromNodeId) && !allIds.has(conn.toNodeId))); @@ -764,6 +726,7 @@ function InfiniteCanvasPage() { const deselectCanvas = useCallback(() => { cancelPendingConnectionCreate(); + setExpandedImageNodeId(null); setSelectedNodeIds(new Set()); setSelectedConnectionId(null); setContextMenu(null); @@ -1004,6 +967,7 @@ function InfiniteCanvasPage() { (event: ReactPointerEvent) => { setContextMenu(null); setNodeCreatePosition(null); + setExpandedImageNodeId(null); if (pendingConnectionCreateRef.current) cancelPendingConnectionCreate(); if (event.button !== 0) return; @@ -1076,7 +1040,6 @@ function InfiniteCanvasPage() { const dragIds = new Set(nextSelected); currentNodes.forEach((node) => { if (!nextSelected.has(node.id)) return; - node.metadata?.batchChildIds?.forEach((childId) => dragIds.add(childId)); if (node.type === CanvasNodeType.Group) { currentNodes.forEach((child) => { if (child.metadata?.groupId === node.id) dragIds.add(child.id); @@ -1209,7 +1172,6 @@ function InfiniteCanvasPage() { const nextSelected = new Set(currentSelection.additive ? currentSelection.initialSelectedNodeIds : []); nodesRef.current - .filter((node) => !isHiddenBatchChild(node, nodesRef.current)) .forEach((node) => { const intersects = rectX < node.position.x + node.width && rectX + rectW > node.position.x && rectY < node.position.y + node.height && rectY + rectH > node.position.y; @@ -1484,58 +1446,35 @@ function InfiniteCanvasPage() { }, []); const toggleBatchExpanded = useCallback((nodeId: string) => { - const isExpanded = Boolean(nodesRef.current.find((node) => node.id === nodeId)?.metadata?.imageBatchExpanded); - if (isExpanded) { - setCollapsingBatchIds((prev) => new Set(prev).add(nodeId)); - window.setTimeout(() => { - setCollapsingBatchIds((prev) => { - const next = new Set(prev); - next.delete(nodeId); - return next; - }); - }, 320); - } else { - setOpeningBatchIds((prev) => new Set(prev).add(nodeId)); - window.setTimeout(() => { - setOpeningBatchIds((prev) => { - const next = new Set(prev); - next.delete(nodeId); - return next; - }); - }, 260); - } + setExpandedImageNodeId((current) => (current === nodeId ? null : nodeId)); + }, []); + + const setBatchPrimary = useCallback((nodeId: string, imageId: string) => { setNodes((prev) => prev.map((node) => { if (node.id !== nodeId) return node; - return { ...node, metadata: { ...node.metadata, imageBatchExpanded: !node.metadata?.imageBatchExpanded } }; + const image = node.metadata?.images?.find((item) => item.id === imageId); + if (!image) return node; + const size = fitNodeSize(image.naturalWidth, image.naturalHeight); + return { + ...node, + position: { x: node.position.x + node.width / 2 - size.width / 2, y: node.position.y + node.height / 2 - size.height / 2 }, + ...size, + metadata: { + ...node.metadata, + content: image.content, + storageKey: image.storageKey, + naturalWidth: image.naturalWidth, + naturalHeight: image.naturalHeight, + bytes: image.bytes, + mimeType: image.mimeType, + primaryImageId: image.id, + }, + }; }), ); }, []); - const setBatchPrimary = useCallback((child: CanvasNodeData) => { - const rootId = child.metadata?.batchRootId; - if (!rootId || !child.metadata?.content) return; - setNodes((prev) => - prev.map((node) => - node.id === rootId - ? { - ...node, - width: child.width, - height: child.height, - metadata: { - ...node.metadata, - content: child.metadata?.content, - primaryImageId: child.id, - naturalWidth: child.metadata?.naturalWidth, - naturalHeight: child.metadata?.naturalHeight, - freeResize: child.metadata?.freeResize, - }, - } - : node, - ), - ); - }, []); - const openTextEditor = useCallback((node: CanvasNodeData) => { if (node.type !== CanvasNodeType.Text) return; setSelectedNodeIds(new Set([node.id])); @@ -1928,10 +1867,7 @@ function InfiniteCanvasPage() { ...imageMetadata(image), errorDetails: undefined, freeResize: false, - isBatchRoot: undefined, - batchRootId: undefined, - batchChildIds: undefined, - batchUsesReferenceImages: undefined, + images: undefined, generationType: undefined, model: undefined, size: undefined, @@ -1939,7 +1875,6 @@ function InfiniteCanvasPage() { count: undefined, references: undefined, primaryImageId: undefined, - imageBatchExpanded: undefined, }, } : node, @@ -2112,18 +2047,15 @@ function InfiniteCanvasPage() { const parentConfig = NODE_DEFAULT_SIZE[isConfigNode ? CanvasNodeType.Config : isImageNode ? CanvasNodeType.Image : CanvasNodeType.Text]; const imageConfig = NODE_DEFAULT_SIZE[CanvasNodeType.Image]; const parentPosition = sourceNode?.position || { x: 0, y: 0 }; - const gap = 96; - const rowGap = 36; const rootId = isEmptyImageNode ? nodeId : nanoid(); - const childIds = count > 1 ? Array.from({ length: count }, () => nanoid()) : []; - const targetIds = count > 1 ? childIds : [rootId]; - pendingChildIds = isEmptyImageNode ? childIds : [rootId, ...childIds]; + const imageIds = Array.from({ length: count }, () => nanoid()); + pendingChildIds = [rootId]; const rootNode: CanvasNodeData = { id: rootId, type: CanvasNodeType.Image, title: effectivePrompt.slice(0, 32) || "Generated Image", position: { - x: isEmptyImageNode ? parentPosition.x : parentPosition.x + parentConfig.width + gap, + x: isEmptyImageNode ? parentPosition.x : parentPosition.x + parentConfig.width + 96, y: parentPosition.y + parentConfig.height / 2 - imageConfig.height / 2, }, width: isEmptyImageNode ? sourceNode?.width || imageConfig.width : imageConfig.width, @@ -2131,26 +2063,10 @@ function InfiniteCanvasPage() { metadata: { prompt: effectivePrompt, status: NODE_STATUS_LOADING, - isBatchRoot: count > 1, - batchChildIds: count > 1 ? childIds : undefined, - batchUsesReferenceImages: referenceImages.length > 0, + images: [], ...generationMetadata, - imageBatchExpanded: count > 1 ? true : undefined, }, }; - const childNodes: CanvasNodeData[] = childIds.map((id, index) => ({ - id, - type: CanvasNodeType.Image, - title: effectivePrompt.slice(0, 32) || "Generated Image", - position: { - x: rootNode.position.x + rootNode.width + 120 + (index % 2) * (imageConfig.width + 36), - y: rootNode.position.y + Math.floor(index / 2) * (imageConfig.height + rowGap), - }, - width: imageConfig.width, - height: imageConfig.height, - metadata: { prompt: effectivePrompt, status: NODE_STATUS_LOADING, batchRootId: count > 1 ? rootId : undefined, ...generationMetadata }, - })); - const batchConnections = [...(isEmptyImageNode ? [] : [{ id: nanoid(), fromNodeId: nodeId, toNodeId: rootId }]), ...childIds.map((childId) => ({ id: nanoid(), fromNodeId: rootId, toNodeId: childId }))]; setNodes((prev) => [ ...prev.map((node) => @@ -2185,51 +2101,49 @@ function InfiniteCanvasPage() { : node, ), ...(isEmptyImageNode ? [] : [rootNode]), - ...childNodes, ]); - setConnections((prev) => [...prev, ...batchConnections]); + if (!isEmptyImageNode) setConnections((prev) => [...prev, { id: nanoid(), fromNodeId: nodeId, toNodeId: rootId }]); setSelectedNodeIds(new Set([nodeId])); setSelectedConnectionId(null); setDialogNodeId(nodeId); - const controller = runController; - targetIds.forEach((targetId) => startGenerationRequest(targetId, nodeId, nodeId, controller)); - if (count > 1) startGenerationRequest(rootId, nodeId, nodeId, controller); + const controller = rootId === nodeId ? runController : startGenerationRequest(rootId, nodeId, nodeId, runController); let hasSuccess = false; let hasFailure = false; let firstError = ""; await Promise.all( - targetIds.map(async (targetId) => { + imageIds.map(async (imageId) => { try { const image = referenceImages.length ? await requestEdit({ ...generationConfig, count: "1" }, effectivePrompt, referenceImages, undefined, { signal: controller.signal }).then((items) => items[0]) : await requestGeneration({ ...generationConfig, count: "1" }, effectivePrompt, { signal: controller.signal }).then((items) => items[0]); const uploaded = await uploadImage(image.dataUrl); const imageSize = fitNodeSize(uploaded.width, uploaded.height, imageConfig.width, imageConfig.height); - setNodes((prev) => { - const root = prev.find((node) => node.id === rootId); - return prev.map((node) => { - if (node.id !== targetId && node.id !== rootId) return node; + const item: CanvasNodeImage = { id: imageId, content: uploaded.url, storageKey: uploaded.storageKey, naturalWidth: uploaded.width, naturalHeight: uploaded.height, bytes: uploaded.bytes, mimeType: uploaded.mimeType }; + setNodes((prev) => + prev.map((node) => { + if (node.id !== rootId) return node; + const images = [...(node.metadata?.images || []), item].sort((a, b) => imageIds.indexOf(a.id) - imageIds.indexOf(b.id)); + if (node.metadata?.primaryImageId) return { ...node, metadata: { ...node.metadata, images } }; const center = { x: node.position.x + node.width / 2, y: node.position.y + node.height / 2 }; - if (node.id === rootId && (targetId === rootId || !root?.metadata?.primaryImageId)) - return { - ...node, - position: { x: center.x - imageSize.width / 2, y: center.y - imageSize.height / 2 }, - width: imageSize.width, - height: imageSize.height, - metadata: { ...node.metadata, ...imageMetadata(uploaded), primaryImageId: targetId }, - }; - if (node.id === targetId) - return { - ...node, - position: { x: center.x - imageSize.width / 2, y: center.y - imageSize.height / 2 }, - width: imageSize.width, - height: imageSize.height, - metadata: { ...node.metadata, ...imageMetadata(uploaded) }, - }; - return node; - }); - }); + return { + ...node, + position: { x: center.x - imageSize.width / 2, y: center.y - imageSize.height / 2 }, + ...imageSize, + metadata: { + ...node.metadata, + content: item.content, + storageKey: item.storageKey, + naturalWidth: item.naturalWidth, + naturalHeight: item.naturalHeight, + bytes: item.bytes, + mimeType: item.mimeType, + images, + primaryImageId: imageId, + }, + }; + }), + ); hasSuccess = true; if (isConfigNode) setNodes((prev) => prev.map((node) => (node.id === nodeId ? { ...node, metadata: { ...node.metadata, status: NODE_STATUS_SUCCESS, errorDetails: undefined } } : node))); return true; @@ -2238,14 +2152,11 @@ function InfiniteCanvasPage() { const errorDetails = error instanceof Error ? error.message : t("canvas.projectPage.generationFailed"); if (!firstError) firstError = errorDetails; hasFailure = true; - setNodes((prev) => prev.map((node) => (node.id === targetId ? { ...node, metadata: { ...node.metadata, status: NODE_STATUS_ERROR, errorDetails } } : node))); - } finally { - finishGenerationRequest(targetId, controller); } return false; }), ); - if (count > 1) finishGenerationRequest(rootId, controller); + if (rootId !== nodeId) finishGenerationRequest(rootId, controller); if (controller.signal.aborted) { setNodes((prev) => prev.map((node) => (node.id === nodeId && isConfigNode && node.metadata?.status === NODE_STATUS_LOADING ? { ...node, metadata: { ...node.metadata, status: NODE_STATUS_IDLE, errorDetails: undefined } } : node))); return; @@ -2257,10 +2168,8 @@ function InfiniteCanvasPage() { prev.map((node) => node.id === nodeId && isConfigNode ? { ...node, metadata: { ...node.metadata, status: hasSuccess ? NODE_STATUS_SUCCESS : NODE_STATUS_ERROR, errorDetails: hasSuccess ? undefined : t("canvas.projectPage.generationFailed") } } - : node.id === nodeId && isEmptyImageNode - ? { ...node, metadata: { ...node.metadata, status: hasSuccess ? NODE_STATUS_SUCCESS : NODE_STATUS_ERROR, errorDetails: hasSuccess ? undefined : t("canvas.projectPage.generationFailed") } } - : node.id === rootId && !hasSuccess && !targetIds.includes(node.id) - ? { ...node, metadata: { ...node.metadata, status: NODE_STATUS_ERROR, errorDetails: t("canvas.projectPage.allFailed") } } + : node.id === rootId + ? { ...node, metadata: { ...node.metadata, status: hasSuccess ? NODE_STATUS_SUCCESS : NODE_STATUS_ERROR, errorDetails: hasSuccess ? undefined : t("canvas.projectPage.allFailed") } } : node, ), ); @@ -2450,8 +2359,7 @@ function InfiniteCanvasPage() { const handleRetryNode = useCallback( async (node: CanvasNodeData) => { const sourceNode = findRetrySourceNode(node.id, nodesRef.current, connectionsRef.current) || node; - const batchRoot = node.metadata?.batchRootId ? nodesRef.current.find((item) => item.id === node.metadata?.batchRootId) : null; - const savedImageMetadata = node.type === CanvasNodeType.Image ? { ...batchRoot?.metadata, ...node.metadata } : undefined; + const savedImageMetadata = node.type === CanvasNodeType.Image ? node.metadata : undefined; const hasSavedImageMetadata = Boolean(savedImageMetadata?.generationType); const generationConfig = hasSavedImageMetadata && savedImageMetadata @@ -2478,7 +2386,7 @@ function InfiniteCanvasPage() { const generationType = savedImageMetadata?.generationType; const useReferenceImages = generationType ? generationType === "edit" : Boolean(context?.referenceImages.length); const retryReferenceImages = - hasSavedImageMetadata && savedImageMetadata ? await resolveMetadataReferences(savedImageMetadata) : useReferenceImages ? (context?.referenceImages.length ? context.referenceImages : sourceNodeReferenceImages(batchRoot || sourceNode)) : []; + hasSavedImageMetadata && savedImageMetadata ? await resolveMetadataReferences(savedImageMetadata) : useReferenceImages ? (context?.referenceImages.length ? context.referenceImages : sourceNodeReferenceImages(sourceNode)) : []; if (useReferenceImages && !retryReferenceImages) { message.error(t("canvas.projectPage.referenceMissing")); setNodes((prev) => prev.map((item) => (item.id === node.id ? { ...item, metadata: { ...item.metadata, status: NODE_STATUS_ERROR, errorDetails: t("canvas.projectPage.referenceMissing") } } : item))); @@ -2546,6 +2454,15 @@ function InfiniteCanvasPage() { const uploadedImage = await uploadImage(image.dataUrl); const imageConfig = NODE_DEFAULT_SIZE[CanvasNodeType.Image]; const imageSize = fitNodeSize(uploadedImage.width, uploadedImage.height, imageConfig.width, imageConfig.height); + const retryImage: CanvasNodeImage = { + id: node.metadata?.primaryImageId || nanoid(), + content: uploadedImage.url, + storageKey: uploadedImage.storageKey, + naturalWidth: uploadedImage.width, + naturalHeight: uploadedImage.height, + bytes: uploadedImage.bytes, + mimeType: uploadedImage.mimeType, + }; const generationMetadata = savedImageMetadata?.generationType ? { generationType: savedImageMetadata.generationType, @@ -2565,7 +2482,14 @@ function InfiniteCanvasPage() { type: CanvasNodeType.Image, width: imageSize.width, height: imageSize.height, - metadata: { ...item.metadata, ...imageMetadata(uploadedImage), prompt, ...generationMetadata }, + metadata: { + ...item.metadata, + ...imageMetadata(uploadedImage), + images: item.metadata?.images?.map((current) => (current.id === retryImage.id ? retryImage : current)), + primaryImageId: retryImage.id, + prompt, + ...generationMetadata, + }, } : item, ), @@ -2806,11 +2730,6 @@ function InfiniteCanvasPage() { > {connections - .filter((connection) => { - const from = nodeById.get(connection.fromNodeId); - const to = nodeById.get(connection.toNodeId); - return Boolean(from && to && !isHiddenBatchConnectionEndpoint(from, nodes) && !isHiddenBatchConnectionEndpoint(to, nodes)); - }) .map((connection) => { const from = nodeById.get(connection.fromNodeId); const to = nodeById.get(connection.toNodeId); @@ -2851,14 +2770,9 @@ function InfiniteCanvasPage() { isConnecting={Boolean(connectingParams)} editRequestNonce={editingNodeId === node.id ? editRequestNonce : 0} showPanel={dialogNodeId === node.id && !selectionBox && !getNodeDefinition(node.type)?.hidePanel} - batchCount={batchChildCountById.get(node.id) || 0} groupChildCount={groupChildCountById.get(node.id) || 0} isGroupDropTarget={dropTargetGroupId === node.id} - batchExpanded={Boolean(node.metadata?.imageBatchExpanded)} - batchClosing={Boolean(node.metadata?.batchRootId && collapsingBatchIds.has(node.metadata.batchRootId))} - batchOpening={openingBatchIds.has(node.id)} - batchRecovering={collapsingBatchIds.has(node.id)} - batchMotion={batchMotionById.get(node.id)} + batchExpanded={expandedImageNodeId === node.id} showImageInfo={showImageInfo} mentionReferences={mentionReferencesByNodeId.get(node.id) || EMPTY_REFERENCES} pluginHost={pluginHost} @@ -2911,7 +2825,7 @@ function InfiniteCanvasPage() {