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
+2
View File
@@ -3,6 +3,8 @@
## Unreleased ## Unreleased
+ [新增] 提示词来源新增 Freestylefly GPT Image 2 内置来源。 + [新增] 提示词来源新增 Freestylefly GPT Image 2 内置来源。
+ [修复] 文本节点和生成配置新增独立的文本生成次数,默认一次且不再误用生图数量。
+ [优化] 图片工具条默认仅显示图标并移除图片、文本编辑入口,单击图片、文本或视频节点直接打开下方提示词面板。
+ [优化] 画布多图展开状态改为由用户主动控制,操作画布其他区域时不再自动收起。 + [优化] 画布多图展开状态改为由用户主动控制,操作画布其他区域时不再自动收起。
+ [调整] 画布默认使用移动工具,可直接拖动元素或空白画布,按住 Control 可临时框选节点。 + [调整] 画布默认使用移动工具,可直接拖动元素或空白画布,按住 Control 可临时框选节点。
+ [修复] 节点缩放期间暂停渲染提示词面板,避免导入图片后立即缩放导致页面崩溃。 + [修复] 节点缩放期间暂停渲染提示词面板,避免导入图片后立即缩放导致页面崩溃。
@@ -11,6 +11,8 @@ 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. - 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. - Global Dropdown, Menu, Select, Cascader, and TreeSelect popup backgrounds, hover states, and selected states in both light and dark themes.
- Canvas node resize stability: importing an image should open its prompt panel, resizing it immediately from any corner should temporarily hide the panel, remain smooth without flashing or triggering a maximum update depth error, and restore the panel after release. Prompt scrolling, generated-prompt restoration, edit-prompt persistence after closing an existing image or text node panel, expanded prompt editing with live synchronization and `@` references, image editing, drag-and-drop references, and generation configuration should continue to work. - Canvas node resize stability: importing an image should open its prompt panel, resizing it immediately from any corner should temporarily hide the panel, remain smooth without flashing or triggering a maximum update depth error, and restore the panel after release. Prompt scrolling, generated-prompt restoration, edit-prompt persistence after closing an existing image or text node panel, expanded prompt editing with live synchronization and `@` references, image editing, drag-and-drop references, and generation configuration should continue to work.
- Text output count: both a text node's lower prompt panel and a generation configuration node in Text mode should expose an independent generation count from 1 to 15, default to one, and create that many text nodes. Changing the text count must not alter the image count, and switching a generation configuration node back to Image should continue to use the configured image count.
- Node editing entry points: the image toolbar should default to icons without labels and no longer include Edit; the text toolbar should no longer include Edit or Edit text. Clicking an image, text, or video node without dragging should open its lower prompt panel directly, while image toolbar labels can still be enabled explicitly from the toolbar settings.
- Canvas side panel: clicking an element row should smoothly center and select its node without zooming above 100%; image elements with content should still open the large preview without triggering canvas focus. - Canvas side panel: clicking an element row should smoothly center and select its node without zooming above 100%; image elements with content should still open the large preview without triggering canvas focus.
- Multi-image canvas generation: starting N images should immediately create one image node with N expandable slots and one incoming connection; each empty slot should independently show generating or failure while requests are running, every slot with image content should always display that image, and the first successful image should appear as the primary image immediately. The collapsed state should use a compact stack with at most three closely spaced backplates and a clearly legible count control; only clicking the count control should expand or collapse the slots upward and to the right, while double-clicking the image should open its large preview. Every completed secondary image should keep a clearly legible primary-image action visible in both themes. Expanded groups should remain expanded when clicking elsewhere, dragging, resizing, or previewing, and multiple groups may remain expanded together. - Multi-image canvas generation: starting N images should immediately create one image node with N expandable slots and one incoming connection; each empty slot should independently show generating or failure while requests are running, every slot with image content should always display that image, and the first successful image should appear as the primary image immediately. The collapsed state should use a compact stack with at most three closely spaced backplates and a clearly legible count control; only clicking the count control should expand or collapse the slots upward and to the right, while double-clicking the image should open its large preview. Every completed secondary image should keep a clearly legible primary-image action visible in both themes. Expanded groups should remain expanded when clicking elsewhere, dragging, resizing, or previewing, and multiple groups may remain expanded together.
- 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. - 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.
@@ -107,7 +107,7 @@ export function CanvasConfigNodePanel({ node, isRunning, inputSummary, onConfigC
) : mode === "audio" ? ( ) : 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))} /> <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> </div>
@@ -5,7 +5,7 @@ import type { CanvasNodeData } from "@/types/canvas";
import i18n from "@/i18n"; import i18n from "@/i18n";
export type ImageNodeActionToolId = "copyPrompt" | "reversePrompt" | "replace" | "resize" | "maskEdit" | "crop" | "split" | "upscale" | "superResolve" | "angle" | "view"; 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 = { export type ImageToolHandlers = {
onUpload: (node: CanvasNodeData) => void; onUpload: (node: CanvasNodeData) => void;
@@ -36,9 +36,9 @@ export type ImageQuickToolsConfig = {
showLabels: boolean; 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[] = [ export const imageToolDefinitions: ImageToolDefinition[] = [
{ {
@@ -152,12 +152,12 @@ export function normalizeImageQuickToolIds(value: unknown[]) {
} }
export function readImageQuickToolsConfig(value: unknown): ImageQuickToolsConfig { export function readImageQuickToolsConfig(value: unknown): ImageQuickToolsConfig {
if (Array.isArray(value)) return { ids: normalizeImageQuickToolIds(value), showLabels: true }; if (Array.isArray(value)) return { ids: normalizeImageQuickToolIds(value), showLabels: false };
if (!value || typeof value !== "object") return { ids: defaultImageQuickToolIds, showLabels: true }; if (!value || typeof value !== "object") return { ids: defaultImageQuickToolIds, showLabels: false };
const data = value as Partial<ImageQuickToolsConfig>; const data = value as Partial<ImageQuickToolsConfig>;
return { return {
ids: Array.isArray(data.ids) ? normalizeImageQuickToolIds(data.ids) : defaultImageQuickToolIds, 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 { useEffect, useMemo, useState, type ReactNode } from "react";
import { App, Modal, Segmented, Tooltip } from "antd"; 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 { useTranslation } from "react-i18next";
import { canvasThemes } from "@/lib/canvas-theme"; import { canvasThemes } from "@/lib/canvas-theme";
@@ -19,7 +19,6 @@ type CanvasNodeHoverToolbarProps = {
onKeep: (nodeId: string) => void; onKeep: (nodeId: string) => void;
onLeave: () => void; onLeave: () => void;
onInfo: (node: CanvasNodeData) => void; onInfo: (node: CanvasNodeData) => void;
onEditText: (node: CanvasNodeData) => void;
onDecreaseFont: (node: CanvasNodeData) => void; onDecreaseFont: (node: CanvasNodeData) => void;
onIncreaseFont: (node: CanvasNodeData) => void; onIncreaseFont: (node: CanvasNodeData) => void;
onToggleDialog: (node: CanvasNodeData) => void; onToggleDialog: (node: CanvasNodeData) => void;
@@ -57,7 +56,6 @@ export function CanvasNodeHoverToolbar({
onKeep, onKeep,
onLeave, onLeave,
onInfo, onInfo,
onEditText,
onDecreaseFont, onDecreaseFont,
onIncreaseFont, onIncreaseFont,
onToggleDialog, onToggleDialog,
@@ -79,9 +77,9 @@ export function CanvasNodeHoverToolbar({
extraTools = [], extraTools = [],
}: CanvasNodeHoverToolbarProps) { }: CanvasNodeHoverToolbarProps) {
const [quickImageToolIds, setQuickImageToolIds] = useState<ImageQuickToolId[]>(defaultImageQuickToolIds); const [quickImageToolIds, setQuickImageToolIds] = useState<ImageQuickToolId[]>(defaultImageQuickToolIds);
const [showImageToolLabels, setShowImageToolLabels] = useState(true); const [showImageToolLabels, setShowImageToolLabels] = useState(false);
const [draftImageToolIds, setDraftImageToolIds] = useState<ImageQuickToolId[]>(defaultImageQuickToolIds); const [draftImageToolIds, setDraftImageToolIds] = useState<ImageQuickToolId[]>(defaultImageQuickToolIds);
const [draftShowImageToolLabels, setDraftShowImageToolLabels] = useState(true); const [draftShowImageToolLabels, setDraftShowImageToolLabels] = useState(false);
const [imageToolSettingsOpen, setImageToolSettingsOpen] = useState(false); const [imageToolSettingsOpen, setImageToolSettingsOpen] = useState(false);
const { message } = App.useApp(); const { message } = App.useApp();
const { t } = useTranslation(); const { t } = useTranslation();
@@ -117,7 +115,6 @@ export function CanvasNodeHoverToolbar({
const hasAudio = isAudio && Boolean(node.metadata?.content); const hasAudio = isAudio && Boolean(node.metadata?.content);
const isText = node.type === CanvasNodeType.Text; const isText = node.type === CanvasNodeType.Text;
const isConfig = node.type === CanvasNodeType.Config; const isConfig = node.type === CanvasNodeType.Config;
const canOpenDialog = isText || hasImage || isVideo;
const canRetry = node.metadata?.status === "error"; const canRetry = node.metadata?.status === "error";
const quickImageToolIdSet = new Set(quickImageToolIds); const quickImageToolIdSet = new Set(quickImageToolIds);
const copyImagePrompt = (target: CanvasNodeData) => { 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) }] : []), ...(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 || 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) }] : []), ...(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) }] : []), ...(isVideo ? [{ 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) }] : []),
...(isText ? [{ id: "generateImage", title: t("canvas.node.generateImage"), label: t("canvas.node.generate"), icon: <ImageIcon className="size-4" />, onClick: () => onGenerateImage(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) }] : []), ...(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) }] : []), ...(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()} onPointerDown={(event) => event.stopPropagation()}
> >
{toolbarTools.map((tool) => ( {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} {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> </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]" /> <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> </div>
@@ -24,7 +24,6 @@ type CanvasNodeProps = {
isFocusRelated: boolean; isFocusRelated: boolean;
isConnectionTarget: boolean; isConnectionTarget: boolean;
isConnecting: boolean; isConnecting: boolean;
editRequestNonce?: number;
showPanel: boolean; showPanel: boolean;
showImageInfo: boolean; showImageInfo: boolean;
mentionReferences?: CanvasResourceReference[]; mentionReferences?: CanvasResourceReference[];
@@ -90,7 +89,6 @@ export const CanvasNode = React.memo(function CanvasNode({
isFocusRelated, isFocusRelated,
isConnectionTarget, isConnectionTarget,
isConnecting, isConnecting,
editRequestNonce = 0,
showPanel, showPanel,
showImageInfo, showImageInfo,
mentionReferences = [], mentionReferences = [],
@@ -203,11 +201,6 @@ export const CanvasNode = React.memo(function CanvasNode({
textarea?.setSelectionRange(textarea.value.length, textarea.value.length); textarea?.setSelectionRange(textarea.value.length, textarea.value.length);
}, [isEditingContent]); }, [isEditingContent]);
useEffect(() => {
if (!editRequestNonce || data.type !== CanvasNodeType.Text) return;
setIsEditingContent(true);
}, [data.type, editRequestNonce]);
useEffect(() => { useEffect(() => {
if (!isEditingContent) return; if (!isEditingContent) return;
@@ -1,7 +1,7 @@
import { useEffect, useRef, useState, type RefObject } from "react"; import { useEffect, useRef, useState, type RefObject } from "react";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import { Settings2 } from "lucide-react"; import { Settings2 } from "lucide-react";
import { Button } from "antd"; import { Button, InputNumber } from "antd";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { reasoningEffortLabel, TextSettingsPanel } from "@/components/text-settings-panel"; import { reasoningEffortLabel, TextSettingsPanel } from "@/components/text-settings-panel";
@@ -12,11 +12,13 @@ import type { AiConfig, ReasoningEffort } from "@/stores/use-config-store";
type CanvasTextSettingsPopoverProps = { type CanvasTextSettingsPopoverProps = {
config: AiConfig; config: AiConfig;
onConfigChange: (key: "reasoningEffort", value: ReasoningEffort) => void; onConfigChange: (key: "reasoningEffort", value: ReasoningEffort) => void;
count?: number;
onCountChange?: (count: number) => void;
buttonClassName?: string; buttonClassName?: string;
placement?: "topLeft" | "top" | "topRight" | "bottomLeft" | "bottom" | "bottomRight"; 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 { t } = useTranslation();
const theme = canvasThemes[useThemeStore((state) => state.theme)]; const theme = canvasThemes[useThemeStore((state) => state.theme)];
const buttonRef = useRef<HTMLSpanElement>(null); const buttonRef = useRef<HTMLSpanElement>(null);
@@ -43,13 +45,13 @@ export function CanvasTextSettingsPopover({ config, onConfigChange, buttonClassN
}; };
}, [open]); }, [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 ( return (
<> <>
<span ref={buttonRef} className="inline-flex min-w-0"> <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)}> <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> </Button>
</span> </span>
{panel} {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; buttonRect: DOMRect;
panelRef: RefObject<HTMLDivElement | null>; panelRef: RefObject<HTMLDivElement | null>;
placement: CanvasTextSettingsPopoverProps["placement"]; placement: CanvasTextSettingsPopoverProps["placement"];
theme: (typeof canvasThemes)[keyof typeof canvasThemes]; theme: (typeof canvasThemes)[keyof typeof canvasThemes];
config: AiConfig; config: AiConfig;
count?: number;
onConfigChange: CanvasTextSettingsPopoverProps["onConfigChange"]; onConfigChange: CanvasTextSettingsPopoverProps["onConfigChange"];
onCountChange?: (count: number) => void;
}) { }) {
const { t } = useTranslation();
const width = 356; const width = 356;
const gap = 8; const gap = 8;
const margin = 12; const margin = 12;
@@ -88,6 +93,12 @@ function TextSettingsPortal({ buttonRect, panelRef, placement, theme, config, on
return createPortal( return createPortal(
<div ref={panelRef} style={style} onPointerDown={(event) => event.stopPropagation()} onMouseDown={(event) => event.stopPropagation()} onClick={(event) => event.stopPropagation()}> <div ref={panelRef} style={style} onPointerDown={(event) => event.stopPropagation()} onMouseDown={(event) => event.stopPropagation()} onClick={(event) => event.stopPropagation()}>
<TextSettingsPanel config={config} onConfigChange={onConfigChange} theme={theme} /> <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>, </div>,
document.body, 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" }, 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" } }, 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." }, 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" } }, 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"] }, 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" }, 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" }, 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}}" } }, 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}}." }, 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" }, 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}})" }, 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}} 张" }, 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: "自适应" } }, 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: "例如:自然、温暖、适合旁白。" }, 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: "音频" } }, model: { select: "选择模型", assign: "请先在渠道里为{{capability}}指定模型", noMatch: "暂无匹配的{{capability}}模型", addFirst: "请先到配置里添加渠道和模型", capabilities: { image: "生图", video: "视频", text: "文本", audio: "音频" } },
}, },
generation: { pending: ["正在创建图片", "马上就好了", "再等等", "正在整理细节"] }, 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: "交互" }, 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: "停止" }, 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}}" } }, 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}}。" }, 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: "触发生成" }, 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}})" }, 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 [toolbarNodeId, setToolbarNodeId] = useState<string | null>(null);
const [nodeImageSettingsOpen, setNodeImageSettingsOpen] = useState(false); const [nodeImageSettingsOpen, setNodeImageSettingsOpen] = useState(false);
const [dialogNodeId, setDialogNodeId] = useState<string | null>(null); 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 [infoNodeId, setInfoNodeId] = useState<string | null>(null);
const [pluginManagerOpen, setPluginManagerOpen] = useState(false); const [pluginManagerOpen, setPluginManagerOpen] = useState(false);
const [cropNodeId, setCropNodeId] = useState<string | null>(null); const [cropNodeId, setCropNodeId] = useState<string | null>(null);
@@ -714,7 +712,6 @@ function InfiniteCanvasPage() {
setHoveredNodeId((current) => (current && allIds.has(current) ? null : current)); setHoveredNodeId((current) => (current && allIds.has(current) ? null : current));
setToolbarNodeId((current) => (current && allIds.has(current) ? null : current)); setToolbarNodeId((current) => (current && allIds.has(current) ? null : current));
setDialogNodeId((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)); setInfoNodeId((current) => (current && allIds.has(current) ? null : current));
setCropNodeId((current) => (current && allIds.has(current) ? null : current)); setCropNodeId((current) => (current && allIds.has(current) ? null : current));
setMaskEditNodeId((current) => (current && allIds.has(current) ? null : current)); setMaskEditNodeId((current) => (current && allIds.has(current) ? null : current));
@@ -743,7 +740,6 @@ function InfiniteCanvasPage() {
setHoveredNodeId(null); setHoveredNodeId(null);
setToolbarNodeId(null); setToolbarNodeId(null);
setDialogNodeId(null); setDialogNodeId(null);
setEditingNodeId(null);
}, [cancelPendingConnectionCreate]); }, [cancelPendingConnectionCreate]);
const clearCanvas = useCallback(() => { const clearCanvas = useCallback(() => {
@@ -979,7 +975,6 @@ function InfiniteCanvasPage() {
setHoveredNodeId(null); setHoveredNodeId(null);
setToolbarNodeId(null); setToolbarNodeId(null);
setDialogNodeId(null); setDialogNodeId(null);
setEditingNodeId(null);
if (pendingConnectionCreateRef.current) cancelPendingConnectionCreate(); if (pendingConnectionCreateRef.current) cancelPendingConnectionCreate();
if (event.button !== 0) return; if (event.button !== 0) return;
@@ -1105,9 +1100,7 @@ function InfiniteCanvasPage() {
if (wasClick && clickedNodeId) { if (wasClick && clickedNodeId) {
const clickedNode = nodesRef.current.find((node) => node.id === clickedNodeId); const clickedNode = nodesRef.current.find((node) => node.id === clickedNodeId);
const clickedDefinition = clickedNode ? getNodeDefinition(clickedNode.type) : undefined; const clickedDefinition = clickedNode ? getNodeDefinition(clickedNode.type) : undefined;
if (clickedNode?.type === CanvasNodeType.Text) { if (clickedDefinition?.hidePanel) {
setDialogNodeId((current) => (current === clickedNodeId ? current : null));
} else if (clickedDefinition?.hidePanel) {
// Clicking a display-only plugin node selects it without opening a lower panel. // Clicking a display-only plugin node selects it without opening a lower panel.
setDialogNodeId((current) => (current === clickedNodeId ? current : null)); setDialogNodeId((current) => (current === clickedNodeId ? current : null));
} else if (clickedNode?.type !== CanvasNodeType.Group) { } else if (clickedNode?.type !== CanvasNodeType.Group) {
@@ -1398,7 +1391,6 @@ function InfiniteCanvasPage() {
setHoveredNodeId(null); setHoveredNodeId(null);
setToolbarNodeId(null); setToolbarNodeId(null);
setDialogNodeId(null); setDialogNodeId(null);
setEditingNodeId(null);
setInfoNodeId(null); setInfoNodeId(null);
setCropNodeId(null); setCropNodeId(null);
setMaskEditNodeId(null); setMaskEditNodeId(null);
@@ -1523,15 +1515,6 @@ function InfiniteCanvasPage() {
setDialogNodeId(id); 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) => { const handleNodePromptChange = useCallback((nodeId: string, prompt: string) => {
setNodes((prev) => prev.map((node) => (node.id === nodeId ? { ...node, metadata: { ...node.metadata, prompt } } : node))); setNodes((prev) => prev.map((node) => (node.id === nodeId ? { ...node, metadata: { ...node.metadata, prompt } } : node)));
}, []); }, []);
@@ -2331,13 +2314,13 @@ function InfiniteCanvasPage() {
let streamed = ""; let streamed = "";
const isConfigNode = sourceNode?.type === CanvasNodeType.Config; 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 parentConfig = NODE_DEFAULT_SIZE[isConfigNode ? CanvasNodeType.Config : CanvasNodeType.Text];
const textConfig = NODE_DEFAULT_SIZE[CanvasNodeType.Text]; const textConfig = NODE_DEFAULT_SIZE[CanvasNodeType.Text];
const parentPosition = sourceNode?.position || { x: 0, y: 0 }; 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; pendingChildIds = childIds;
if (isConfigNode || editingTextNode) { if (childIds.length) {
const childNodes: CanvasNodeData[] = childIds.map((id, index) => ({ const childNodes: CanvasNodeData[] = childIds.map((id, index) => ({
id, id,
type: CanvasNodeType.Text, type: CanvasNodeType.Text,
@@ -2842,7 +2825,6 @@ function InfiniteCanvasPage() {
isFocusRelated={activeNodeId === node.id} isFocusRelated={activeNodeId === node.id}
isConnectionTarget={connectionTargetNodeId === node.id} isConnectionTarget={connectionTargetNodeId === node.id}
isConnecting={Boolean(connectingParams)} isConnecting={Boolean(connectingParams)}
editRequestNonce={editingNodeId === node.id ? editRequestNonce : 0}
showPanel={!isNodeResizing && dialogNodeId === node.id && !selectionBox && !getNodeDefinition(node.type)?.hidePanel} showPanel={!isNodeResizing && dialogNodeId === node.id && !selectionBox && !getNodeDefinition(node.type)?.hidePanel}
groupChildCount={groupChildCountById.get(node.id) || 0} groupChildCount={groupChildCountById.get(node.id) || 0}
isGroupDropTarget={dropTargetGroupId === node.id} isGroupDropTarget={dropTargetGroupId === node.id}
@@ -2909,7 +2891,6 @@ function InfiniteCanvasPage() {
onKeep={keepNodeToolbar} onKeep={keepNodeToolbar}
onLeave={hideNodeToolbar} onLeave={hideNodeToolbar}
onInfo={(node) => setInfoNodeId(node.id)} onInfo={(node) => setInfoNodeId(node.id)}
onEditText={openTextEditor}
onDecreaseFont={(node) => handleFontSizeChange(node.id, Math.max(10, (node.metadata?.fontSize || 14) - 2))} 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))} onIncreaseFont={(node) => handleFontSizeChange(node.id, Math.min(32, (node.metadata?.fontSize || 14) + 2))}
onToggleDialog={(node) => setDialogNodeId((current) => (current === node.id ? null : node.id))} onToggleDialog={(node) => setDialogNodeId((current) => (current === node.id ? null : node.id))}
+1
View File
@@ -52,6 +52,7 @@ export type CanvasNodeMetadata = {
quality?: string; quality?: string;
background?: string; background?: string;
count?: number; count?: number;
textCount?: number;
seconds?: string; seconds?: string;
vquality?: string; vquality?: string;
generateAudio?: string; generateAudio?: string;