diff --git a/CHANGELOG.md b/CHANGELOG.md index f56cb69..aeca33b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,10 @@ ## Unreleased +## v0.2.3 - 2026-06-04 + + [新增] 新增图片蒙版局部修改功能。 ++ [优化] 优化配置节点@图片功能。 ## v0.2.2 - 2026-06-04 diff --git a/docs/content/docs/progress/pending-test.mdx b/docs/content/docs/progress/pending-test.mdx index ce5682f..baabda0 100644 --- a/docs/content/docs/progress/pending-test.mdx +++ b/docs/content/docs/progress/pending-test.mdx @@ -5,9 +5,10 @@ description: 当前版本已实现但仍需人工验证的变更项 # 待测试 +- 画布生成配置节点的“生成配置/组装提示词”改为在节点下方打开独立输入浮层;浮层支持输入 `@` 从已连接图片、文本、视频、音频中选择引用,引用会以图片缩略图或文本标记展示,图片引用可放大预览;生成时再按当前引用解析为实际素材编号,不再提供或读取输入排序。 - 图片节点悬浮工具栏新增“局部编辑”入口,打开后可在图片上用画笔/擦除工具绘制遮罩区域、填写局部修改要求,并通过图片编辑接口携带同尺寸 PNG mask 生成新图片节点;结果节点会放在原图右侧并自动连线,原图保持不变。 - 图片节点悬浮工具栏新增“复制提示词”、“放大”和“超分”入口,并在末尾增加 `...` 更多按钮;点击“复制提示词”会复制生成该图片的提示词,图片没有提示词时会提示暂无可复制内容;点击 `...` 后打开 Ant Design 风格的“自定义工具栏”弹窗,可在图片节点占位上预览悬浮工具栏,信息、删除、存素材、下载、编辑和图片工具都在同一个快捷工具列表中勾选配置,并可切换是否显示按钮文字,保存后写入本地配置;`...` 配置入口固定显示,预览工具栏下方提供常驻横向滚动控制条。“放大”可在弹窗中选择 1K/2K/4K 目标像素和高清插值、双线性、最近邻算法,按原图比例生成新图片节点,已达到的目标像素会禁用并提示无需放大,最高不超过 4K;“超分”当前只打开暂未实现弹窗。 -- Canvas 资源节点会按当前生成上下文显示 `图片1`、`视频1`、`音频1`、`文本1` 角标;文本节点内容框、节点底部 prompt 面板和生成配置预览文本编辑框输入 `@` 时应弹出已连接资源选择器,点击缩略图或文字可插入纯文本编号,并以蓝色 token 视觉高亮。 +- Canvas 资源节点会按当前生成上下文显示 `图片1`、`视频1`、`音频1`、`文本1` 角标;文本节点内容框和节点底部 prompt 面板输入 `@` 时应弹出已连接资源选择器,点击缩略图或文字可插入纯文本编号,并以蓝色 token 视觉高亮。 - 文本节点连接到生成配置节点时,`@` 候选和实际生成输入应读取该生成配置节点的上游参考资源;生成配置输入统计区域应可拖动整个配置节点,预览按钮和设置控件仍保持可点击。 - 配置弹窗改为“配置与用户偏好”并放大为可滚动弹窗;本地直连支持配置生图、视频、文本、音频四类可选模型列表和默认模型,新建画布生图和配置节点会读取“画布默认生图张数”,需要验证远程渠道和本地直连模型列表都能正确显示。 - 画布音频节点底部生成面板改为音频提示词、音频模型下拉和 OpenAI Speech 参数设置,支持 `voice`、`response_format`、`speed`、`instructions` 并通过 `/audio/speech` 生成音频节点;需要验证本地直连和云端渠道的生成、重试、下载和刷新恢复。 diff --git a/web/src/app/(user)/canvas/[id]/canvas-client-page.tsx b/web/src/app/(user)/canvas/[id]/canvas-client-page.tsx index 1b9bf1b..90ab1bf 100644 --- a/web/src/app/(user)/canvas/[id]/canvas-client-page.tsx +++ b/web/src/app/(user)/canvas/[id]/canvas-client-page.tsx @@ -24,6 +24,7 @@ import { fitNodeSize, nodeSizeFromRatio } from "../utils/canvas-node-size"; import { App, Button, Dropdown, Modal } from "antd"; import { NODE_DEFAULT_SIZE, getNodeSpec } from "../constants"; import { ActiveConnectionPath, ConnectionPath } from "../components/canvas-connections"; +import { CanvasConfigComposer } from "../components/canvas-config-composer"; import { CanvasConfigNodePanel } from "../components/canvas-config-node-panel"; import { CanvasAssistantPanel } from "../components/canvas-assistant-panel"; import { CanvasNodeContextMenu } from "../components/canvas-context-menu"; @@ -41,7 +42,7 @@ import { CanvasToolbar } from "../components/canvas-toolbar"; import { AssetPickerModal, type AssetPickerTab, type InsertAssetPayload } from "../components/asset-picker-modal"; import { CanvasZoomControls } from "../components/canvas-zoom-controls"; import { useCanvasStore } from "../stores/use-canvas-store"; -import { buildCanvasResourceReferences, buildInputMentionReferences, buildNodeMentionReferences } from "../utils/canvas-resource-references"; +import { buildCanvasResourceReferences, buildNodeMentionReferences } from "../utils/canvas-resource-references"; import { CanvasNodeType, type CanvasAssistantImage, @@ -638,12 +639,6 @@ function InfiniteCanvasPage() { nodes.forEach((node) => map.set(node.id, buildNodeMentionReferences(node, nodes, connections))); return map; }, [connections, nodes]); - const configMentionReferencesById = useMemo(() => { - const map = new Map>(); - configInputsById.forEach((inputs, nodeId) => map.set(nodeId, buildInputMentionReferences(inputs))); - return map; - }, [configInputsById]); - const createNode = useCallback( (type: CanvasNodeType, position?: Position) => { const targetPosition = position || getCanvasCenter(); @@ -1231,7 +1226,8 @@ function InfiniteCanvasPage() { useEffect(() => { const handleKeyDown = (event: KeyboardEvent) => { - if (event.target instanceof HTMLInputElement || event.target instanceof HTMLTextAreaElement || event.target instanceof HTMLSelectElement) return; + 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; const key = event.key.toLowerCase(); const isModifierShortcut = event.metaKey || event.ctrlKey; @@ -1735,12 +1731,13 @@ function InfiniteCanvasPage() { ); const effectivePrompt = generationContext.prompt.trim(); const markSourceStatus = sourceNode?.type !== CanvasNodeType.Image && !editingTextNode; + const statusPrompt = sourceNode?.type === CanvasNodeType.Config ? effectivePrompt : prompt; if (!effectivePrompt && (mode === "text" || mode === "audio")) { setRunningNodeId(null); return; } let pendingChildIds: string[] = []; - if (markSourceStatus) setNodes((prev) => prev.map((node) => (node.id === nodeId ? { ...node, metadata: { ...node.metadata, prompt, status: NODE_STATUS_LOADING, errorDetails: undefined } } : node))); + if (markSourceStatus) setNodes((prev) => prev.map((node) => (node.id === nodeId ? { ...node, metadata: { ...node.metadata, prompt: statusPrompt, status: NODE_STATUS_LOADING, errorDetails: undefined } } : node))); try { if (mode === "image") { @@ -1804,7 +1801,7 @@ function InfiniteCanvasPage() { ? isConfigNode ? { ...node, - metadata: { ...node.metadata, prompt, status: NODE_STATUS_LOADING, errorDetails: undefined }, + metadata: { ...node.metadata, prompt: effectivePrompt, status: NODE_STATUS_LOADING, errorDetails: undefined }, } : isEmptyImageNode ? { @@ -1955,16 +1952,16 @@ function InfiniteCanvasPage() { const childNodes: CanvasNodeData[] = childIds.map((id, index) => ({ id, type: CanvasNodeType.Text, - title: prompt.slice(0, 32) || "Generated Text", + title: effectivePrompt.slice(0, 32) || "Generated Text", position: { x: parentPosition.x + parentConfig.width + 96, y: parentPosition.y + parentConfig.height / 2 - textConfig.height / 2 + (index - (textCount - 1) / 2) * (textConfig.height + 36), }, width: textConfig.width, height: textConfig.height, - metadata: { prompt, status: NODE_STATUS_LOADING, fontSize: 14 }, + metadata: { prompt: effectivePrompt, status: NODE_STATUS_LOADING, fontSize: 14 }, })); - setNodes((prev) => [...prev.map((node) => (node.id === nodeId && isConfigNode ? { ...node, metadata: { ...node.metadata, prompt, status: NODE_STATUS_LOADING, errorDetails: undefined } } : node)), ...childNodes]); + setNodes((prev) => [...prev.map((node) => (node.id === nodeId && isConfigNode ? { ...node, metadata: { ...node.metadata, prompt: effectivePrompt, status: NODE_STATUS_LOADING, errorDetails: undefined } } : node)), ...childNodes]); setConnections((prev) => [...prev, ...childIds.map((childId) => ({ id: nanoid(), fromNodeId: nodeId, toNodeId: childId }))]); } @@ -2293,32 +2290,39 @@ function InfiniteCanvasPage() { showImageInfo={showImageInfo} resourceLabel={resourceReferenceByNodeId.get(node.id)} mentionReferences={mentionReferencesByNodeId.get(node.id) || []} - renderPanel={(panelNode) => ( - { - setNodeImageSettingsOpen(open); - if (open) setToolbarNodeId(null); - }} - /> - )} + renderPanel={(panelNode) => + panelNode.type === CanvasNodeType.Config ? ( + handleConfigNodeChange(panelNode.id, { composerContent })} + onClose={() => setDialogNodeId(null)} + /> + ) : ( + { + setNodeImageSettingsOpen(open); + if (open) setToolbarNodeId(null); + }} + /> + ) + } renderNodeContent={(contentNode) => ( setDialogNodeId((current) => (current === contentNode.id ? null : contentNode.id))} onGenerate={(nodeId) => { const target = nodesRef.current.find((item) => item.id === nodeId); - void handleGenerateNode(nodeId, target?.metadata?.generationMode || "image", target?.metadata?.prompt || ""); + void handleGenerateNode(nodeId, target?.metadata?.generationMode || "image", target?.metadata?.composerContent ?? target?.metadata?.prompt ?? ""); }} /> )} diff --git a/web/src/app/(user)/canvas/components/canvas-config-composer.tsx b/web/src/app/(user)/canvas/components/canvas-config-composer.tsx new file mode 100644 index 0000000..eb7235f --- /dev/null +++ b/web/src/app/(user)/canvas/components/canvas-config-composer.tsx @@ -0,0 +1,366 @@ +"use client"; + +import { useEffect, useMemo, useRef, useState } from "react"; +import type { CSSProperties, KeyboardEvent, MouseEvent, PointerEvent } from "react"; +import { Button, Image } from "antd"; +import { FileText, Image as ImageIcon, Music2, Video, X } from "lucide-react"; + +import { canvasThemes } from "@/lib/canvas-theme"; +import { useThemeStore } from "@/stores/use-theme-store"; +import type { NodeGenerationInput } from "./canvas-node-generation"; + +type CanvasConfigComposerProps = { + value: string; + inputs: NodeGenerationInput[]; + onChange: (value: string) => void; + onClose: () => void; +}; + +type Token = + | { type: "text"; value: string } + | { type: "reference"; nodeId: string }; + +type MentionState = { + query: string; +}; + +export const CONFIG_REFERENCE_PATTERN = /@\[node:([^\]]+)\]/g; + +export function CanvasConfigComposer({ value, inputs, onChange, onClose }: CanvasConfigComposerProps) { + const theme = canvasThemes[useThemeStore((state) => state.theme)]; + const editorRef = useRef(null); + const composingRef = useRef(false); + const [mention, setMention] = useState(null); + const [activeIndex, setActiveIndex] = useState(0); + const [imagePreview, setImagePreview] = useState(null); + const tokens = useMemo(() => parseComposerTokens(value), [value]); + const referenceById = useMemo(() => new Map(inputs.map((input) => [input.nodeId, input])), [inputs]); + const candidates = useMemo(() => { + if (!mention) return []; + const query = (mention.query || "").trim().toLowerCase(); + if (!query) return inputs; + return inputs.filter((input) => `${resourceLabel(input, inputs)} ${input.title} ${input.text || ""}`.toLowerCase().includes(query)); + }, [inputs, mention]); + + useEffect(() => { + if (document.activeElement === editorRef.current) return; + const editor = editorRef.current; + if (!editor) return; + editor.textContent = ""; + tokens.forEach((token) => { + if (token.type === "text") { + editor.append(document.createTextNode(token.value)); + return; + } + const input = referenceById.get(token.nodeId); + if (input) editor.append(createReferenceChip(input, inputs, theme, setImagePreview)); + }); + }, [inputs, referenceById, theme, tokens]); + + const syncFromEditor = () => { + const editor = editorRef.current; + if (!editor) return; + const next = serializeEditor(editor); + onChange(next); + syncMention(); + }; + + const syncMention = () => { + const text = textBeforeCaret(); + const match = /@([^\s@]*)$/.exec(text); + if (!match || !inputs.length) { + closeMention(); + return; + } + setMention({ query: match[1] || "" }); + setActiveIndex(0); + }; + + const closeMention = () => { + setMention(null); + setActiveIndex(0); + }; + + const insertReference = (input: NodeGenerationInput) => { + const editor = editorRef.current; + if (!editor) return; + removeActiveMention(); + const chip = createReferenceChip(input, inputs, theme, setImagePreview); + const space = document.createTextNode(" "); + const selection = window.getSelection(); + const range = selection?.rangeCount ? selection.getRangeAt(0) : null; + if (range) { + range.insertNode(space); + range.insertNode(chip); + range.setStartAfter(space); + range.collapse(true); + selection?.removeAllRanges(); + selection?.addRange(range); + } else { + editor.append(chip, space); + placeCaretAtEnd(editor); + } + closeMention(); + onChange(serializeEditor(editor)); + }; + + const stopCanvasInteraction = (event: PointerEvent | MouseEvent) => event.stopPropagation(); + + return ( +
event.stopPropagation()} + > +
+
+
组装提示词
+
@ 引用已连接素材,发送前按当前连接重新编号
+
+
+
+ {!value.trim() ?
输入提示词,按 @ 引用连接的图片或文本
: null} +
{ + if (!composingRef.current) syncFromEditor(); + }} + onCompositionStart={() => { + composingRef.current = true; + }} + onCompositionEnd={() => { + composingRef.current = false; + syncFromEditor(); + }} + onKeyDown={(event: KeyboardEvent) => { + event.stopPropagation(); + if (mention && candidates.length) { + if (event.key === "ArrowDown") { + event.preventDefault(); + setActiveIndex((index) => (index + 1) % candidates.length); + return; + } + if (event.key === "ArrowUp") { + event.preventDefault(); + setActiveIndex((index) => (index - 1 + candidates.length) % candidates.length); + return; + } + if (event.key === "Enter") { + event.preventDefault(); + insertReference(candidates[Math.min(activeIndex, candidates.length - 1)]); + return; + } + if (event.key === "Escape") { + event.preventDefault(); + closeMention(); + return; + } + } + if ((event.key === "Backspace" || event.key === "Delete") && deleteAdjacentReference(event.key)) { + event.preventDefault(); + requestAnimationFrame(syncFromEditor); + return; + } + requestAnimationFrame(syncMention); + }} + onBlur={() => window.setTimeout(closeMention, 120)} + /> + {mention && candidates.length ? : null} +
+ {imagePreview ? 引用图片预览 !visible && setImagePreview(null) }} /> : null} +
+ ); + +} + +function MentionMenu({ inputs, allInputs, activeIndex, theme, onSelect }: { inputs: NodeGenerationInput[]; allInputs: NodeGenerationInput[]; activeIndex: number; theme: (typeof canvasThemes)[keyof typeof canvasThemes]; onSelect: (input: NodeGenerationInput) => void }) { + const selectedRef = useRef(false); + const selectInput = (input: NodeGenerationInput) => { + if (selectedRef.current) return; + selectedRef.current = true; + onSelect(input); + }; + + return ( +
+ {inputs.map((input, index) => ( + + ))} +
+ ); +} + +function ResourcePreview({ input }: { input: NodeGenerationInput }) { + if (input.type === "image" && input.image) return ; + if (input.type === "video" && input.video) return
- ); -} - -function PreviewSection({ title, count, empty, children }: { title: string; count: number; empty: string; children: React.ReactNode }) { - return ( -
-
-
{title}
-
{count} 个
-
- {count ? children :
{empty}
} -
- ); -} - -function TextSortCard({ - input, - textIndex, - textTotal, - inputs, - theme, - onMove, - onEdit, -}: { - input: NodeGenerationInput; - textIndex: number; - textTotal: number; - inputs: NodeGenerationInput[]; - theme: (typeof canvasThemes)[keyof typeof canvasThemes]; - onMove: (input: NodeGenerationInput, offset: number) => void; - onEdit: (input: NodeGenerationInput) => void; -}) { - return ( -
-
-
文本 {textIndex + 1}
-
{input.text}
-
-
-
-
- ); -} - -function ImageSortCard({ - input, - imageIndex, - imageTotal, - inputs, - theme, - onMove, -}: { - input: NodeGenerationInput; - imageIndex: number; - imageTotal: number; - inputs: NodeGenerationInput[]; - theme: (typeof canvasThemes)[keyof typeof canvasThemes]; - onMove: (input: NodeGenerationInput, offset: number) => void; -}) { - if (!input.image) return null; - return ( -
-
- {input.title} - {imageReferenceLabel(imageIndex)} - onMove(input, offset)} /> -
-
- ); -} - -function VideoSortCard({ - input, - videoIndex, - videoTotal, - theme, - onMove, -}: { - input: NodeGenerationInput; - videoIndex: number; - videoTotal: number; - theme: (typeof canvasThemes)[keyof typeof canvasThemes]; - onMove: (input: NodeGenerationInput, offset: number) => void; -}) { - if (!input.video) return null; - return ( -
-
-
-
- ); -} - -function AudioSortCard({ - input, - audioIndex, - audioTotal, - theme, - onMove, -}: { - input: NodeGenerationInput; - audioIndex: number; - audioTotal: number; - theme: (typeof canvasThemes)[keyof typeof canvasThemes]; - onMove: (input: NodeGenerationInput, offset: number) => void; -}) { - if (!input.audio) return null; - return ( -
-
- - {input.title} -
-
- ); -} - -function VerticalOrderButtons({ index, total, onMove }: { index: number; total: number; onMove: (offset: number) => void }) { - return ( - <> -