feat(canvas): add independent text generation count and streamline toolbar interactions

This commit is contained in:
HouYunFei
2026-08-18 15:06:38 +08:00
parent fadba9f7a5
commit b8053ee9f8
12 changed files with 42 additions and 56 deletions
@@ -107,7 +107,7 @@ export function CanvasConfigNodePanel({ node, isRunning, inputSummary, onConfigC
) : mode === "audio" ? (
<CanvasAudioSettingsPopover config={config} placement="topRight" buttonClassName="canvas-compact-control !h-10 !w-full !justify-start !rounded-lg !px-2" onConfigChange={(key, value) => onConfigChange(node.id, audioConfigPatch(key, value))} />
) : (
<CanvasTextSettingsPopover config={config} placement="topRight" buttonClassName="canvas-compact-control !h-10 !w-full !justify-start !rounded-lg !px-2" onConfigChange={(_, value) => onConfigChange(node.id, { reasoningEffort: value })} />
<CanvasTextSettingsPopover config={config} count={node.metadata?.textCount || 1} placement="topRight" buttonClassName="canvas-compact-control !h-10 !w-full !justify-start !rounded-lg !px-2" onConfigChange={(_, value) => onConfigChange(node.id, { reasoningEffort: value })} onCountChange={(textCount) => onConfigChange(node.id, { textCount })} />
)}
</div>
@@ -5,7 +5,7 @@ import type { CanvasNodeData } from "@/types/canvas";
import i18n from "@/i18n";
export type ImageNodeActionToolId = "copyPrompt" | "reversePrompt" | "replace" | "resize" | "maskEdit" | "crop" | "split" | "upscale" | "superResolve" | "angle" | "view";
export type ImageQuickToolId = "info" | "delete" | "saveAsset" | "download" | "edit" | ImageNodeActionToolId;
export type ImageQuickToolId = "info" | "delete" | "saveAsset" | "download" | ImageNodeActionToolId;
export type ImageToolHandlers = {
onUpload: (node: CanvasNodeData) => void;
@@ -36,9 +36,9 @@ export type ImageQuickToolsConfig = {
showLabels: boolean;
};
export const IMAGE_QUICK_TOOLS_STORAGE_KEY = "canvas-image-quick-tools-v6";
export const IMAGE_QUICK_TOOLS_STORAGE_KEY = "canvas-image-quick-tools-v7";
const defaultBaseToolIds: ImageQuickToolId[] = ["info", "delete", "saveAsset", "download", "edit"];
const defaultBaseToolIds: ImageQuickToolId[] = ["info", "delete", "saveAsset", "download"];
export const imageToolDefinitions: ImageToolDefinition[] = [
{
@@ -152,12 +152,12 @@ export function normalizeImageQuickToolIds(value: unknown[]) {
}
export function readImageQuickToolsConfig(value: unknown): ImageQuickToolsConfig {
if (Array.isArray(value)) return { ids: normalizeImageQuickToolIds(value), showLabels: true };
if (!value || typeof value !== "object") return { ids: defaultImageQuickToolIds, showLabels: true };
if (Array.isArray(value)) return { ids: normalizeImageQuickToolIds(value), showLabels: false };
if (!value || typeof value !== "object") return { ids: defaultImageQuickToolIds, showLabels: false };
const data = value as Partial<ImageQuickToolsConfig>;
return {
ids: Array.isArray(data.ids) ? normalizeImageQuickToolIds(data.ids) : defaultImageQuickToolIds,
showLabels: data.showLabels !== false,
showLabels: data.showLabels === true,
};
}
@@ -1,6 +1,6 @@
import { useEffect, useMemo, useState, type ReactNode } from "react";
import { App, Modal, Segmented, Tooltip } from "antd";
import { Download, Ellipsis, FolderPlus, Image as ImageIcon, Info, MessageSquare, Minus, Music2, Pencil, Plus, RefreshCw, Settings2, Trash2, Upload, Video } from "lucide-react";
import { Download, Ellipsis, FolderPlus, Image as ImageIcon, Info, MessageSquare, Minus, Music2, Plus, RefreshCw, Settings2, Trash2, Upload, Video } from "lucide-react";
import { useTranslation } from "react-i18next";
import { canvasThemes } from "@/lib/canvas-theme";
@@ -19,7 +19,6 @@ type CanvasNodeHoverToolbarProps = {
onKeep: (nodeId: string) => void;
onLeave: () => void;
onInfo: (node: CanvasNodeData) => void;
onEditText: (node: CanvasNodeData) => void;
onDecreaseFont: (node: CanvasNodeData) => void;
onIncreaseFont: (node: CanvasNodeData) => void;
onToggleDialog: (node: CanvasNodeData) => void;
@@ -57,7 +56,6 @@ export function CanvasNodeHoverToolbar({
onKeep,
onLeave,
onInfo,
onEditText,
onDecreaseFont,
onIncreaseFont,
onToggleDialog,
@@ -79,9 +77,9 @@ export function CanvasNodeHoverToolbar({
extraTools = [],
}: CanvasNodeHoverToolbarProps) {
const [quickImageToolIds, setQuickImageToolIds] = useState<ImageQuickToolId[]>(defaultImageQuickToolIds);
const [showImageToolLabels, setShowImageToolLabels] = useState(true);
const [showImageToolLabels, setShowImageToolLabels] = useState(false);
const [draftImageToolIds, setDraftImageToolIds] = useState<ImageQuickToolId[]>(defaultImageQuickToolIds);
const [draftShowImageToolLabels, setDraftShowImageToolLabels] = useState(true);
const [draftShowImageToolLabels, setDraftShowImageToolLabels] = useState(false);
const [imageToolSettingsOpen, setImageToolSettingsOpen] = useState(false);
const { message } = App.useApp();
const { t } = useTranslation();
@@ -117,7 +115,6 @@ export function CanvasNodeHoverToolbar({
const hasAudio = isAudio && Boolean(node.metadata?.content);
const isText = node.type === CanvasNodeType.Text;
const isConfig = node.type === CanvasNodeType.Config;
const canOpenDialog = isText || hasImage || isVideo;
const canRetry = node.metadata?.status === "error";
const quickImageToolIdSet = new Set(quickImageToolIds);
const copyImagePrompt = (target: CanvasNodeData) => {
@@ -145,8 +142,7 @@ export function CanvasNodeHoverToolbar({
...(canRetry ? [{ id: "retry", title: t("canvas.nodeToolbar.retryTitle"), label: t("canvas.node.retry"), icon: <RefreshCw className="size-4" />, onClick: () => onRetry(node) }] : []),
...(hasImage || hasVideo || isText ? [{ id: "saveAsset", title: t("common.addToAssets"), label: t("canvas.nodeToolbar.saveAsset"), icon: <FolderPlus className="size-4" />, onClick: () => onSaveAsset(node) }] : []),
...(hasImage || hasVideo || hasAudio ? [{ id: "download", title: t(hasAudio ? "canvas.nodeToolbar.downloadAudio" : hasVideo ? "canvas.nodeToolbar.downloadVideo" : "canvas.nodeToolbar.downloadImage"), label: t("common.download"), icon: <Download className="size-4" />, onClick: () => onDownload(node) }] : []),
...(canOpenDialog ? [{ id: "edit", title: t("common.edit"), label: t("common.edit"), icon: <MessageSquare className="size-4" />, onClick: () => onToggleDialog(node) }] : []),
...(isText ? [{ id: "editText", title: t("canvas.nodeToolbar.editTextTitle"), label: t("canvas.nodeToolbar.editText"), icon: <Pencil className="size-4" />, onClick: () => onEditText(node) }] : []),
...(isVideo ? [{ id: "edit", title: t("common.edit"), label: t("common.edit"), icon: <MessageSquare className="size-4" />, onClick: () => onToggleDialog(node) }] : []),
...(isText ? [{ id: "generateImage", title: t("canvas.node.generateImage"), label: t("canvas.node.generate"), icon: <ImageIcon className="size-4" />, onClick: () => onGenerateImage(node) }] : []),
...(isConfig ? [{ id: "config", title: t("canvas.configNode.title"), label: t("canvas.configNode.title"), icon: <Settings2 className="size-4" />, onClick: () => onToggleDialog(node) }] : []),
...(isText ? [{ id: "decreaseFont", title: t("canvas.nodeToolbar.decreaseFont"), label: t("canvas.nodeToolbar.zoomOut"), icon: <Minus className="size-4" />, onClick: () => onDecreaseFont(node) }] : []),
@@ -194,7 +190,7 @@ export function CanvasNodeHoverToolbar({
onPointerDown={(event) => event.stopPropagation()}
>
{toolbarTools.map((tool) => (
<ToolbarAction key={tool.id} {...tool} showLabel={showImageToolLabels} />
<ToolbarAction key={tool.id} {...tool} showLabel={isImage ? showImageToolLabels : true} />
))}
{hasImage ? <ToolbarAction id="more" title={t("canvas.imageTools.configure")} label={t("canvas.imageTools.more")} icon={<Ellipsis className="size-4" />} active={imageToolSettingsOpen} onClick={openImageToolSettings} showLabel={showImageToolLabels} /> : null}
</div>
@@ -115,7 +115,7 @@ export function CanvasNodePromptPanel({ node, isRunning, onPromptChange, onConfi
) : (
<>
<ModelPicker config={config} value={config.model} onChange={(model) => onConfigChange(node.id, { model })} capability="text" onMissingConfig={() => openConfigDialog(true)} className="max-w-[190px]" />
<CanvasTextSettingsPopover config={config} onConfigChange={(_, value) => onConfigChange(node.id, { reasoningEffort: value })} />
<CanvasTextSettingsPopover config={config} count={node.metadata?.textCount || 1} onConfigChange={(_, value) => onConfigChange(node.id, { reasoningEffort: value })} onCountChange={(textCount) => onConfigChange(node.id, { textCount })} />
</>
)}
</div>
@@ -24,7 +24,6 @@ type CanvasNodeProps = {
isFocusRelated: boolean;
isConnectionTarget: boolean;
isConnecting: boolean;
editRequestNonce?: number;
showPanel: boolean;
showImageInfo: boolean;
mentionReferences?: CanvasResourceReference[];
@@ -90,7 +89,6 @@ export const CanvasNode = React.memo(function CanvasNode({
isFocusRelated,
isConnectionTarget,
isConnecting,
editRequestNonce = 0,
showPanel,
showImageInfo,
mentionReferences = [],
@@ -203,11 +201,6 @@ export const CanvasNode = React.memo(function CanvasNode({
textarea?.setSelectionRange(textarea.value.length, textarea.value.length);
}, [isEditingContent]);
useEffect(() => {
if (!editRequestNonce || data.type !== CanvasNodeType.Text) return;
setIsEditingContent(true);
}, [data.type, editRequestNonce]);
useEffect(() => {
if (!isEditingContent) return;
@@ -1,7 +1,7 @@
import { useEffect, useRef, useState, type RefObject } from "react";
import { createPortal } from "react-dom";
import { Settings2 } from "lucide-react";
import { Button } from "antd";
import { Button, InputNumber } from "antd";
import { useTranslation } from "react-i18next";
import { reasoningEffortLabel, TextSettingsPanel } from "@/components/text-settings-panel";
@@ -12,11 +12,13 @@ import type { AiConfig, ReasoningEffort } from "@/stores/use-config-store";
type CanvasTextSettingsPopoverProps = {
config: AiConfig;
onConfigChange: (key: "reasoningEffort", value: ReasoningEffort) => void;
count?: number;
onCountChange?: (count: number) => void;
buttonClassName?: string;
placement?: "topLeft" | "top" | "topRight" | "bottomLeft" | "bottom" | "bottomRight";
};
export function CanvasTextSettingsPopover({ config, onConfigChange, buttonClassName, placement = "topLeft" }: CanvasTextSettingsPopoverProps) {
export function CanvasTextSettingsPopover({ config, onConfigChange, count, onCountChange, buttonClassName, placement = "topLeft" }: CanvasTextSettingsPopoverProps) {
const { t } = useTranslation();
const theme = canvasThemes[useThemeStore((state) => state.theme)];
const buttonRef = useRef<HTMLSpanElement>(null);
@@ -43,13 +45,13 @@ export function CanvasTextSettingsPopover({ config, onConfigChange, buttonClassN
};
}, [open]);
const panel = open && buttonRect ? <TextSettingsPortal buttonRect={buttonRect} panelRef={panelRef} placement={placement} theme={theme} config={config} onConfigChange={onConfigChange} /> : null;
const panel = open && buttonRect ? <TextSettingsPortal buttonRect={buttonRect} panelRef={panelRef} placement={placement} theme={theme} config={config} count={count} onConfigChange={onConfigChange} onCountChange={onCountChange} /> : null;
return (
<>
<span ref={buttonRef} className="inline-flex min-w-0">
<Button size="small" type="text" className={buttonClassName || "!h-8 !max-w-[170px] !justify-start !rounded-full !px-2.5"} style={{ background: theme.node.fill, color: theme.node.text }} icon={<Settings2 className="size-3.5" />} onClick={() => setOpen((current) => !current)}>
<span className="truncate">{t("canvas.controls.reasoning")} · {reasoningEffortLabel(config.reasoningEffort)}</span>
<span className="truncate">{t("canvas.controls.reasoning")} · {reasoningEffortLabel(config.reasoningEffort)}{onCountChange ? ` · ${t("canvas.controls.generations", { count })}` : ""}</span>
</Button>
</span>
{panel}
@@ -57,14 +59,17 @@ export function CanvasTextSettingsPopover({ config, onConfigChange, buttonClassN
);
}
function TextSettingsPortal({ buttonRect, panelRef, placement, theme, config, onConfigChange }: {
function TextSettingsPortal({ buttonRect, panelRef, placement, theme, config, count, onConfigChange, onCountChange }: {
buttonRect: DOMRect;
panelRef: RefObject<HTMLDivElement | null>;
placement: CanvasTextSettingsPopoverProps["placement"];
theme: (typeof canvasThemes)[keyof typeof canvasThemes];
config: AiConfig;
count?: number;
onConfigChange: CanvasTextSettingsPopoverProps["onConfigChange"];
onCountChange?: (count: number) => void;
}) {
const { t } = useTranslation();
const width = 356;
const gap = 8;
const margin = 12;
@@ -88,6 +93,12 @@ function TextSettingsPortal({ buttonRect, panelRef, placement, theme, config, on
return createPortal(
<div ref={panelRef} style={style} onPointerDown={(event) => event.stopPropagation()} onMouseDown={(event) => event.stopPropagation()} onClick={(event) => event.stopPropagation()}>
<TextSettingsPanel config={config} onConfigChange={onConfigChange} theme={theme} />
{onCountChange ? (
<div className="mt-4 space-y-2.5">
<div className="text-sm font-medium" style={{ color: theme.node.muted }}>{t("settingsPanels.text.count")}</div>
<InputNumber className="w-full" min={1} max={15} precision={0} value={count} onChange={(value) => onCountChange(value || 1)} />
</div>
) : null}
</div>,
document.body,
);
+2 -2
View File
@@ -33,7 +33,7 @@ export default {
image: { title: "Image settings", quality: "Quality", size: "Size", align16: "Align to multiples of 16", align16Hint: "Round dimensions up to the next multiple of 16 after input", aspectRatio: "Aspect ratio", transparent: "Transparent background", transparentHint: "Generate an image without a background when supported by the model", count: "Image count", images: "{{count}} images" },
video: { title: "Video settings", quality: "Quality", size: "Size", seconds: "Seconds", resolution: "Resolution", ratio: "Aspect ratio", duration: "Duration", smart: "Smart", output: "Output", generateAudio: "Generate audio", watermark: "Add watermark", adaptive: "Adaptive", sizes: { landscape: "Landscape", portrait: "Portrait", square: "Square", widescreen: "Widescreen", tall: "Tall", auto: "Auto" }, ratios: { landscape: "Landscape", portrait: "Portrait", square: "Square", standardLandscape: "Standard landscape", standardPortrait: "Standard portrait", cinematic: "Cinematic", adaptive: "Adaptive" } },
audio: { title: "Audio settings", voice: "Voice", format: "Format", speed: "Speed", instructions: "Voice instructions", instructionsPlaceholder: "For example: natural, warm, and suitable for narration." },
text: { title: "Text settings", reasoning: "Reasoning effort" },
text: { title: "Text settings", reasoning: "Reasoning effort", count: "Generation count" },
model: { select: "Select model", assign: "Assign a model for {{capability}} in the provider settings", noMatch: "No matching {{capability}} models", addFirst: "Add a provider and models in Settings first", capabilities: { image: "image generation", video: "video", text: "text", audio: "audio" } },
},
generation: { pending: ["Creating image", "Almost there", "Just a little longer", "Refining details"] },
@@ -283,7 +283,7 @@ export default {
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", 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" },
controls: { ratio: "Ratio", duplicate: "Duplicate", delete: "Delete", images: "{{count}} images", generations: "{{count}} runs", 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}}." },
agentOps: { add_node: "Add node", update_node: "Update node", delete_node: "Delete node", delete_connections: "Delete connections", connect_nodes: "Connect", set_viewport: "Adjust view", select_nodes: "Select nodes", run_generation: "Run generation" },
pluginErrors: { invalidExport: "The plugin did not export a valid object", missingFields: "The plugin is missing id or nodes", downloadFailed: "Download failed (HTTP {{status}})", registryFailed: "Failed to load the official plugin registry (HTTP {{status}})" },
+2 -2
View File
@@ -33,7 +33,7 @@ export default {
image: { title: "图像设置", quality: "质量", size: "尺寸", align16: "16 倍数对齐", align16Hint: "输入完成后自动向上补成 16 的倍数", aspectRatio: "宽高比", transparent: "透明背景", transparentHint: "开启后生成无背景的透明图像(仅部分模型可用)", count: "生成张数", images: "{{count}} 张" },
video: { title: "视频设置", quality: "清晰度", size: "尺寸", seconds: "秒数", resolution: "分辨率", ratio: "比例", duration: "时长", smart: "智能", output: "输出", generateAudio: "生成声音", watermark: "添加水印", adaptive: "自适应", sizes: { landscape: "横屏", portrait: "竖屏", square: "方形", widescreen: "宽屏", tall: "长图", auto: "自动" }, ratios: { landscape: "横屏", portrait: "竖屏", square: "方形", standardLandscape: "标准横屏", standardPortrait: "标准竖屏", cinematic: "宽银幕", adaptive: "自适应" } },
audio: { title: "音频设置", voice: "声音", format: "格式", speed: "语速", instructions: "声音指令", instructionsPlaceholder: "例如:自然、温暖、适合旁白。" },
text: { title: "文本设置", reasoning: "推理强度" },
text: { title: "文本设置", reasoning: "推理强度", count: "生成次数" },
model: { select: "选择模型", assign: "请先在渠道里为{{capability}}指定模型", noMatch: "暂无匹配的{{capability}}模型", addFirst: "请先到配置里添加渠道和模型", capabilities: { image: "生图", video: "视频", text: "文本", audio: "音频" } },
},
generation: { pending: ["正在创建图片", "马上就好了", "再等等", "正在整理细节"] },
@@ -283,7 +283,7 @@ export default {
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: "请输入你想要将本段文本修改成什么", 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: "推理" },
controls: { ratio: "比例", duplicate: "复制", delete: "删除", images: "{{count}} 张", generations: "{{count}} 次", reasoning: "推理" },
generation: { interrupted: "页面刷新后生成已中断,请重新生成。", front: "正面视角", rotateRight: "向右旋转 {{angle}} 度", rotateLeft: "向左旋转 {{angle}} 度", level: "水平视角", topDown: "俯视 {{angle}} 度", lowAngle: "仰视 {{angle}} 度", angleLabel: "AI 多角度:{{horizontal}},{{pitch}},镜头距离 {{distance}},{{lens}}镜头", anglePrompt: "基于参考图重新生成同一主体的新视角,保持主体、颜色、材质和画面风格一致,不要只做透视变形。{{angle}}。" },
agentOps: { add_node: "新增节点", update_node: "更新节点", delete_node: "删除节点", delete_connections: "删除连线", connect_nodes: "连接", set_viewport: "调整视图", select_nodes: "选择节点", run_generation: "触发生成" },
pluginErrors: { invalidExport: "插件未导出有效对象", missingFields: "插件缺少 id 或 nodes", downloadFailed: "下载失败 (HTTP {{status}})", registryFailed: "获取官方插件列表失败 (HTTP {{status}})" },
+4 -23
View File
@@ -221,8 +221,6 @@ function InfiniteCanvasPage() {
const [toolbarNodeId, setToolbarNodeId] = useState<string | null>(null);
const [nodeImageSettingsOpen, setNodeImageSettingsOpen] = useState(false);
const [dialogNodeId, setDialogNodeId] = useState<string | null>(null);
const [editingNodeId, setEditingNodeId] = useState<string | null>(null);
const [editRequestNonce, setEditRequestNonce] = useState(0);
const [infoNodeId, setInfoNodeId] = useState<string | null>(null);
const [pluginManagerOpen, setPluginManagerOpen] = useState(false);
const [cropNodeId, setCropNodeId] = useState<string | null>(null);
@@ -714,7 +712,6 @@ function InfiniteCanvasPage() {
setHoveredNodeId((current) => (current && allIds.has(current) ? null : current));
setToolbarNodeId((current) => (current && allIds.has(current) ? null : current));
setDialogNodeId((current) => (current && allIds.has(current) ? null : current));
setEditingNodeId((current) => (current && allIds.has(current) ? null : current));
setInfoNodeId((current) => (current && allIds.has(current) ? null : current));
setCropNodeId((current) => (current && allIds.has(current) ? null : current));
setMaskEditNodeId((current) => (current && allIds.has(current) ? null : current));
@@ -743,7 +740,6 @@ function InfiniteCanvasPage() {
setHoveredNodeId(null);
setToolbarNodeId(null);
setDialogNodeId(null);
setEditingNodeId(null);
}, [cancelPendingConnectionCreate]);
const clearCanvas = useCallback(() => {
@@ -979,7 +975,6 @@ function InfiniteCanvasPage() {
setHoveredNodeId(null);
setToolbarNodeId(null);
setDialogNodeId(null);
setEditingNodeId(null);
if (pendingConnectionCreateRef.current) cancelPendingConnectionCreate();
if (event.button !== 0) return;
@@ -1105,9 +1100,7 @@ function InfiniteCanvasPage() {
if (wasClick && clickedNodeId) {
const clickedNode = nodesRef.current.find((node) => node.id === clickedNodeId);
const clickedDefinition = clickedNode ? getNodeDefinition(clickedNode.type) : undefined;
if (clickedNode?.type === CanvasNodeType.Text) {
setDialogNodeId((current) => (current === clickedNodeId ? current : null));
} else if (clickedDefinition?.hidePanel) {
if (clickedDefinition?.hidePanel) {
// Clicking a display-only plugin node selects it without opening a lower panel.
setDialogNodeId((current) => (current === clickedNodeId ? current : null));
} else if (clickedNode?.type !== CanvasNodeType.Group) {
@@ -1398,7 +1391,6 @@ function InfiniteCanvasPage() {
setHoveredNodeId(null);
setToolbarNodeId(null);
setDialogNodeId(null);
setEditingNodeId(null);
setInfoNodeId(null);
setCropNodeId(null);
setMaskEditNodeId(null);
@@ -1523,15 +1515,6 @@ function InfiniteCanvasPage() {
setDialogNodeId(id);
}, []);
const openTextEditor = useCallback((node: CanvasNodeData) => {
if (node.type !== CanvasNodeType.Text) return;
setSelectedNodeIds(new Set([node.id]));
setSelectedConnectionId(null);
setDialogNodeId(node.id);
setEditingNodeId(node.id);
setEditRequestNonce((value) => value + 1);
}, []);
const handleNodePromptChange = useCallback((nodeId: string, prompt: string) => {
setNodes((prev) => prev.map((node) => (node.id === nodeId ? { ...node, metadata: { ...node.metadata, prompt } } : node)));
}, []);
@@ -2331,13 +2314,13 @@ function InfiniteCanvasPage() {
let streamed = "";
const isConfigNode = sourceNode?.type === CanvasNodeType.Config;
const textCount = isConfigNode ? getGenerationCount(generationConfig.count) : 1;
const textCount = getGenerationCount(String(sourceNode?.metadata?.textCount || 1));
const parentConfig = NODE_DEFAULT_SIZE[isConfigNode ? CanvasNodeType.Config : CanvasNodeType.Text];
const textConfig = NODE_DEFAULT_SIZE[CanvasNodeType.Text];
const parentPosition = sourceNode?.position || { x: 0, y: 0 };
const childIds = isConfigNode || editingTextNode ? Array.from({ length: textCount }, () => nanoid()) : [];
const childIds = isConfigNode || editingTextNode || textCount > 1 ? Array.from({ length: textCount }, () => nanoid()) : [];
pendingChildIds = childIds;
if (isConfigNode || editingTextNode) {
if (childIds.length) {
const childNodes: CanvasNodeData[] = childIds.map((id, index) => ({
id,
type: CanvasNodeType.Text,
@@ -2842,7 +2825,6 @@ function InfiniteCanvasPage() {
isFocusRelated={activeNodeId === node.id}
isConnectionTarget={connectionTargetNodeId === node.id}
isConnecting={Boolean(connectingParams)}
editRequestNonce={editingNodeId === node.id ? editRequestNonce : 0}
showPanel={!isNodeResizing && dialogNodeId === node.id && !selectionBox && !getNodeDefinition(node.type)?.hidePanel}
groupChildCount={groupChildCountById.get(node.id) || 0}
isGroupDropTarget={dropTargetGroupId === node.id}
@@ -2909,7 +2891,6 @@ function InfiniteCanvasPage() {
onKeep={keepNodeToolbar}
onLeave={hideNodeToolbar}
onInfo={(node) => setInfoNodeId(node.id)}
onEditText={openTextEditor}
onDecreaseFont={(node) => handleFontSizeChange(node.id, Math.max(10, (node.metadata?.fontSize || 14) - 2))}
onIncreaseFont={(node) => handleFontSizeChange(node.id, Math.min(32, (node.metadata?.fontSize || 14) + 2))}
onToggleDialog={(node) => setDialogNodeId((current) => (current === node.id ? null : node.id))}
+1
View File
@@ -52,6 +52,7 @@ export type CanvasNodeMetadata = {
quality?: string;
background?: string;
count?: number;
textCount?: number;
seconds?: string;
vquality?: string;
generateAudio?: string;