diff --git a/CHANGELOG.md b/CHANGELOG.md
index 995e059..806b796 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -3,6 +3,8 @@
## Unreleased
+ [新增] 图像设置新增「透明背景」开关,开启后生成无背景的透明图像。
++ [修复] 画布节点提示词输入框补上悬停文本光标。
++ [优化] 画布节点输入区域移除灰色底色与边框、美化样式。
## v0.8.1 - 2026-07-16
diff --git a/web/src/components/canvas/canvas-config-composer.tsx b/web/src/components/canvas/canvas-config-composer.tsx
index 07f26b7..295f2fa 100644
--- a/web/src/components/canvas/canvas-config-composer.tsx
+++ b/web/src/components/canvas/canvas-config-composer.tsx
@@ -120,7 +120,7 @@ export function CanvasConfigComposer({ value, inputs, onChange, onClose }: Canva
} onClick={onClose} />
-
+
{!value.trim() ?
输入提示词,按 @ 引用连接的图片或文本
: null}
@@ -78,7 +78,7 @@ export function CanvasNodePromptPanel({ node, isRunning, onPromptChange, onConfi
{mode === "image" ? (
<>
-
onConfigChange(node.id, { model })} capability="image" onMissingConfig={() => openConfigDialog(true)} />
+ onConfigChange(node.id, { model })} capability="image" onMissingConfig={() => openConfigDialog(true)} className="max-w-[190px]" />
) : mode === "video" ? (
<>
- onConfigChange(node.id, { model })} capability="video" onMissingConfig={() => openConfigDialog(true)} />
+ onConfigChange(node.id, { model })} capability="video" onMissingConfig={() => openConfigDialog(true)} className="max-w-[190px]" />
onConfigChange(node.id, videoConfigPatch(key, value))} />
>
) : mode === "audio" ? (
<>
- onConfigChange(node.id, { model })} capability="audio" onMissingConfig={() => openConfigDialog(true)} />
+ onConfigChange(node.id, { model })} capability="audio" onMissingConfig={() => openConfigDialog(true)} className="max-w-[190px]" />
onConfigChange(node.id, audioConfigPatch(key, value))} />
>
) : (
- onConfigChange(node.id, { model })} capability="text" onMissingConfig={() => openConfigDialog(true)} />
+ onConfigChange(node.id, { model })} capability="text" onMissingConfig={() => openConfigDialog(true)} className="max-w-[190px]" />
)}
);
});
diff --git a/web/src/components/canvas/canvas-resource-mention-textarea.tsx b/web/src/components/canvas/canvas-resource-mention-textarea.tsx
index 5315750..e07a5b6 100644
--- a/web/src/components/canvas/canvas-resource-mention-textarea.tsx
+++ b/web/src/components/canvas/canvas-resource-mention-textarea.tsx
@@ -89,9 +89,11 @@ export const CanvasResourceMentionTextarea = forwardRef : null;
+ const menu = mention && candidates.length && textareaRef.current ? : null;
return (
@@ -136,6 +138,18 @@ export const CanvasResourceMentionTextarea = forwardRef 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;
+}
diff --git a/web/src/components/canvas/infinite-canvas.tsx b/web/src/components/canvas/infinite-canvas.tsx
index e6f59f2..c1c6667 100644
--- a/web/src/components/canvas/infinite-canvas.tsx
+++ b/web/src/components/canvas/infinite-canvas.tsx
@@ -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);