diff --git a/docs/pending-test.md b/docs/pending-test.md
index 65a702d..ea850b2 100644
--- a/docs/pending-test.md
+++ b/docs/pending-test.md
@@ -14,7 +14,7 @@
- 管理后台编辑渠道时,API Key 留空不再触发必填校验,表示沿用已保存的密钥;新增渠道仍要求填写 API Key。
- 管理后台公开配置里的系统可用模型候选项改为由已启用渠道中选择的模型合并去重生成,最终开放哪些模型仍由公开配置里手动勾选。
- 视频生成请求参数对齐 `grok-imagine-video` 接口:使用 `resolution_name`、`preset=normal`、`input_reference[]`,支持清晰度、尺寸、秒数快捷选择和手动输入,并支持最多 7 张参考图。
-- 画布视频设置浮层改为挂载到页面根层级,避免被节点悬浮工具栏遮挡。
+- 画布视频设置浮层改为挂载到页面根层级并使用自建浮层交互,避免被节点悬浮工具栏遮挡或点击面板内容时关闭。
- 画布生成配置节点的生图参数改为复用图像设置浮层,支持在同一个入口里调整质量、尺寸和生成张数。
- 视频清晰度输入框改为只输入数字,提交请求时再拼接 `p` 单位。
- 视频生成前端会识别后端 `{ code, msg }` 错误响应,创建失败不再继续轮询 `undefined`。
diff --git a/web/src/app/(user)/canvas/components/canvas-config-node-panel.tsx b/web/src/app/(user)/canvas/components/canvas-config-node-panel.tsx
index 0e5b515..8f5c117 100644
--- a/web/src/app/(user)/canvas/components/canvas-config-node-panel.tsx
+++ b/web/src/app/(user)/canvas/components/canvas-config-node-panel.tsx
@@ -121,7 +121,7 @@ export function CanvasConfigNodePanel({ node, isRunning, inputSummary, inputs, o
event.stopPropagation()}>
onConfigChange(node.id, { model })} onMissingConfig={() => openConfigDialog(true)} fullWidth />
{mode === "video" ? (
- onConfigChange(node.id, key === "videoSeconds" ? { seconds: value } : { [key]: value })} />
+ onConfigChange(node.id, key === "videoSeconds" ? { seconds: value } : { [key]: value })} />
) : mode === "image" ? (
onConfigChange(node.id, key === "count" ? { count: Number(value) || 1 } : { [key]: value })} />
) : null}
diff --git a/web/src/app/(user)/canvas/components/canvas-video-settings-popover.tsx b/web/src/app/(user)/canvas/components/canvas-video-settings-popover.tsx
index 06a5962..4e4c613 100644
--- a/web/src/app/(user)/canvas/components/canvas-video-settings-popover.tsx
+++ b/web/src/app/(user)/canvas/components/canvas-video-settings-popover.tsx
@@ -1,7 +1,9 @@
"use client";
+import { useEffect, useRef, useState, type RefObject } from "react";
+import { createPortal } from "react-dom";
import { Settings2 } from "lucide-react";
-import { Button, Popover } from "antd";
+import { Button } from "antd";
import { VideoSettingsPanel, videoResolutionLabel, videoSecondsLabel, videoSizeLabel } from "@/components/video-settings-panel";
import { canvasThemes } from "@/lib/canvas-theme";
@@ -17,23 +19,95 @@ type CanvasVideoSettingsPopoverProps = {
export function CanvasVideoSettingsPopover({ config, onConfigChange, buttonClassName, placement = "topLeft" }: CanvasVideoSettingsPopoverProps) {
const theme = canvasThemes[useThemeStore((state) => state.theme)];
+ const buttonRef = useRef(null);
+ const panelRef = useRef(null);
+ const [open, setOpen] = useState(false);
+ const [buttonRect, setButtonRect] = useState(null);
+
+ useEffect(() => {
+ if (!open) return;
+ const syncPosition = () => setButtonRect(buttonRef.current?.getBoundingClientRect() || null);
+ const closeOnOutsidePointer = (event: PointerEvent) => {
+ const target = event.target;
+ if (!(target instanceof Node)) return;
+ if (buttonRef.current?.contains(target) || panelRef.current?.contains(target)) return;
+ setOpen(false);
+ };
+
+ syncPosition();
+ window.addEventListener("resize", syncPosition);
+ window.addEventListener("scroll", syncPosition, true);
+ window.addEventListener("pointerdown", closeOnOutsidePointer, true);
+ return () => {
+ window.removeEventListener("resize", syncPosition);
+ window.removeEventListener("scroll", syncPosition, true);
+ window.removeEventListener("pointerdown", closeOnOutsidePointer, true);
+ };
+ }, [open]);
+
+ const panel = open && buttonRect ? : null;
return (
- document.body}
- content={ onConfigChange(key, value)} theme={theme} />}
- >
- }>
-
- {videoResolutionLabel(config.vquality)} · {videoSizeLabel(config.size)} · {videoSecondsLabel(config.videoSeconds)}
-
-
-
+ <>
+
+ } onClick={() => setOpen((current) => !current)}>
+
+ {videoResolutionLabel(config.vquality)} · {videoSizeLabel(config.size)} · {videoSecondsLabel(config.videoSeconds)}
+
+
+
+ {panel}
+ >
+ );
+}
+
+function VideoSettingsPortal({
+ buttonRect,
+ panelRef,
+ placement,
+ theme,
+ config,
+ onConfigChange,
+}: {
+ buttonRect: DOMRect;
+ panelRef: RefObject;
+ placement: CanvasVideoSettingsPopoverProps["placement"];
+ theme: (typeof canvasThemes)[keyof typeof canvasThemes];
+ config: AiConfig;
+ onConfigChange: (key: keyof AiConfig, value: string) => void;
+}) {
+ const width = 356;
+ const gap = 8;
+ const margin = 12;
+ const alignRight = placement?.endsWith("Right");
+ const alignCenter = placement === "top" || placement === "bottom";
+ const left = alignCenter ? buttonRect.left + buttonRect.width / 2 - width / 2 : alignRight ? buttonRect.right - width : buttonRect.left;
+ const topPlacement = placement?.startsWith("top");
+ const style = {
+ position: "fixed",
+ zIndex: 1200,
+ width,
+ left: Math.max(margin, Math.min(window.innerWidth - width - margin, left)),
+ ...(topPlacement ? { bottom: window.innerHeight - buttonRect.top + gap, maxHeight: Math.max(260, buttonRect.top - margin * 2) } : { top: buttonRect.bottom + gap, maxHeight: Math.max(260, window.innerHeight - buttonRect.bottom - margin * 2) }),
+ background: theme.toolbar.panel,
+ borderRadius: 18,
+ boxShadow: "0 18px 54px rgba(28, 25, 23, 0.16)",
+ padding: 18,
+ overflowY: "auto",
+ color: theme.node.text,
+ } as const;
+
+ return createPortal(
+ event.stopPropagation()}
+ onMouseDown={(event) => event.stopPropagation()}
+ onClick={(event) => event.stopPropagation()}
+ >
+ onConfigChange(key, value)} theme={theme} className="space-y-4" />
+
,
+ document.body,
);
}