feat(canvas): add expanded editor for prompt input and retain edits after closing panel

This commit is contained in:
HouYunFei
2026-08-06 15:48:36 +08:00
parent c23942ddba
commit f8f1b160b0
6 changed files with 50 additions and 18 deletions
@@ -1,6 +1,6 @@
import { useEffect, useState } from "react";
import { ArrowUp, LoaderCircle, Square } from "lucide-react";
import { Button } from "antd";
import { ArrowUp, LoaderCircle, Maximize2, Square } from "lucide-react";
import { Button, Modal, Tooltip } from "antd";
import { useTranslation } from "react-i18next";
import { ModelPicker } from "@/components/model-picker";
@@ -40,17 +40,20 @@ export function CanvasNodePromptPanel({ node, isRunning, onPromptChange, onConfi
const hasTextContent = node.type === CanvasNodeType.Text && Boolean(node.metadata?.content?.trim());
const hasImageContent = node.type === CanvasNodeType.Image && Boolean(node.metadata?.content);
const isEditingExistingContent = hasTextContent || hasImageContent;
const [prompt, setPrompt] = useState(node.metadata?.prompt || "");
const [prompt, setPrompt] = useState(node.metadata?.composerContent ?? node.metadata?.prompt ?? "");
const [expandedPrompt, setExpandedPrompt] = useState("");
const [expanded, setExpanded] = useState(false);
// Restore prompts only when switching nodes; preserve the current input after generation on the same node.
useEffect(() => {
setPrompt(node.metadata?.prompt || "");
setPrompt(node.metadata?.composerContent ?? node.metadata?.prompt ?? "");
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [node.id]);
const updatePrompt = (value: string) => {
setPrompt(value);
if (!isEditingExistingContent) onPromptChange(node.id, value);
if (isEditingExistingContent) onConfigChange(node.id, { composerContent: value });
else onPromptChange(node.id, value);
};
const submit = () => {
@@ -59,6 +62,16 @@ export function CanvasNodePromptPanel({ node, isRunning, onPromptChange, onConfi
onGenerate(node.id, mode, text);
};
const openExpandedEditor = () => {
setExpandedPrompt(prompt);
setExpanded(true);
};
const saveExpandedPrompt = () => {
updatePrompt(expandedPrompt);
setExpanded(false);
};
return (
<div
data-canvas-no-zoom
@@ -68,15 +81,20 @@ export function CanvasNodePromptPanel({ node, isRunning, onPromptChange, onConfi
onPointerDown={(event) => event.stopPropagation()}
onWheel={(event) => event.stopPropagation()}
>
<CanvasPromptChipInput
value={prompt}
references={mentionReferences}
onChange={updatePrompt}
onSubmit={submit}
className="thin-scrollbar h-40 w-full cursor-text resize-none rounded-xl px-3 py-2 text-sm leading-5 outline-none"
style={{ background: "transparent", color: theme.node.text }}
placeholder={t(`canvas.promptPanel.${mode === "image" && hasImageContent ? "editImage" : mode === "text" && hasTextContent ? "editText" : mode}`)}
/>
<div className="relative">
<CanvasPromptChipInput
value={prompt}
references={mentionReferences}
onChange={updatePrompt}
onSubmit={submit}
className="thin-scrollbar h-40 w-full cursor-text resize-none rounded-xl px-3 py-2 pr-10 text-sm leading-5 outline-none"
style={{ background: "transparent", color: theme.node.text }}
placeholder={t(`canvas.promptPanel.${mode === "image" && hasImageContent ? "editImage" : mode === "text" && hasTextContent ? "editText" : mode}`)}
/>
<Tooltip title={t("canvas.promptPanel.expandEditor")}>
<Button type="text" className="absolute right-1 top-1 !h-8 !w-8 !min-w-8 !rounded-full !bg-transparent !p-0" style={{ color: theme.node.text }} icon={<Maximize2 className="size-3.5" />} onClick={openExpandedEditor} aria-label={t("canvas.promptPanel.expandEditor")} />
</Tooltip>
</div>
<div className="mt-2 flex min-w-0 items-center justify-between gap-2">
<div className="flex min-w-0 items-center gap-2">
@@ -131,6 +149,18 @@ export function CanvasNodePromptPanel({ node, isRunning, onPromptChange, onConfi
</span>
</Button>
</div>
<Modal title={t("canvas.promptPanel.editorTitle")} open={expanded} centered width={760} okText={t("common.done")} cancelText={t("common.cancel")} onOk={saveExpandedPrompt} onCancel={() => setExpanded(false)} destroyOnHidden>
<div data-canvas-no-zoom className="pt-2" onWheelCapture={(event) => event.stopPropagation()}>
<CanvasPromptChipInput
value={expandedPrompt}
references={mentionReferences}
onChange={setExpandedPrompt}
className="thin-scrollbar h-[52dvh] min-h-80 w-full cursor-text overflow-y-auto rounded-xl border p-4 text-[15px] leading-6 outline-none"
style={{ background: "transparent", borderColor: theme.toolbar.border, color: theme.node.text }}
placeholder={t(`canvas.promptPanel.${mode === "image" && hasImageContent ? "editImage" : mode === "text" && hasTextContent ? "editText" : mode}`)}
/>
</div>
</Modal>
</div>
);
}
@@ -225,7 +225,7 @@ function MentionMenu({ rect, references, activeIndex, theme, onSelect }: { rect:
return createPortal(
<div
data-canvas-resource-mention-menu="true"
className="fixed z-[120] max-h-56 w-64 overflow-y-auto rounded-xl border p-1 shadow-2xl backdrop-blur-md"
className="fixed z-[1100] max-h-56 w-64 overflow-y-auto rounded-xl border p-1 shadow-2xl backdrop-blur-md"
style={{ left, top, background: theme.toolbar.panel, borderColor: theme.toolbar.border, color: theme.node.text }}
onPointerDown={stopCanvasInteraction}
onMouseDown={stopCanvasInteraction}
+1 -1
View File
@@ -281,7 +281,7 @@ export default {
splitTitle: "Split image", splitDescription: "Create {{count}} image nodes and arrange them in the original grid to the right of the canvas", splitHint: "Mouse wheel to zoom · middle button or Space + left drag to pan · Delete removes the selected line · Ctrl/Cmd+Z to undo · Ctrl/Cmd+Shift+Z to redo", undoSplitTitle: "Undo split adjustment (Ctrl/Cmd+Z)", undoSplit: "Undo split adjustment", redoSplitTitle: "Redo split adjustment (Ctrl/Cmd+Shift+Z)", redoSplit: "Redo split adjustment", rows: "Rows", columns: "Columns", horizontalLine: "Horizontal line", verticalLine: "Vertical line", deleteLine: "Delete line", resetLines: "Reset lines", pieceCount: "Pieces", pieces: "{{count}}", averageSize: "Average size", generateChildren: "Create child nodes",
},
plugins: { title: "Node plugins", installedPlugin: "Installed plugin {{name}}", installed: "Installed {{name}}", installFailed: "Installation failed: {{error}}", enabled: "Enabled", disabled: "Disabled", upgradeAvailable: "New version available — click to upgrade", updateFromSource: "Update from source", updated: "Updated", uninstallTitle: "Uninstall this plugin?", uninstall: "Uninstall", newVersion: "A new version is available", officialDescription: "Official plugins from this project's registry", refresh: "Refresh", loadFailed: "Failed to load: {{error}}", loadingOfficial: "Loading official plugins…", noOfficial: "No official plugins", install: "Install", urlPlaceholder: "Enter a plugin JavaScript URL, for example https://.../plugin.js", noThirdParty: "No third-party plugins installed", official: "Official", local: "Local", thirdParty: "Third-party", warning: "Plugin code runs directly on this page and can access local data, including your AI API key. Only install plugins from sources you trust.", aiConfigRequired: "AI configuration is not ready. Configure a model and API key in Settings first.", interactiveTitle: "Interaction mode is active. Click to switch to Move mode and drag the node.", movableTitle: "Move mode is active. Click to switch to Interaction mode and operate the node content.", move: "Move", interact: "Interact" },
promptPanel: { video: "Describe the video you want to generate", audio: "Describe the audio you want to generate", image: "Describe the image you want to generate", text: "Describe the text you want to generate", editImage: "Describe how you want to change this image", editText: "Describe how you want to revise this text", stopGeneration: "Stop generation", generate: "Generate", stop: "Stop" },
promptPanel: { video: "Describe the video you want to generate", audio: "Describe the audio you want to generate", image: "Describe the image you want to generate", text: "Describe the text you want to generate", editImage: "Describe how you want to change this image", editText: "Describe how you want to revise this text", expandEditor: "Expand editor", editorTitle: "Edit prompt", stopGeneration: "Stop generation", generate: "Generate", stop: "Stop" },
composer: { title: "Compose prompt", description: "Use @ to reference connected assets; references are renumbered before sending", placeholder: "Enter a prompt and use @ to reference connected images or text", imagePreview: "Referenced image preview", resources: { image: "Image {{index}}", video: "Video {{index}}", audio: "Audio {{index}}", text: "Text {{index}}" } },
controls: { ratio: "Ratio", duplicate: "Duplicate", delete: "Delete", images: "{{count}} images", reasoning: "Reasoning" },
generation: { interrupted: "Generation was interrupted by a page refresh. Generate again.", front: "front view", rotateRight: "rotated {{angle}} degrees right", rotateLeft: "rotated {{angle}} degrees left", level: "eye-level view", topDown: "{{angle}}-degree top-down view", lowAngle: "{{angle}}-degree low-angle view", angleLabel: "AI multi-angle: {{horizontal}}, {{pitch}}, camera distance {{distance}}, {{lens}} lens", anglePrompt: "Regenerate a new view of the same subject from the reference image. Preserve the subject, colors, materials, and visual style; do not merely apply perspective distortion. {{angle}}." },
+1 -1
View File
@@ -281,7 +281,7 @@ export default {
splitTitle: "切分图片", splitDescription: "生成 {{count}} 个图片子节点,并按原图网格排列到画布右侧", splitHint: "滚轮缩放 · 中键或空格+左键拖动画面 · Delete 删除选中线 · Ctrl/Cmd+Z 撤回 · Ctrl/Cmd+Shift+Z 重做", undoSplitTitle: "撤回切图调整 (Ctrl/Cmd+Z)", undoSplit: "撤回切图调整", redoSplitTitle: "重做切图调整 (Ctrl/Cmd+Shift+Z)", redoSplit: "重做切图调整", rows: "行数", columns: "列数", horizontalLine: "横向线", verticalLine: "纵向线", deleteLine: "删除线", resetLines: "重置线", pieceCount: "切片数量", pieces: "{{count}} 个", averageSize: "平均约", generateChildren: "生成子节点",
},
plugins: { title: "节点插件", installedPlugin: "已安装插件 {{name}}", installed: "已安装 {{name}}", installFailed: "安装失败:{{error}}", enabled: "已启用", disabled: "已禁用", upgradeAvailable: "有新版本,点击升级", updateFromSource: "从来源更新", updated: "已更新", uninstallTitle: "卸载该插件?", uninstall: "卸载", newVersion: "有新版本可升级", officialDescription: "本项目官方插件,来自仓库注册表", refresh: "刷新", loadFailed: "加载失败:{{error}}", loadingOfficial: "正在获取官方插件…", noOfficial: "暂无官方插件", install: "安装", urlPlaceholder: "输入插件 JS 文件 URL,例如 https://.../plugin.js", noThirdParty: "还没有安装第三方插件", official: "官方插件", local: "本地插件", thirdParty: "第三方插件", warning: "插件代码会在当前页面内直接执行,可访问本地数据(包含 AI API Key)。请仅安装你信任来源的插件。", aiConfigRequired: "AI 配置未就绪,请先在设置里配置模型与密钥", interactiveTitle: "当前:交互中。点击切回「移动」——拖动可移动节点", movableTitle: "当前:可移动。点击切到「交互」——可操作节点内容(如转动全景)", move: "移动", interact: "交互" },
promptPanel: { video: "描述要生成的视频内容", audio: "描述要生成的音频内容", image: "描述要生成的图片内容", text: "请输入你想要生成的文本内容", editImage: "请输入你想要把这张图修改成什么", editText: "请输入你想要将本段文本修改成什么", stopGeneration: "停止生成", generate: "生成", stop: "停止" },
promptPanel: { video: "描述要生成的视频内容", audio: "描述要生成的音频内容", image: "描述要生成的图片内容", text: "请输入你想要生成的文本内容", editImage: "请输入你想要把这张图修改成什么", editText: "请输入你想要将本段文本修改成什么", expandEditor: "放大编辑", editorTitle: "编辑提示词", stopGeneration: "停止生成", generate: "生成", stop: "停止" },
composer: { title: "组装提示词", description: "@ 引用已连接资产,发送前按当前连接重新编号", placeholder: "输入提示词,按 @ 引用连接的图片或文本", imagePreview: "引用图片预览", resources: { image: "图片{{index}}", video: "视频{{index}}", audio: "音频{{index}}", text: "文本{{index}}" } },
controls: { ratio: "比例", duplicate: "复制", delete: "删除", images: "{{count}} 张", reasoning: "推理" },
generation: { interrupted: "页面刷新后生成已中断,请重新生成。", front: "正面视角", rotateRight: "向右旋转 {{angle}} 度", rotateLeft: "向左旋转 {{angle}} 度", level: "水平视角", topDown: "俯视 {{angle}} 度", lowAngle: "仰视 {{angle}} 度", angleLabel: "AI 多角度:{{horizontal}}{{pitch}},镜头距离 {{distance}}{{lens}}镜头", anglePrompt: "基于参考图重新生成同一主体的新视角,保持主体、颜色、材质和画面风格一致,不要只做透视变形。{{angle}}。" },