mirror of
https://github.com/basketikun/infinite-canvas.git
synced 2026-07-26 16:54:29 +08:00
Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0c4288b832 | |||
| 093260f7b2 | |||
| 805573b980 | |||
| e6829333c5 | |||
| 2959b5d340 |
@@ -2,6 +2,12 @@
|
||||
|
||||
## Unreleased
|
||||
|
||||
## v0.8.2 - 2026-07-16
|
||||
|
||||
+ [新增] 图像设置新增「透明背景」开关,开启后生成无背景的透明图像。
|
||||
+ [修复] 画布节点提示词输入框补上悬停文本光标。
|
||||
+ [优化] 画布节点输入区域移除灰色底色与边框、美化样式。
|
||||
|
||||
## v0.8.1 - 2026-07-16
|
||||
|
||||
+ [新增] 插件 SDK 扩展:AI 生成能力、面板控制能力。
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
"pages": [
|
||||
"[更新日志](/docs/progress/changelog)",
|
||||
"local-agent-integration-plan",
|
||||
"prompt-chip-input-plan",
|
||||
"pending-test",
|
||||
"todo"
|
||||
]
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
---
|
||||
title: 提示词面板引用显示图片缩略图规划
|
||||
description: 将提示词面板输入框从 textarea 改造为 contentEditable,让 @ 引用直接显示真实图片缩略图
|
||||
---
|
||||
|
||||
# 提示词面板 @ 引用显示真实图片缩略图
|
||||
|
||||
## 背景 / 要解决的问题
|
||||
|
||||
提示词面板(`CanvasNodePromptPanel`)的输入框里,`@` 引用一张图片后,显示的是蓝色文字 chip「图片1」。期望**直接显示这张图片的真实缩略图**,而不是文字编号。
|
||||
|
||||
当前该输入框是原生 `<textarea>`,只能承载纯文本,无法内嵌图片。要显示缩略图,必须改用 `contentEditable`(可编辑 div),把引用做成带 `<img>` 的原子小卡片。项目里「组装提示词」组件(`CanvasConfigComposer`)已经是这套 contentEditable + 图片 chip 的成熟实现,可作蓝本。
|
||||
|
||||
## 关键约束(为什么不能直接改现有组件)
|
||||
|
||||
`CanvasResourceMentionTextarea` 被两处复用:
|
||||
|
||||
1. **提示词面板** `web/src/components/canvas/canvas-node-prompt-panel.tsx` —— 本次要改的对象。`highlightLabels` 默认 true,value 是提示词字符串,引用以 label 文本「图片1」嵌在其中,**直接发给 AI**(配合 `web/src/lib/image-reference-prompt.ts` 的「参考图片编号:图片1」前缀)。
|
||||
2. **节点内文本编辑** `web/src/components/canvas/canvas-node.tsx`(`isEditingContent` 分支)—— `highlightLabels={false}`,通过 `ref` 调用 `textarea.setSelectionRange(...)` 等 **textarea 专有 API**。若把组件改成 contentEditable,这处会直接崩。
|
||||
|
||||
> 结论:**新建一个 contentEditable chip 输入组件,只替换提示词面板那一处**;`CanvasResourceMentionTextarea`(textarea 版)保持不动,继续服务节点内文本编辑。
|
||||
|
||||
## 方案:新增 `CanvasPromptChipInput` 组件
|
||||
|
||||
新文件 `web/src/components/canvas/canvas-prompt-chip-input.tsx`,参照 `canvas-config-composer.tsx` 的 contentEditable 模式,但有一处**核心差异**:
|
||||
|
||||
- 「组装提示词」把引用序列化成 `@[node:UUID]` 标记;
|
||||
- 本组件的 value 必须保持**引用的 label 文本**(如「图片1」),这样发给生成的提示词语义不变。因此 chip ↔ 文本的序列化用 **label 文本**,不是 node 标记。
|
||||
|
||||
### 组件契约(与现用法一致)
|
||||
|
||||
Props:`value: string`、`references: CanvasResourceReference[]`、`onChange(value)`、`onSubmit?()`、`className`、`style`、`placeholder`。图片缩略图取 `reference.previewUrl`(即 `node.metadata.content`,已存在于 `CanvasResourceReference`)。
|
||||
|
||||
### 实现要点(大量复用 composer 的做法)
|
||||
|
||||
1. **DOM ← value(未聚焦时重建)**:仿 `CanvasConfigComposer` 的重建 effect。用 active label 列表(`references.filter(active).map(label)`,按长度降序)把 value 字符串切成「文本片段 + 命中 label」,命中处插入 chip。聚焦时不重建(`document.activeElement === editor` 直接 return),避免与输入 / IME 打架。
|
||||
2. **chip 渲染**:仿 `createReferenceChip`。image 类型 → `size-6` 的 `<img src={reference.previewUrl}>`;其它类型 → 截断文字。chip 为 `contentEditable="false"` 的 `<span>`,用 `dataset.refLabel` 存 label 以便序列化。
|
||||
3. **value ← DOM(syncFromEditor)**:仿 `serializeNodes`,但 chip 序列化为 `dataset.refLabel`(label 文本)而非 node 标记;文本节点原样拼接,`<br>` → `\n`。
|
||||
4. **@ 菜单**:复用 `textBeforeCaret` + `/@([^\s@]*)$/` 检测;菜单定位改用 `getSelection().getRangeAt(0).getBoundingClientRect()`(比 textarea 的镜像 div 更简单、天然贴着光标)。菜单 UI 沿用现有 `MentionMenu` 的视觉(缩略图 + label + 副标题)。
|
||||
5. **原子删除**:直接复用 composer 的 `deleteAdjacentReference` / `adjacentReferenceNode` / `findReferenceSibling`——contentEditable + `contentEditable="false"` chip 天然整体删除。可把这几个通用 DOM 助手连同 `textBeforeCaret` / `placeCaretAtEnd` / `closestEditor` 复制进新组件(保持 composer 不动,规避回归风险)。
|
||||
6. **回车发送 + IME**:复用 `isPlainEnterKey` / `isImeComposing`(`@/lib/keyboard-event`)与 `onCompositionStart/End`,发送时调用 `onSubmit`。
|
||||
7. **占位符 & 样式**:空值时显示 placeholder(absolute 占位层);容器套用面板传入的 `className/style`(`min-h-40`、透明底、无边框,与当前一致)。
|
||||
|
||||
### 替换点
|
||||
|
||||
`canvas-node-prompt-panel.tsx` 里把 `<CanvasResourceMentionTextarea .../>` 换成 `<CanvasPromptChipInput .../>`(同样的 value/references/onChange/onSubmit/className/style/placeholder)。其余(模型选择、设置、生成按钮)不动。
|
||||
|
||||
### 不改动
|
||||
|
||||
- `CanvasResourceMentionTextarea`(textarea 版)保留,节点内文本编辑继续用它。
|
||||
- 生成链路、`image-reference-prompt.ts` 前缀逻辑不变(value 仍是 label 文本)。
|
||||
|
||||
## 影响文件
|
||||
|
||||
- 新增:`web/src/components/canvas/canvas-prompt-chip-input.tsx`
|
||||
- 修改:`web/src/components/canvas/canvas-node-prompt-panel.tsx`(仅替换输入框组件 + 相应 import)
|
||||
|
||||
## 验证
|
||||
|
||||
1. `npx tsc --noEmit` 通过。
|
||||
2. dev server 刷新后:
|
||||
- 连一张图片到图片 / 视频节点 → 面板输入框输入 `@` → 菜单贴着光标弹出 → 选中 → **输入框内显示该图的真实缩略图 chip**(不再是「图片1」文字)。
|
||||
- 光标在 chip 后按一次 Backspace → 整个 chip 一次删除。
|
||||
- 普通文字 + chip 混排,回车触发生成;确认发给生成的 prompt 里引用仍是「图片1」文本(生成结果正常引用到图)。
|
||||
- 中文输入法输入不吞字、不误触发发送。
|
||||
- 回归:双击文本节点进入内文本编辑仍正常(未受影响)。
|
||||
@@ -120,7 +120,7 @@ export function CanvasConfigComposer({ value, inputs, onChange, onClose }: Canva
|
||||
</div>
|
||||
<Button size="small" type="text" className="!h-7 !w-7 !min-w-7 !p-0" icon={<X className="size-3.5" />} onClick={onClose} />
|
||||
</div>
|
||||
<div className="relative rounded-xl border" style={{ background: theme.node.fill, borderColor: theme.node.stroke }}>
|
||||
<div className="relative rounded-xl">
|
||||
{!value.trim() ? <div className="pointer-events-none absolute left-3 top-2 text-sm leading-7" style={{ color: theme.node.placeholder }}>输入提示词,按 @ 引用连接的图片或文本</div> : null}
|
||||
<div
|
||||
ref={editorRef}
|
||||
|
||||
@@ -156,6 +156,7 @@ function buildNodeConfig(globalConfig: AiConfig, node: CanvasNodeData, mode: Can
|
||||
model,
|
||||
quality: node.metadata?.quality || globalConfig.quality || defaultConfig.quality,
|
||||
size: node.metadata?.size || globalConfig.size || defaultConfig.size,
|
||||
background: node.metadata?.background ?? globalConfig.background ?? defaultConfig.background,
|
||||
videoSeconds: node.metadata?.seconds || globalConfig.videoSeconds || defaultConfig.videoSeconds,
|
||||
vquality: node.metadata?.vquality || globalConfig.vquality || defaultConfig.vquality,
|
||||
videoGenerateAudio: node.metadata?.generateAudio || globalConfig.videoGenerateAudio || defaultConfig.videoGenerateAudio,
|
||||
|
||||
@@ -68,8 +68,8 @@ export function CanvasNodePromptPanel({ node, isRunning, onPromptChange, onConfi
|
||||
references={mentionReferences}
|
||||
onChange={updatePrompt}
|
||||
onSubmit={submit}
|
||||
className="thin-scrollbar h-24 w-full resize-none rounded-xl border px-3 py-2 text-sm leading-5 outline-none"
|
||||
style={{ background: theme.node.fill, borderColor: theme.node.stroke, color: theme.node.text }}
|
||||
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={promptPlaceholder(mode, hasImageContent, hasTextContent)}
|
||||
/>
|
||||
|
||||
@@ -78,7 +78,7 @@ export function CanvasNodePromptPanel({ node, isRunning, onPromptChange, onConfi
|
||||
<CanvasPromptLibrary onSelect={updatePrompt} />
|
||||
{mode === "image" ? (
|
||||
<>
|
||||
<ModelPicker config={config} value={config.model} onChange={(model) => onConfigChange(node.id, { model })} capability="image" onMissingConfig={() => openConfigDialog(true)} />
|
||||
<ModelPicker config={config} value={config.model} onChange={(model) => onConfigChange(node.id, { model })} capability="image" onMissingConfig={() => openConfigDialog(true)} className="max-w-[190px]" />
|
||||
<CanvasImageSettingsPopover
|
||||
config={config}
|
||||
placement="topLeft"
|
||||
@@ -90,16 +90,16 @@ export function CanvasNodePromptPanel({ node, isRunning, onPromptChange, onConfi
|
||||
</>
|
||||
) : mode === "video" ? (
|
||||
<>
|
||||
<ModelPicker config={config} value={config.model} onChange={(model) => onConfigChange(node.id, { model })} capability="video" onMissingConfig={() => openConfigDialog(true)} />
|
||||
<ModelPicker config={config} value={config.model} onChange={(model) => onConfigChange(node.id, { model })} capability="video" onMissingConfig={() => openConfigDialog(true)} className="max-w-[190px]" />
|
||||
<CanvasVideoSettingsPopover config={config} buttonClassName="!h-10 !max-w-[170px] !justify-start !rounded-full !px-3" onConfigChange={(key, value) => onConfigChange(node.id, videoConfigPatch(key, value))} />
|
||||
</>
|
||||
) : mode === "audio" ? (
|
||||
<>
|
||||
<ModelPicker config={config} value={config.model} onChange={(model) => onConfigChange(node.id, { model })} capability="audio" onMissingConfig={() => openConfigDialog(true)} />
|
||||
<ModelPicker config={config} value={config.model} onChange={(model) => onConfigChange(node.id, { model })} capability="audio" onMissingConfig={() => openConfigDialog(true)} className="max-w-[190px]" />
|
||||
<CanvasAudioSettingsPopover config={config} buttonClassName="!h-10 !max-w-[170px] !justify-start !rounded-full !px-3" onConfigChange={(key, value) => onConfigChange(node.id, audioConfigPatch(key, value))} />
|
||||
</>
|
||||
) : (
|
||||
<ModelPicker config={config} value={config.model} onChange={(model) => onConfigChange(node.id, { model })} capability="text" onMissingConfig={() => openConfigDialog(true)} />
|
||||
<ModelPicker config={config} value={config.model} onChange={(model) => onConfigChange(node.id, { model })} capability="text" onMissingConfig={() => openConfigDialog(true)} className="max-w-[190px]" />
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
@@ -145,6 +145,7 @@ function buildNodeConfig(globalConfig: AiConfig, node: CanvasNodeData, mode: Can
|
||||
model,
|
||||
quality: node.metadata?.quality || globalConfig.quality || defaultConfig.quality,
|
||||
size: node.metadata?.size || globalConfig.size || defaultConfig.size,
|
||||
background: node.metadata?.background ?? globalConfig.background ?? defaultConfig.background,
|
||||
videoSeconds: node.metadata?.seconds || globalConfig.videoSeconds || defaultConfig.videoSeconds,
|
||||
vquality: node.metadata?.vquality || globalConfig.vquality || defaultConfig.vquality,
|
||||
videoGenerateAudio: node.metadata?.generateAudio || globalConfig.videoGenerateAudio || defaultConfig.videoGenerateAudio,
|
||||
|
||||
@@ -427,7 +427,7 @@ export const CanvasNode = React.memo(function CanvasNode({
|
||||
{!isGroup ? <ConnectionHandleDot side="left" visible={hovered || isSelected || isConnecting} onMouseDown={(event) => onConnectStart(event, data.id, "target")} /> : null}
|
||||
{!isGroup ? <ConnectionHandleDot side="right" visible={(definition?.hasSourceHandle ?? true) && data.type !== CanvasNodeType.Config && (hovered || isSelected || isConnecting)} onMouseDown={(event) => onConnectStart(event, data.id, "source")} /> : null}
|
||||
|
||||
{showPanel && !isGroup && renderPanel ? <div className="absolute left-1/2 top-full z-[70] w-[500px] -translate-x-1/2 pt-4">{renderPanel(data)}</div> : null}
|
||||
{showPanel && !isGroup && renderPanel ? <div className="absolute left-1/2 top-full z-[70] w-[600px] -translate-x-1/2 pt-4">{renderPanel(data)}</div> : null}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -89,9 +89,11 @@ export const CanvasResourceMentionTextarea = forwardRef<HTMLTextAreaElement, Pro
|
||||
...(style || {}),
|
||||
color: showOverlay ? "transparent" : style?.color,
|
||||
caretColor: style?.color || theme.node.text,
|
||||
...(showOverlay ? { background: "transparent", backgroundColor: "transparent" } : {}),
|
||||
cursor: "text",
|
||||
// showOverlay 时高亮 div 覆盖在 textarea 上,若不把 textarea 提到上层,原生插入光标(caret)会被盖住看不见
|
||||
...(showOverlay ? { position: "relative", zIndex: 1, background: "transparent", backgroundColor: "transparent" } : {}),
|
||||
} as CSSProperties;
|
||||
const menu = mention && candidates.length && textareaRef.current ? <MentionMenu textarea={textareaRef.current} references={candidates} activeIndex={Math.min(activeIndex, candidates.length - 1)} theme={theme} onSelect={insertReference} /> : null;
|
||||
const menu = mention && candidates.length && textareaRef.current ? <MentionMenu textarea={textareaRef.current} caretIndex={mention.start} references={candidates} activeIndex={Math.min(activeIndex, candidates.length - 1)} theme={theme} onSelect={insertReference} /> : null;
|
||||
|
||||
return (
|
||||
<div className={`relative h-full w-full ${containerClassName || ""}`}>
|
||||
@@ -136,6 +138,18 @@ export const CanvasResourceMentionTextarea = forwardRef<HTMLTextAreaElement, Pro
|
||||
onKeyDown?.(event);
|
||||
return;
|
||||
}
|
||||
if ((event.key === "Backspace" || event.key === "Delete") && !mention) {
|
||||
const el = textareaRef.current;
|
||||
if (el && el.selectionStart === el.selectionEnd) {
|
||||
const result = deleteAdjacentLabel(value, el.selectionStart, event.key === "Backspace" ? "backward" : "forward", activeLabels);
|
||||
if (result) {
|
||||
event.preventDefault();
|
||||
updateValue(result.value, result.caret);
|
||||
requestAnimationFrame(updateSelectionState);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (mention && candidates.length) {
|
||||
if (event.key === "ArrowDown") {
|
||||
event.preventDefault();
|
||||
@@ -199,16 +213,24 @@ function MentionHighlightText({ value, labels, placeholder }: { value: string; l
|
||||
);
|
||||
}
|
||||
|
||||
function MentionMenu({ textarea, references, activeIndex, theme, onSelect }: { textarea: HTMLTextAreaElement; references: CanvasResourceReference[]; activeIndex: number; theme: (typeof canvasThemes)[keyof typeof canvasThemes]; onSelect: (reference: CanvasResourceReference) => void }) {
|
||||
function MentionMenu({ textarea, caretIndex, references, activeIndex, theme, onSelect }: { textarea: HTMLTextAreaElement; caretIndex: number; references: CanvasResourceReference[]; activeIndex: number; theme: (typeof canvasThemes)[keyof typeof canvasThemes]; onSelect: (reference: CanvasResourceReference) => void }) {
|
||||
const selectedRef = useRef(false);
|
||||
const rect = textarea.getBoundingClientRect();
|
||||
const boundary = textarea.closest(".ant-modal-content")?.getBoundingClientRect() || { left: 8, top: 8, right: window.innerWidth - 8, bottom: window.innerHeight - 8 };
|
||||
const menuWidth = 256;
|
||||
const maxMenuHeight = 224;
|
||||
const gap = 6;
|
||||
const left = clamp(rect.left, boundary.left + 8, boundary.right - menuWidth - 8);
|
||||
const showAbove = rect.bottom + gap + maxMenuHeight > boundary.bottom && rect.top - gap - maxMenuHeight >= boundary.top;
|
||||
const top = clamp(showAbove ? rect.top - gap - maxMenuHeight : rect.bottom + gap, boundary.top + 8, boundary.bottom - maxMenuHeight - 8);
|
||||
// 菜单锚定到 @ 所在的光标像素位置(而非 textarea 底边),避免输入框较高时菜单离 @ 太远。
|
||||
// 画布可能被缩放,rect 是缩放后坐标,而镜像测量得到的是布局坐标,需按 scale 换算。
|
||||
const scale = textarea.offsetWidth ? rect.width / textarea.offsetWidth : 1;
|
||||
const computed = window.getComputedStyle(textarea);
|
||||
const lineHeight = (parseFloat(computed.lineHeight) || parseFloat(computed.fontSize) * 1.4 || 20) * scale;
|
||||
const caret = getCaretPoint(textarea, caretIndex);
|
||||
const caretLeft = rect.left + (caret.left - textarea.scrollLeft) * scale;
|
||||
const caretTop = rect.top + (caret.top - textarea.scrollTop) * scale;
|
||||
const left = clamp(caretLeft, boundary.left + 8, boundary.right - menuWidth - 8);
|
||||
const showAbove = caretTop + lineHeight + gap + maxMenuHeight > boundary.bottom && caretTop - gap - maxMenuHeight >= boundary.top;
|
||||
const top = clamp(showAbove ? caretTop - gap - maxMenuHeight : caretTop + lineHeight + gap, boundary.top + 8, boundary.bottom - maxMenuHeight - 8);
|
||||
|
||||
const stopCanvasInteraction = (event: PointerEvent | MouseEvent) => {
|
||||
event.stopPropagation();
|
||||
@@ -273,6 +295,58 @@ function clamp(value: number, min: number, max: number) {
|
||||
return Math.min(Math.max(value, min), max);
|
||||
}
|
||||
|
||||
// 通过镜像 div 复刻 textarea 的排版,测出第 index 个字符处光标相对 textarea 的像素坐标(布局尺度,未含缩放)。
|
||||
const MIRROR_STYLE_PROPS = ["boxSizing", "width", "paddingTop", "paddingRight", "paddingBottom", "paddingLeft", "borderTopWidth", "borderRightWidth", "borderBottomWidth", "borderLeftWidth", "fontStyle", "fontVariant", "fontWeight", "fontStretch", "fontSize", "lineHeight", "fontFamily", "textAlign", "textIndent", "letterSpacing", "wordSpacing", "tabSize", "textTransform"] as const;
|
||||
|
||||
function getCaretPoint(textarea: HTMLTextAreaElement, index: number) {
|
||||
const computed = window.getComputedStyle(textarea);
|
||||
const mirror = document.createElement("div");
|
||||
const style = mirror.style;
|
||||
style.position = "absolute";
|
||||
style.top = "0";
|
||||
style.left = "-9999px";
|
||||
style.visibility = "hidden";
|
||||
style.whiteSpace = "pre-wrap";
|
||||
style.overflowWrap = "break-word";
|
||||
for (const prop of MIRROR_STYLE_PROPS) style[prop] = computed[prop];
|
||||
mirror.textContent = textarea.value.slice(0, index);
|
||||
const marker = document.createElement("span");
|
||||
marker.textContent = textarea.value.slice(index) || ".";
|
||||
mirror.appendChild(marker);
|
||||
document.body.appendChild(mirror);
|
||||
const point = { left: marker.offsetLeft, top: marker.offsetTop };
|
||||
document.body.removeChild(mirror);
|
||||
return point;
|
||||
}
|
||||
|
||||
function escapeRegExp(value: string) {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
// 纯 textarea 无法做真正的原子 token,这里在删除时把整个引用 label 当作一个整体一次删掉,
|
||||
// 避免逐字删除把「图片1」删成「图片」。labels 需按长度降序传入以优先匹配更长的 label。
|
||||
function deleteAdjacentLabel(value: string, caret: number, direction: "backward" | "forward", labels: string[]): { value: string; caret: number } | null {
|
||||
if (direction === "backward") {
|
||||
const before = value.slice(0, caret);
|
||||
for (const label of labels) {
|
||||
if (!label) continue;
|
||||
const match = new RegExp(`(^|\\s)(${escapeRegExp(label)})\\s*$`).exec(before);
|
||||
if (match) {
|
||||
const start = match.index + match[1].length; // label 起始位置(保留前面的分隔空格)
|
||||
return { value: value.slice(0, start) + value.slice(caret), caret: start };
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 向后删除:光标前须是行首或空白,避免切进其它文字中间
|
||||
if (caret > 0 && !/\s/.test(value[caret - 1])) return null;
|
||||
const after = value.slice(caret);
|
||||
for (const label of labels) {
|
||||
if (!label) continue;
|
||||
const match = new RegExp(`^(\\s*)(${escapeRegExp(label)})(?=\\s|$)`).exec(after);
|
||||
if (match) {
|
||||
return { value: value.slice(0, caret) + value.slice(caret + match[0].length), caret };
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -152,7 +152,7 @@ export function InfiniteCanvas({ containerRef, viewport, backgroundMode = "lines
|
||||
onCanvasDeselect?.();
|
||||
}
|
||||
panState.current.isPanning = false;
|
||||
document.body.style.cursor = "default";
|
||||
document.body.style.cursor = "";
|
||||
};
|
||||
|
||||
window.addEventListener("pointermove", handlePointerMove);
|
||||
|
||||
@@ -33,7 +33,7 @@ export const imageAspectOptions = aspectOptions.map((item) => ({ value: item.siz
|
||||
|
||||
type ImageSettingsPanelProps = {
|
||||
config: AiConfig;
|
||||
onConfigChange: (key: "quality" | "size" | "count", value: string) => void;
|
||||
onConfigChange: (key: "quality" | "size" | "count" | "background", value: string) => void;
|
||||
theme: CanvasTheme;
|
||||
showTitle?: boolean;
|
||||
className?: string;
|
||||
@@ -46,6 +46,7 @@ export function ImageSettingsPanel({ config, onConfigChange, theme, showTitle =
|
||||
const quality = config.quality || "auto";
|
||||
const count = Math.max(1, Math.min(maxCount, Math.floor(Math.abs(Number(config.count)) || 1)));
|
||||
const activeSize = config.size || "auto";
|
||||
const transparentBackground = config.background === "transparent";
|
||||
const selectedAspect = aspectOptions.find((item) => (item.size || item.value) === activeSize || item.value === activeSize);
|
||||
const dimensions = readSizeDimensions(activeSize, selectedAspect || aspectOptions[0]);
|
||||
const selectAspect = (value: string) => {
|
||||
@@ -117,6 +118,17 @@ export function ImageSettingsPanel({ config, onConfigChange, theme, showTitle =
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="space-y-0.5">
|
||||
<SettingTitle color={theme.node.muted}>透明背景</SettingTitle>
|
||||
<div className="text-xs" style={{ color: theme.node.muted, opacity: 0.75 }}>
|
||||
开启后生成无背景的透明图像(仅部分模型可用)
|
||||
</div>
|
||||
</div>
|
||||
<span onMouseDown={(event) => event.stopPropagation()}>
|
||||
<Switch size="small" checked={transparentBackground} onChange={(checked) => onConfigChange("background", checked ? "transparent" : "")} />
|
||||
</span>
|
||||
</div>
|
||||
<div className="space-y-2.5">
|
||||
<SettingTitle color={theme.node.muted}>生成张数</SettingTitle>
|
||||
<div className="grid grid-cols-4 gap-2.5">
|
||||
|
||||
@@ -2522,6 +2522,7 @@ function InfiniteCanvasPage() {
|
||||
model: savedImageMetadata.model || effectiveConfig.imageModel || effectiveConfig.model,
|
||||
quality: savedImageMetadata.quality || effectiveConfig.quality,
|
||||
size: savedImageMetadata.size || effectiveConfig.size,
|
||||
background: savedImageMetadata.background ?? effectiveConfig.background,
|
||||
count: "1",
|
||||
}
|
||||
: { ...buildGenerationConfig(effectiveConfig, sourceNode, node.type === CanvasNodeType.Text ? "text" : node.type === CanvasNodeType.Video ? "video" : node.type === CanvasNodeType.Audio ? "audio" : "image"), count: "1" };
|
||||
@@ -2579,7 +2580,7 @@ function InfiniteCanvasPage() {
|
||||
const imageConfig = NODE_DEFAULT_SIZE[CanvasNodeType.Image];
|
||||
const imageSize = fitNodeSize(uploadedImage.width, uploadedImage.height, imageConfig.width, imageConfig.height);
|
||||
const generationMetadata = savedImageMetadata?.generationType
|
||||
? { generationType: savedImageMetadata.generationType, model: generationConfig.model, size: generationConfig.size, quality: generationConfig.quality, count: savedImageMetadata.count || 1, references: savedImageMetadata.references }
|
||||
? { generationType: savedImageMetadata.generationType, model: generationConfig.model, size: generationConfig.size, quality: generationConfig.quality, ...(generationConfig.background ? { background: generationConfig.background } : {}), count: savedImageMetadata.count || 1, references: savedImageMetadata.references }
|
||||
: buildImageGenerationMetadata(useReferenceImages ? "edit" : "generation", generationConfig, 1, retryImages);
|
||||
setNodes((prev) =>
|
||||
prev.map((item) =>
|
||||
@@ -3282,6 +3283,7 @@ function buildImageGenerationMetadata(type: CanvasImageGenerationType, config: A
|
||||
model: config.model,
|
||||
size: config.size,
|
||||
quality: config.quality,
|
||||
...(config.background ? { background: config.background } : {}),
|
||||
count,
|
||||
references: references.map(referenceUrl).filter((url): url is string => Boolean(url)),
|
||||
};
|
||||
@@ -3460,6 +3462,7 @@ function buildGenerationConfig(config: AiConfig, node: CanvasNodeData | undefine
|
||||
model: node?.metadata?.model || defaultModel || (mode === "audio" ? defaultConfig.audioModel : config.model || defaultConfig.model),
|
||||
quality: node?.metadata?.quality || config.quality || defaultConfig.quality,
|
||||
size: node?.metadata?.size || config.size || defaultConfig.size,
|
||||
background: node?.metadata?.background ?? config.background ?? defaultConfig.background,
|
||||
videoSeconds: node?.metadata?.seconds || config.videoSeconds || defaultConfig.videoSeconds,
|
||||
vquality: node?.metadata?.vquality || config.vquality || defaultConfig.vquality,
|
||||
videoGenerateAudio: node?.metadata?.generateAudio || config.videoGenerateAudio || defaultConfig.videoGenerateAudio,
|
||||
|
||||
@@ -122,6 +122,11 @@ function normalizeQuality(quality: string) {
|
||||
return QUALITY_BASE[normalized] ? normalized : undefined;
|
||||
}
|
||||
|
||||
/** Only "transparent" is forwarded; any other value (incl. empty) means keep the default opaque background. */
|
||||
function normalizeBackground(background: string | undefined) {
|
||||
return background?.trim().toLowerCase() === "transparent" ? "transparent" : undefined;
|
||||
}
|
||||
|
||||
/** Map "quality + ratio" to an explicit pixel dimension like "3840x2160". */
|
||||
function resolveSize(quality: string | undefined, ratio: string): string {
|
||||
const parsedRatio = parseImageRatio(ratio);
|
||||
@@ -661,6 +666,7 @@ export async function requestGeneration(config: AiConfig, prompt: string, option
|
||||
if (script) {
|
||||
const quality = normalizeQuality(config.quality);
|
||||
const requestSize = resolveRequestSize(quality, config.size);
|
||||
const background = normalizeBackground(config.background);
|
||||
try {
|
||||
const result = await runModelPlugin({
|
||||
capability: "image",
|
||||
@@ -668,7 +674,7 @@ export async function requestGeneration(config: AiConfig, prompt: string, option
|
||||
config: requestConfig,
|
||||
prompt: withSystemPrompt(requestConfig, prompt),
|
||||
images: [],
|
||||
params: { size: requestSize, quality, count: n },
|
||||
params: { size: requestSize, quality, count: n, ...(background ? { background } : {}) },
|
||||
signal: options?.signal,
|
||||
});
|
||||
return normalizePluginImages(result).map((dataUrl) => ({ id: nanoid(), dataUrl }));
|
||||
@@ -685,6 +691,7 @@ export async function requestGeneration(config: AiConfig, prompt: string, option
|
||||
}
|
||||
const quality = normalizeQuality(config.quality);
|
||||
const requestSize = resolveRequestSize(quality, config.size);
|
||||
const background = normalizeBackground(config.background);
|
||||
try {
|
||||
const response = await axios.post<ImageApiResponse>(
|
||||
aiApiUrl(requestConfig, "/images/generations"),
|
||||
@@ -694,6 +701,7 @@ export async function requestGeneration(config: AiConfig, prompt: string, option
|
||||
n,
|
||||
...(quality ? { quality } : {}),
|
||||
...(requestSize ? { size: requestSize } : {}),
|
||||
...(background ? { background } : {}),
|
||||
response_format: "b64_json",
|
||||
output_format: IMAGE_OUTPUT_FORMAT,
|
||||
},
|
||||
@@ -717,6 +725,7 @@ export async function requestEdit(config: AiConfig, prompt: string, references:
|
||||
if (script) {
|
||||
const quality = normalizeQuality(config.quality);
|
||||
const requestSize = resolveRequestSize(quality, config.size);
|
||||
const background = normalizeBackground(config.background);
|
||||
const refs = await Promise.all(references.map((image) => imageToDataUrl(image)));
|
||||
try {
|
||||
const result = await runModelPlugin({
|
||||
@@ -725,7 +734,7 @@ export async function requestEdit(config: AiConfig, prompt: string, references:
|
||||
config: requestConfig,
|
||||
prompt: withSystemPrompt(requestConfig, requestPrompt),
|
||||
images: refs,
|
||||
params: { size: requestSize, quality, count: n },
|
||||
params: { size: requestSize, quality, count: n, ...(background ? { background } : {}) },
|
||||
signal: options?.signal,
|
||||
});
|
||||
return normalizePluginImages(result).map((dataUrl) => ({ id: nanoid(), dataUrl }));
|
||||
@@ -743,6 +752,7 @@ export async function requestEdit(config: AiConfig, prompt: string, references:
|
||||
}
|
||||
const quality = normalizeQuality(config.quality);
|
||||
const requestSize = resolveRequestSize(quality, config.size);
|
||||
const background = normalizeBackground(config.background);
|
||||
const formData = new FormData();
|
||||
formData.set("model", requestConfig.model);
|
||||
formData.set("prompt", withSystemPrompt(requestConfig, requestPrompt));
|
||||
@@ -755,6 +765,9 @@ export async function requestEdit(config: AiConfig, prompt: string, references:
|
||||
if (requestSize) {
|
||||
formData.set("size", requestSize);
|
||||
}
|
||||
if (background) {
|
||||
formData.set("background", background);
|
||||
}
|
||||
const files = await Promise.all(references.map(async (image) => dataUrlToFile({ ...image, dataUrl: await imageToDataUrl(image) })));
|
||||
files.forEach((file) => formData.append("image", file));
|
||||
if (mask) formData.set("mask", dataUrlToFile(mask));
|
||||
|
||||
@@ -44,6 +44,7 @@ export type AiConfig = {
|
||||
models: string[];
|
||||
quality: string;
|
||||
size: string;
|
||||
background: string;
|
||||
count: string;
|
||||
canvasImageCount: string;
|
||||
};
|
||||
@@ -99,6 +100,7 @@ export const defaultConfig: AiConfig = {
|
||||
models: ["default::gpt-image-2", "default::grok-imagine-video", "default::gpt-5.5", "default::gpt-4o-mini-tts"],
|
||||
quality: "auto",
|
||||
size: "1:1",
|
||||
background: "",
|
||||
count: "1",
|
||||
canvasImageCount: "3",
|
||||
};
|
||||
|
||||
@@ -37,6 +37,7 @@ export type CanvasNodeMetadata = {
|
||||
model?: string;
|
||||
size?: string;
|
||||
quality?: string;
|
||||
background?: string;
|
||||
count?: number;
|
||||
seconds?: string;
|
||||
vquality?: string;
|
||||
|
||||
Reference in New Issue
Block a user