From 4272b8e1b7ba07f52590284f00c8b8b8fb8d661d Mon Sep 17 00:00:00 2001 From: HouYunFei <1844025705@qq.com> Date: Thu, 6 Aug 2026 14:15:04 +0800 Subject: [PATCH] feat(canvas): add support for creating independent image nodes from expanded alternatives --- CHANGELOG.md | 2 ++ docs/content/docs/progress/pending-test.mdx | 1 + web/src/components/canvas/canvas-node.tsx | 35 +++++++++++--------- web/src/i18n/locales/en-US.ts | 2 +- web/src/i18n/locales/zh-CN.ts | 2 +- web/src/pages/canvas/project.tsx | 36 +++++++++++++++++++++ 6 files changed, 61 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 76a2168..40fca25 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ + [新增] 画布多图展开后支持单独重新生成或删除失败的图片槽位。 + [修复] 画布多图失败槽位重试时正确传递图片标识,避免触发未定义变量错误。 + [修复] 调整展开的多图节点尺寸时先收起子图并停止清理阶段的状态回写,避免 React 循环更新崩溃。 ++ [新增] 画布多图备选图片支持创建为独立图片节点。 ++ [修复] 统一画布多图备选操作按钮的主题背景与文字颜色,避免按钮内容对比度不足。 ## v0.14.0 - 2026-08-05 diff --git a/docs/content/docs/progress/pending-test.mdx b/docs/content/docs/progress/pending-test.mdx index 9f8e814..ce953ae 100644 --- a/docs/content/docs/progress/pending-test.mdx +++ b/docs/content/docs/progress/pending-test.mdx @@ -15,6 +15,7 @@ The current release needs manual verification in these areas: - Multi-image primary selection: choosing another primary image should preserve the node center and current maximum edge while adapting to the new image ratio; free-resize nodes should keep their exact dimensions, and the viewport zoom indicator must not change. - Failed multi-image slots: every failed slot, including the current primary slot, should expose retry and delete actions. Retry should update only that slot without replacing an existing successful primary image; delete should remove only that failed slot and update the displayed count. - Multi-image resize: starting a resize while child images are expanded should collapse the group once, then resize the root node normally without triggering nested React updates or an application error. +- Multi-image alternatives: every completed expanded alternative should provide clearly legible create-copy and set-primary actions in both themes; create-copy should add an independent image node to the right of the group, select it, and leave the original image group unchanged. - 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/web/src/components/canvas/canvas-node.tsx b/web/src/components/canvas/canvas-node.tsx index 9b92e17..9835ca7 100644 --- a/web/src/components/canvas/canvas-node.tsx +++ b/web/src/components/canvas/canvas-node.tsx @@ -1,6 +1,6 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; import type { ReactNode } from "react"; -import { ChevronRight, Group, Image as ImageIcon, Music2, Puzzle, RefreshCw, Star, Trash2, Video } from "lucide-react"; +import { ChevronRight, Copy, Group, Image as ImageIcon, Music2, Puzzle, RefreshCw, Star, Trash2, Video } from "lucide-react"; import { canvasThemes } from "@/lib/canvas-theme"; import { formatBytes } from "@/lib/image-utils"; @@ -47,6 +47,7 @@ type CanvasNodeProps = { onTitleChange: (nodeId: string, title: string) => void; onToggleBatch?: (nodeId: string) => void; onSetBatchPrimary?: (nodeId: string, imageId: string) => void; + onDuplicateBatchImage?: (node: CanvasNodeData, imageId: string) => void; onRetryBatchImage?: (node: CanvasNodeData, imageId: string) => void; onDeleteBatchImage?: (nodeId: string, imageId: string) => void; onRetry?: (node: CanvasNodeData) => void; @@ -72,6 +73,7 @@ type NodeContentRendererProps = { onGenerateImage?: (node: CanvasNodeData) => void; onToggleBatch?: () => void; onSetBatchPrimary?: (imageId: string) => void; + onDuplicateBatchImage?: (imageId: string) => void; onRetryBatchImage?: (imageId: string) => void; onDeleteBatchImage?: (imageId: string) => void; groupChildCount: number; @@ -107,6 +109,7 @@ export const CanvasNode = React.memo(function CanvasNode({ onTitleChange, onToggleBatch, onSetBatchPrimary, + onDuplicateBatchImage, onRetryBatchImage, onDeleteBatchImage, onRetry, @@ -404,6 +407,7 @@ export const CanvasNode = React.memo(function CanvasNode({ onGenerateImage={onGenerateImage} onToggleBatch={() => onToggleBatch?.(data.id)} onSetBatchPrimary={(imageId) => onSetBatchPrimary?.(data.id, imageId)} + onDuplicateBatchImage={(imageId) => onDuplicateBatchImage?.(data, imageId)} onRetryBatchImage={(imageId) => onRetryBatchImage?.(data, imageId)} onDeleteBatchImage={(imageId) => onDeleteBatchImage?.(data.id, imageId)} groupChildCount={groupChildCount} @@ -578,6 +582,7 @@ function ImageNodeContent(props: NodeContentRendererProps) { batchExpanded={props.batchExpanded} onToggleBatch={props.onToggleBatch} onSetBatchPrimary={props.onSetBatchPrimary} + onDuplicateBatchImage={props.onDuplicateBatchImage} onRetryBatchImage={props.onRetryBatchImage} onDeleteBatchImage={props.onDeleteBatchImage} /> @@ -633,6 +638,7 @@ function ImageContent({ batchExpanded, onToggleBatch, onSetBatchPrimary, + onDuplicateBatchImage, onRetryBatchImage, onDeleteBatchImage, }: { @@ -640,6 +646,7 @@ function ImageContent({ batchExpanded: boolean; onToggleBatch?: () => void; onSetBatchPrimary?: (imageId: string) => void; + onDuplicateBatchImage?: (imageId: string) => void; onRetryBatchImage?: (imageId: string) => void; onDeleteBatchImage?: (imageId: string) => void; }) { @@ -657,7 +664,7 @@ function ImageContent({ {batchExpanded ? images .filter((image) => image.id !== primaryImageId) - .map((image, index) => onSetBatchPrimary?.(image.id)} onRetry={() => onRetryBatchImage?.(image.id)} onDelete={() => onDeleteBatchImage?.(image.id)} />) + .map((image, index) => onSetBatchPrimary?.(image.id)} onDuplicate={() => onDuplicateBatchImage?.(image.id)} onRetry={() => onRetryBatchImage?.(image.id)} onDelete={() => onDeleteBatchImage?.(image.id)} />) : null}
{primaryContent ? ( @@ -694,7 +701,7 @@ function ImageContent({ ); } -function ExpandedImageCard({ node, image, index, onSetPrimary, onRetry, onDelete }: { node: CanvasNodeData; image: CanvasNodeImage; index: number; onSetPrimary: () => void; onRetry: () => void; onDelete: () => void }) { +function ExpandedImageCard({ node, image, index, onSetPrimary, onDuplicate, onRetry, onDelete }: { node: CanvasNodeData; image: CanvasNodeImage; index: number; onSetPrimary: () => void; onDuplicate: () => void; onRetry: () => void; onDelete: () => void }) { const theme = canvasThemes[useThemeStore((state) => state.theme)]; const { t } = useTranslation(); const count = node.metadata?.images?.length || 0; @@ -729,18 +736,16 @@ function ExpandedImageCard({ node, image, index, onSetPrimary, onRetry, onDelete > {image.content ? {node.title} : } {image.content ? ( - +
+ + +
) : null} {image.status === "error" ? : null}
diff --git a/web/src/i18n/locales/en-US.ts b/web/src/i18n/locales/en-US.ts index acb863c..a7b903a 100644 --- a/web/src/i18n/locales/en-US.ts +++ b/web/src/i18n/locales/en-US.ts @@ -256,7 +256,7 @@ export default { }, node: { node: "Node", - untitled: "Untitled node", renameHint: "Double-click to rename the node", group: "Group", nodeCount: "{{count}} nodes", generating: "Generating", failed: "Generation failed", retry: "Retry", missingPlugin: "Plugin missing", missingPluginDescription: "The plugin for node type “{{type}}” is not installed or enabled", generateImage: "Generate image from text", generate: "Generate", editText: "Double-click to edit text", emptyImage: "Empty image node", emptyVideo: "Empty video node", emptyAudio: "Empty audio node", audio: "Audio", batchExpanded: "Image group expanded", batchCollapsed: "Image group collapsed", setPrimary: "Set as primary", + untitled: "Untitled node", renameHint: "Double-click to rename the node", group: "Group", nodeCount: "{{count}} nodes", generating: "Generating", failed: "Generation failed", retry: "Retry", missingPlugin: "Plugin missing", missingPluginDescription: "The plugin for node type “{{type}}” is not installed or enabled", generateImage: "Generate image from text", generate: "Generate", editText: "Double-click to edit text", emptyImage: "Empty image node", emptyVideo: "Empty video node", emptyAudio: "Empty audio node", audio: "Audio", batchExpanded: "Image group expanded", batchCollapsed: "Image group collapsed", createCopy: "Create copy", setPrimary: "Set as primary", }, sidePanel: { canvas: "Canvas", assets: "Assets", prompts: "Prompt Library", resize: "Resize left panel", elements: "Canvas elements", select: "Select", searchNodes: "Search nodes", focusNode: "Focus node", preview: "Large preview", noNodes: "No nodes on this canvas", clearAll: "Clear all", selected: "{{count}} selected", exporting: "Exporting selected elements…", exportName: "canvas-elements-{{count}}", exported: "Exported {{count}} elements", exportFailed: "Export failed. Try again.", diff --git a/web/src/i18n/locales/zh-CN.ts b/web/src/i18n/locales/zh-CN.ts index 0281b72..2838459 100644 --- a/web/src/i18n/locales/zh-CN.ts +++ b/web/src/i18n/locales/zh-CN.ts @@ -256,7 +256,7 @@ export default { }, node: { node: "节点", - untitled: "未命名节点", renameHint: "双击修改节点名称", group: "组", nodeCount: "{{count}} 个节点", generating: "生成中", failed: "生成失败", retry: "重试", missingPlugin: "缺少插件", missingPluginDescription: "节点类型“{{type}}”的插件未安装或未启用", generateImage: "用文本生图", generate: "生图", editText: "双击编辑文字", emptyImage: "空图片节点", emptyVideo: "空视频节点", emptyAudio: "空音频节点", audio: "音频", batchExpanded: "图片组已展开", batchCollapsed: "图片组已收起", setPrimary: "设为主图", + untitled: "未命名节点", renameHint: "双击修改节点名称", group: "组", nodeCount: "{{count}} 个节点", generating: "生成中", failed: "生成失败", retry: "重试", missingPlugin: "缺少插件", missingPluginDescription: "节点类型“{{type}}”的插件未安装或未启用", generateImage: "用文本生图", generate: "生图", editText: "双击编辑文字", emptyImage: "空图片节点", emptyVideo: "空视频节点", emptyAudio: "空音频节点", audio: "音频", batchExpanded: "图片组已展开", batchCollapsed: "图片组已收起", createCopy: "创建副本", setPrimary: "设为主图", }, sidePanel: { canvas: "画布", assets: "资产", prompts: "提示词库", resize: "调整左侧面板宽度", elements: "画布元素", select: "选择", searchNodes: "搜索节点", focusNode: "定位到节点", preview: "放大预览", noNodes: "画布暂无节点", clearAll: "取消全选", selected: "已选 {{count}}", exporting: "正在导出选中元素…", exportName: "画布元素-{{count}}个", exported: "已导出 {{count}} 个元素", exportFailed: "导出失败,请重试", diff --git a/web/src/pages/canvas/project.tsx b/web/src/pages/canvas/project.tsx index aea92d9..518e471 100644 --- a/web/src/pages/canvas/project.tsx +++ b/web/src/pages/canvas/project.tsx @@ -1485,6 +1485,41 @@ function InfiniteCanvasPage() { ); }, []); + const duplicateBatchImage = useCallback((node: CanvasNodeData, imageId: string) => { + const image = node.metadata?.images?.find((item) => item.id === imageId); + if (!image?.content) return; + const id = nanoid(); + const edge = Math.max(node.width, node.height); + const size = fitNodeSize(image.naturalWidth, image.naturalHeight, edge, edge); + const copy: CanvasNodeData = { + id, + type: CanvasNodeType.Image, + title: node.title, + position: { x: node.position.x + node.width * 2 + 96, y: node.position.y + node.height / 2 - size.height / 2 }, + ...size, + metadata: { + content: image.content, + storageKey: image.storageKey, + naturalWidth: image.naturalWidth, + naturalHeight: image.naturalHeight, + bytes: image.bytes, + mimeType: image.mimeType, + status: NODE_STATUS_SUCCESS, + prompt: node.metadata?.prompt, + generationType: node.metadata?.generationType, + model: node.metadata?.model, + size: node.metadata?.size, + quality: node.metadata?.quality, + background: node.metadata?.background, + references: node.metadata?.references, + }, + }; + setNodes((prev) => [...prev, copy]); + setSelectedNodeIds(new Set([id])); + setSelectedConnectionId(null); + setDialogNodeId(id); + }, []); + const openTextEditor = useCallback((node: CanvasNodeData) => { if (node.type !== CanvasNodeType.Text) return; setSelectedNodeIds(new Set([node.id])); @@ -2817,6 +2852,7 @@ function InfiniteCanvasPage() { onTitleChange={handleNodeTitleChange} onToggleBatch={toggleBatchExpanded} onSetBatchPrimary={setBatchPrimary} + onDuplicateBatchImage={duplicateBatchImage} onRetryBatchImage={retryBatchImage} onDeleteBatchImage={deleteBatchImage} onRetry={handleNodeRetry}