feat(canvas): 画布视觉 - 优化浮动控件与连接线可读性

This commit is contained in:
QAyong
2026-08-22 03:08:15 +08:00
parent d70c0961cb
commit 34a207eb39
7 changed files with 190 additions and 59 deletions

View File

@@ -0,0 +1,64 @@
# 画布浮动控件设计沉淀:顶部操作区与底部 Dock
> 范围:无限画布的顶部项目操作区、底部画布工具 Dock,以及工作模式切换。
> 目标:让画布操作控件形成统一的浮动面板系统,并在不同主题和视口下保持稳定对齐。
> 状态:已实现首版,浏览器回归待确认。
## 1. 设计方向
画布本身是创作空间,操作控件不应占用固定内容栏,也不应把按钮散落在画布节点之间。因此控件采用独立浮动面板:
- 左上放置项目菜单、画布名称和重命名入口。
- 右上放置搜索、导入、性能、积分、专注、分享和 Agent 等操作。
- 左下保留画布视图控制和素材空间 Dock。
- 工作模式切换放在右下,与左下 Dock 共用底部安全区,避免顶部中央出现额外的孤立控件。
## 2. 视觉合同
顶部操作区和底部 `FloatingDock`(浮动 Dock)复用 `canvasDockStyle`(画布 Dock 样式合同):
- `--dock-surface`:浮动面板表面。
- `--dock-border`:主题边界。
- `--elevation-overlay`:面板层级阴影。
- `--dock-command-hover`:控件悬停表面。
- `--dock-command-active`:持续激活状态表面。
普通操作按钮默认透明,只有悬停或持续激活时才显示控件表面;面板本身负责边界和阴影,避免每个按钮都形成独立卡片。
## 3. 工作模式切换
`CanvasWorkspaceModeSwitch`(画布工作模式切换)是右下角独立浮动面板,不再挂在顶部栏中间:
- 触发器与底部 Dock 使用同一高度、圆角、边界和阴影。
- 菜单从触发器上方展开,并与右侧边缘对齐,避免遮挡底部工具栏和画布主体。
- 外层定位使用 `--canvas-inset-x` / `--canvas-inset-y`,与左下视图控制保持同一水平基线。
- 简洁模式和专业模式继续使用原有 `workspaceMode` 状态,不改变节点渲染和生成逻辑。
## 4. 实现地图
- `web/src/pages/canvas/canvas-project-top-bar.tsx`:顶部左右浮动面板,以及工作模式切换组件和下拉菜单。
- `web/src/pages/canvas/project.tsx`:将工作模式切换定位到画布右下角,并与底部控制使用同一安全区。
- `web/src/components/canvas/canvas-zoom-controls.tsx`:底部视图控制 Dock 的既有实现,作为浮动控件视觉基准。
- `web/src/lib/canvas/canvas-aceternity-style.ts`:提供 `canvasDockStyle` 样式合同。
- `web/src/styles/globals.css`:维护顶部面板、控件状态和工作模式菜单的唯一样式规则。
## 5. 响应式与交互边界
- 桌面端顶部左右面板保持同一顶部安全区;底部左右面板保持同一底部安全区。
- 窄视口沿用 `--canvas-inset-y + --space-16` 的底部避让规则,避免与移动端底部工具区域重叠。
- 工作模式菜单打开时支持点击外部区域和 `Escape` 关闭。
- `prefers-reduced-motion` 下关闭或缩短面板和选项的进入动画。
- 本次只调整控件位置和视觉层级,不改变菜单命令、模式持久化和画布操作行为。
## 6. 验证与待回归
已完成:
- `git diff --check` 通过。
- Impeccable 受限扫描未发现本次新增的 UI 反模式。
待确认:
- 浅色/深色主题下检查顶部左右面板、底部三个 Dock 的边界和阴影一致性。
- 检查工作模式菜单在桌面、窄视口和底部工具栏打开时的对齐与遮挡关系。
- 本项目按规则未自动运行 TypeScript、构建或测试;提交前只做了静态检查。

View File

@@ -1,7 +1,7 @@
# 画布节点可读性设计沉淀:节点与图片创作面板
> 范围:无限画布中的节点外壳、节点内容空态、节点标题和图片创作面板。
> 目标:节点在浅色、深色,以及点阵/线稿/空白画布背景上都能被快速识别和操作。
> 范围:无限画布中的节点外壳、节点内容空态、节点标题、图片创作面板和节点间连接线。
> 目标:节点和连接在浅色、深色,以及点阵/线稿/空白画布背景上都能被快速识别和操作。
> 状态:已实现首版,浏览器回归待确认。
## 1. 问题定义
@@ -10,6 +10,7 @@
- 节点边界消失,空态内容看起来像画布的一部分。
- 图片创作面板的“将使用默认模型与参数”提示、输入区和操作控件缺少稳定的表面层级。
- 节点间普通连接线使用过低的透明度和线宽,在低缩放比例或点阵背景上难以追踪。
这不是生成逻辑或参考图尺寸逻辑的问题,因此本次只调整视觉契约,不改变节点数据、生成参数、定位和交互流程。
@@ -42,6 +43,15 @@
- 展开编辑 Modal 使用相同的表面、边界和阴影,避免节点内面板与放大面板产生两套视觉语言。
- “将使用默认模型与参数”仍是状态说明,不改变模型解析或生成默认值。
### 2.4 连接线可读性
`ConnectionPath`(节点连接线)把交互命中层和视觉展示层分开:透明路径继续保留 16px 点击区域,视觉路径不承担点击事件。
- 普通状态使用“柔和外层 + 清晰内芯”两层路径,提高连接在画布背景上的连续性,但不使用高亮色制造噪音。
- 悬停或选中状态切换到 `accent.primary`(主题强调色),同时提高线宽、透明度和流动虚线强度。
- 起点和终点使用轻量圆点标识,让用户能快速判断连接的两端。
- 所有路径使用 `vectorEffect="non-scaling-stroke"`,保证缩放画布时线宽保持可读;动效继续服从 `prefers-reduced-motion`。
## 3. 状态与边界
| 状态 | 处理 | 目的 |
@@ -51,6 +61,8 @@
| 选中 | 沿用 active stroke | 与节点选中态保持一致 |
| 禁用/无内容 | 沿用 `faint` 和既有空态文案 | 不伪造生成成功或配置状态 |
| 展开编辑 | Modal 复用同一套主题 token | 保持放大编辑与节点内编辑一致 |
| 连接线默认 | 中性颜色的外层 + 清晰内芯 + 端点圆点 | 在低缩放和点阵背景下保持连贯可见 |
| 连接线悬停/选中 | 强调色、增强线宽、流动虚线 | 快速识别当前连接,不改变连接数据 |
本次不调整画布背景色、节点尺寸、面板定位、参考图尺寸选项、生成参数和资源同步逻辑。
@@ -60,6 +72,7 @@
- `web/src/components/canvas/canvas-node.tsx`:应用节点外壳和节点标题的主题表面。
- `web/src/components/canvas/canvas-node-content.tsx`:应用绘图空态子表面。
- `web/src/components/canvas/canvas-node-prompt-panel.tsx`:注入图片创作面板和放大编辑面板的主题变量。
- `web/src/components/canvas/canvas-connections.tsx`:渲染连接线的命中层、普通层级、选中流动线和端点标识。
- `web/src/styles/globals.css`:维护面板控件 hover 与尺寸调整手柄的唯一规则。
## 5. 验证与待回归
@@ -73,4 +86,5 @@
- 浏览器中分别切换浅色/深色主题,检查图片创作面板默认、悬停、选中和展开编辑状态。
- 在点阵、线稿和空白背景下检查节点边缘、空绘图态和“将使用默认模型与参数”的对比度。
- 本项目按规则未自动运行 TypeScript、构建或测试;提交前只做了静态 diff 检查。
- 在 25%、40% 和 100% 缩放下检查普通连接线、悬停/选中连接线及两个端点的可见度。
- 本项目按规则未自动运行 TypeScript、构建或测试;提交前只做了静态 diff 检查。

View File

@@ -8,6 +8,8 @@
- [画布节点可读性设计沉淀](design/canvas-node-visual-contrast.mdx):节点外壳、空态和图片创作面板在浅色/深色画布上的表面、边界、阴影与控件状态约束。
- [画布浮动控件设计沉淀](design/canvas-floating-controls.mdx):顶部操作区、底部 Dock 和右下角工作模式切换的浮动面板、定位与响应式约束。
## 本地协作文档(不随仓库分发)
- [beautifului 创作设计](beautifului-creation-design.md):本地设计参考,未纳入版本控制。

View File

@@ -65,24 +65,41 @@ export const ConnectionPath = React.memo(function ConnectionPath({
/>
{showVisual ? <path
d={pathD}
stroke={emphasized ? theme.accent.primary : theme.node.muted}
strokeWidth={emphasized ? 1.4 : 1}
stroke={theme.node.muted}
strokeWidth={emphasized ? 5 : 3.5}
vectorEffect="non-scaling-stroke"
strokeOpacity={emphasized ? 0.42 : 0.16}
strokeOpacity={emphasized ? 0.18 : 0.16}
fill="none"
strokeLinecap="round"
strokeLinejoin="round"
style={{ pointerEvents: "none" }}
/> : null}
{showVisual ? <path
d={pathD}
stroke={emphasized ? theme.accent.primary : theme.node.muted}
strokeWidth={emphasized ? 2.2 : 1.5}
vectorEffect="non-scaling-stroke"
strokeOpacity={emphasized ? 0.9 : 0.72}
fill="none"
strokeLinecap="round"
strokeLinejoin="round"
style={{ pointerEvents: "none" }}
/> : null}
{showVisual ? <>
<circle cx={startX} cy={startY} r={emphasized ? 3.5 : 2.5} fill={emphasized ? theme.accent.primary : theme.node.muted} fillOpacity={emphasized ? 0.9 : 0.72} vectorEffect="non-scaling-stroke" style={{ pointerEvents: "none" }} />
<circle cx={endX} cy={endY} r={emphasized ? 3.5 : 2.5} fill={emphasized ? theme.accent.primary : theme.node.muted} fillOpacity={emphasized ? 0.9 : 0.72} vectorEffect="non-scaling-stroke" style={{ pointerEvents: "none" }} />
</> : null}
{showVisual && emphasized ? <path
className="canvas-connection-flow"
d={pathD}
stroke={`url(#${gradientId})`}
strokeWidth="1.8"
strokeWidth="2.2"
vectorEffect="non-scaling-stroke"
strokeOpacity="0.72"
strokeOpacity="0.84"
strokeDasharray="18 26"
fill="none"
strokeLinecap="round"
strokeLinejoin="round"
style={{ pointerEvents: "none" }}
/> : null}
</g>

View File

@@ -6,6 +6,7 @@ import { Button, Dropdown, Modal, Tooltip } from "antd";
import { useWalletBalance } from "@/hooks/use-wallet-balance";
import { aceternityMotion } from "@/lib/aceternity-motion";
import { canvasDockStyle } from "@/lib/canvas/canvas-aceternity-style";
import type { CanvasContextSummary } from "@/lib/canvas/canvas-context-summary";
import type { CanvasShortDramaProgress } from "@/lib/canvas/canvas-short-drama";
import { canvasThemes } from "@/lib/canvas-theme";
@@ -15,8 +16,6 @@ import type { CanvasMediaPerformanceMode, CanvasWorkspaceMode } from "@/types/ca
type CanvasTopBarProps = {
title: string;
workspaceMode: CanvasWorkspaceMode;
onWorkspaceModeChange: (mode: CanvasWorkspaceMode) => void;
titleDraft: string;
isTitleEditing: boolean;
onTitleDraftChange: (value: string) => void;
@@ -47,8 +46,6 @@ type CanvasTopBarProps = {
export function CanvasTopBar({
title,
workspaceMode,
onWorkspaceModeChange,
titleDraft,
isTitleEditing,
onTitleDraftChange,
@@ -77,22 +74,14 @@ export function CanvasTopBar({
shortDramaGuide,
}: CanvasTopBarProps) {
const theme = canvasThemes[useThemeStore((state) => state.theme)];
const dockStyle = canvasDockStyle(theme, theme.node.text);
const user = useUserStore((state) => state.user);
const creditsEnabled = useUserStore((state) => state.features.creditsEnabled);
const { availableMicrocredits, refreshing } = useWalletBalance(user?.id, creditsEnabled);
const titleRef = useRef<HTMLDivElement>(null);
const [shortcutsOpen, setShortcutsOpen] = useState(false);
const [workspaceModeOpen, setWorkspaceModeOpen] = useState(false);
const handleWorkspaceModeOpenChange = (next: boolean) => {
// 模式开关下拉向下展开,会覆盖展开中的短剧流程条,打开时先收起流程条避免重叠。
if (next && shortDramaGuide && !shortDramaGuide.collapsed) shortDramaGuide.onToggle();
setWorkspaceModeOpen(next);
};
const handleShortDramaGuideToggle = () => {
// 展开流程条会与模式开关下拉在顶部中心重叠,展开前先关闭模式开关。
if (shortDramaGuide?.collapsed) setWorkspaceModeOpen(false);
shortDramaGuide?.onToggle();
};
@@ -112,7 +101,7 @@ export function CanvasTopBar({
return (
<>
<div className="canvas-topbar pointer-events-none absolute left-0 right-0 top-0 z-[var(--z-toolbar)] flex h-[var(--canvas-topbar-h)] items-center justify-between px-4 sm:px-5">
<div className="canvas-topbar-cluster canvas-topbar-project-cluster pointer-events-auto flex min-w-0 items-center gap-2">
<div className="canvas-topbar-cluster canvas-topbar-project-cluster pointer-events-auto flex min-w-0 items-center gap-2" style={dockStyle}>
<Dropdown
trigger={["click"]}
menu={{
@@ -141,7 +130,7 @@ export function CanvasTopBar({
],
}}
>
<button type="button" className="grid size-9 place-items-center rounded-full transition hover:bg-black/5 dark:hover:bg-white/10" style={{ color: theme.node.text }} aria-label="打开画布菜单">
<button type="button" className="canvas-topbar-action grid size-9 place-items-center rounded-full" style={{ color: theme.node.text }} aria-label="打开画布菜单">
<Menu className="size-5" />
</button>
</Dropdown>
@@ -168,7 +157,7 @@ export function CanvasTopBar({
{title}
</button>
<Tooltip title="重命名画布">
<button type="button" className="grid size-7 shrink-0 place-items-center rounded-md opacity-60 transition hover:bg-black/5 hover:opacity-100 focus-visible:outline-none focus-visible:ring-2 dark:hover:bg-white/10" style={{ color: theme.node.text }} onClick={onStartTitleEditing} aria-label="重命名画布">
<button type="button" className="canvas-topbar-action grid size-7 shrink-0 place-items-center rounded-md opacity-60 transition-opacity hover:opacity-100 focus-visible:outline-none focus-visible:ring-2" style={{ color: theme.node.text }} onClick={onStartTitleEditing} aria-label="重命名画布">
<Pencil className="size-3.5" />
</button>
</Tooltip>
@@ -191,10 +180,8 @@ export function CanvasTopBar({
</div>
</div>
<CanvasWorkspaceModeSwitch mode={workspaceMode} onChange={onWorkspaceModeChange} open={workspaceModeOpen} onOpenChange={handleWorkspaceModeOpenChange} />
<div className="canvas-topbar-cluster pointer-events-auto flex items-center gap-1.5">
<Button type="text" className="!hidden !h-10 !w-10 !min-w-10 !rounded-xl !p-0 lg:!inline-flex" style={{ color: theme.node.text }} icon={<Search className="size-4" />} onClick={onOpenSearch} aria-label="搜索画布节点" title="搜索画布节点" />
<div className="canvas-topbar-cluster pointer-events-auto flex items-center gap-1.5" style={dockStyle}>
<Button type="text" className="canvas-topbar-action !hidden !h-10 !w-10 !min-w-10 !rounded-xl !p-0 lg:!inline-flex" style={{ color: theme.node.text }} icon={<Search className="size-4" />} onClick={onOpenSearch} aria-label="搜索画布节点" title="搜索画布节点" />
<Dropdown
trigger={["click"]}
placement="bottomRight"
@@ -205,7 +192,7 @@ export function CanvasTopBar({
],
}}
>
<Button type="text" className="canvas-topbar-import-button !h-10 !rounded-xl !px-2.5 !font-medium" style={{ color: theme.node.text }} icon={<CloudDownload className="size-4" />} aria-label="导入第三方画布" title="导入第三方画布">
<Button type="text" className="canvas-topbar-action canvas-topbar-import-button !h-10 !rounded-xl !px-2.5 !font-medium" style={{ color: theme.node.text }} icon={<CloudDownload className="size-4" />} aria-label="导入第三方画布" title="导入第三方画布">
<span className="hidden lg:inline">导入第三方画布</span>
</Button>
</Dropdown>
@@ -222,13 +209,13 @@ export function CanvasTopBar({
],
}}
>
<Button type="text" className="!hidden !h-10 !w-10 !min-w-10 !rounded-xl !p-0 lg:!inline-flex" style={{ color: theme.node.text }} icon={<Gauge className="size-4" />} aria-label="媒体性能模式" title="媒体性能模式" />
<Button type="text" className="canvas-topbar-action !hidden !h-10 !w-10 !min-w-10 !rounded-xl !p-0 lg:!inline-flex" style={{ color: theme.node.text }} icon={<Gauge className="size-4" />} aria-label="媒体性能模式" title="媒体性能模式" />
</Dropdown>
{compactAgentStatus ? <CompactAgentStatus status={compactAgentStatus} onClick={onToggleAgent} /> : null}
{user && creditsEnabled ? (
<Link
to="/wallet"
className="inline-flex h-9 min-w-[5.5rem] items-center justify-center gap-1.5 rounded-lg px-2.5 text-xs font-medium tabular-nums transition hover:bg-black/5 dark:hover:bg-white/10"
className="canvas-topbar-action inline-flex h-9 min-w-[5.5rem] items-center justify-center gap-1.5 rounded-lg px-2.5 text-xs font-medium tabular-nums"
style={{ color: theme.node.text }}
title="查看积分明细"
>
@@ -239,7 +226,7 @@ export function CanvasTopBar({
<Tooltip title="进入专注模式(⇧⌘F)">
<Button
type="text"
className="!h-10 !w-10 !min-w-10 !rounded-xl !p-0"
className="canvas-topbar-action !h-10 !w-10 !min-w-10 !rounded-xl !p-0"
style={{ color: theme.node.text }}
icon={<Focus className="size-4" />}
onClick={onEnterFocusMode}
@@ -250,24 +237,26 @@ export function CanvasTopBar({
<Tooltip title={shortDramaGuide.collapsed ? "展开短剧流程" : "收起短剧流程"}>
<Button
type="text"
className="!h-10 !rounded-xl !px-2.5 !font-medium"
className="canvas-topbar-action !h-10 !rounded-xl !px-2.5 !font-medium"
style={{ color: theme.node.text, background: shortDramaGuide.collapsed ? undefined : theme.toolbar.activeBg }}
icon={<Clapperboard className="size-4" />}
onClick={handleShortDramaGuideToggle}
aria-label="短剧流程"
aria-pressed={!shortDramaGuide.collapsed}
>
<span className="tabular-nums">{shortDramaGuide.progress.completedCount}/5</span>
</Button>
</Tooltip>
) : null}
<Button type="text" className="!h-10 !w-10 !min-w-10 !rounded-xl !p-0" style={{ color: theme.node.text }} icon={<Share2 className="size-4" />} onClick={onShare} aria-label="分享画布" title="分享画布" />
<Button type="text" className="canvas-topbar-action !h-10 !w-10 !min-w-10 !rounded-xl !p-0" style={{ color: theme.node.text }} icon={<Share2 className="size-4" />} onClick={onShare} aria-label="分享画布" title="分享画布" />
<span className="h-6 w-px" style={{ background: theme.toolbar.border }} />
<Button
type="text"
className="!h-10 !rounded-xl !px-3 !font-medium"
className="canvas-topbar-action !h-10 !rounded-xl !px-3 !font-medium"
style={{ background: agentOpen ? theme.toolbar.activeBg : "transparent", color: theme.node.text }}
icon={<Bot className="size-4" />}
onClick={onToggleAgent}
aria-pressed={agentOpen}
>
Agent
</Button>
@@ -301,19 +290,20 @@ export function CanvasTopBar({
);
}
function CanvasWorkspaceModeSwitch({ mode, onChange, open, onOpenChange }: { mode: CanvasWorkspaceMode; onChange: (mode: CanvasWorkspaceMode) => void; open: boolean; onOpenChange: (open: boolean) => void }) {
export function CanvasWorkspaceModeSwitch({ mode, onChange }: { mode: CanvasWorkspaceMode; onChange: (mode: CanvasWorkspaceMode) => void }) {
const theme = canvasThemes[useThemeStore((state) => state.theme)];
const reducedMotion = useReducedMotion();
const simple = mode === "simple";
const rootRef = useRef<HTMLDivElement>(null);
const [open, setOpen] = useState(false);
useEffect(() => {
if (!open) return;
const closeOnOutsidePress = (event: PointerEvent) => {
if (event.target instanceof Node && !rootRef.current?.contains(event.target)) onOpenChange(false);
if (event.target instanceof Node && !rootRef.current?.contains(event.target)) setOpen(false);
};
const closeOnEscape = (event: KeyboardEvent) => {
if (event.key === "Escape") onOpenChange(false);
if (event.key === "Escape") setOpen(false);
};
document.addEventListener("pointerdown", closeOnOutsidePress);
document.addEventListener("keydown", closeOnEscape);
@@ -321,26 +311,26 @@ function CanvasWorkspaceModeSwitch({ mode, onChange, open, onOpenChange }: { mod
document.removeEventListener("pointerdown", closeOnOutsidePress);
document.removeEventListener("keydown", closeOnEscape);
};
}, [open, onOpenChange]);
}, [open]);
const selectMode = (nextMode: CanvasWorkspaceMode) => {
if (nextMode !== mode) onChange(nextMode);
onOpenChange(false);
setOpen(false);
};
return (
<div ref={rootRef} className="aceternity-mode-switch pointer-events-auto absolute left-1/2 top-2 z-[var(--dock-z-popover)] -translate-x-1/2">
<div ref={rootRef} className="aceternity-mode-switch pointer-events-auto relative z-[var(--dock-z-popover)]">
<motion.button
type="button"
whileHover={reducedMotion ? undefined : { y: -1 }}
whileTap={reducedMotion ? undefined : { scale: 0.97 }}
transition={aceternityMotion.spring.dock}
className="flex h-9 min-w-28 items-center gap-2 rounded-full px-2.5 text-left outline-none backdrop-blur-2xl focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2"
style={{ background: open ? theme.spatial.surface : theme.spatial.elevated, color: theme.node.text, boxShadow: "var(--workspace-overlay-shadow)", outlineColor: theme.accent.primary }}
className="canvas-mode-switch-trigger flex h-10 min-w-28 items-center gap-2 rounded-xl border px-2.5 text-left outline-none backdrop-blur-2xl focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2"
style={{ ...canvasDockStyle(theme, theme.node.text), background: open ? "var(--dock-command-active)" : "var(--dock-surface)", outlineColor: theme.accent.primary }}
aria-haspopup="listbox"
aria-expanded={open}
aria-label={`当前为${simple ? "简洁" : "专业"}模式,点击切换`}
onClick={() => onOpenChange(!open)}
onClick={() => setOpen((value) => !value)}
>
<span className="grid size-6 shrink-0 place-items-center rounded-full" style={{ background: theme.toolbar.itemHover, color: theme.accent.primary }}>
{simple ? <Sparkles className="size-3" /> : <Settings2 className="size-3" />}
@@ -351,18 +341,18 @@ function CanvasWorkspaceModeSwitch({ mode, onChange, open, onOpenChange }: { mod
</motion.span>
</motion.button>
<div className="absolute left-1/2 top-11 w-72 -translate-x-1/2" style={{ maxWidth: "calc(100vw - var(--space-8))" }}>
<div className="absolute bottom-11 right-0 w-72" style={{ maxWidth: "calc(100vw - var(--space-8))" }}>
<AnimatePresence>
{open ? (
<motion.div
role="listbox"
aria-label="选择画布工作模式"
initial={reducedMotion ? { opacity: 0 } : { opacity: 0, y: -8, scale: 0.92 }}
initial={reducedMotion ? { opacity: 0 } : { opacity: 0, y: 8, scale: 0.92 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={reducedMotion ? { opacity: 0 } : { opacity: 0, y: -6, scale: 0.95 }}
exit={reducedMotion ? { opacity: 0 } : { opacity: 0, y: 6, scale: 0.95 }}
transition={aceternityMotion.spring.panel}
className="aceternity-floating-panel w-full overflow-hidden rounded-[var(--r-lg)] p-1.5 backdrop-blur-2xl"
style={{ background: theme.spatial.elevated, color: theme.node.text, boxShadow: "var(--workspace-overlay-shadow)" }}
className="canvas-mode-switch-menu aceternity-floating-panel w-full overflow-hidden rounded-[var(--r-lg)] border p-1.5 backdrop-blur-2xl"
style={{ ...canvasDockStyle(theme, theme.node.text), background: "var(--dock-surface)" }}
>
<ModeOption active={simple} motionEnabled={!reducedMotion} icon={<Sparkles className="size-4" />} title="简洁模式" description="保留核心创作路径,降低参数密度" theme={theme} onClick={() => selectMode("simple")} />
<ModeOption active={!simple} motionEnabled={!reducedMotion} icon={<Settings2 className="size-4" />} title="专业模式" description="显示完整节点、导演台与生成控制" theme={theme} onClick={() => selectMode("professional")} />
@@ -384,7 +374,7 @@ function ModeOption({ active, motionEnabled, icon, title, description, theme, on
aria-selected={active}
whileTap={motionEnabled ? { scale: 0.98 } : undefined}
transition={aceternityMotion.spring.dock}
className="group flex min-h-14 w-full items-center gap-3 rounded-[var(--r-md)] px-3 py-2 text-left outline-none transition-colors hover:bg-black/5 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-0 dark:hover:bg-white/8"
className="canvas-mode-switch-option group flex min-h-14 w-full items-center gap-3 rounded-[var(--r-md)] px-3 py-2 text-left outline-none focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-0"
style={{ background: active ? theme.accent.primarySoft : "transparent", color: theme.node.text, outlineColor: theme.accent.primary }}
onClick={onClick}
>
@@ -414,7 +404,7 @@ function CompactAgentStatus({ status, onClick }: { status: { connected: boolean;
const label = status.connected ? "已连接到本地 Codex" : status.enabled ? status.activity || "连接中" : "正在连接本地 Codex";
const dotColor = status.connected ? "#22c55e" : status.enabled ? "#f59e0b" : theme.node.muted;
return (
<button type="button" className="flex h-10 items-center gap-2 rounded-xl px-3 text-sm font-medium transition hover:opacity-85" style={{ background: theme.toolbar.panel, color: theme.node.text, boxShadow: "0 10px 30px rgba(28,25,23,.10)" }} onClick={onClick} title="打开本地 Codex 面板">
<button type="button" className="canvas-topbar-action flex h-10 items-center gap-2 rounded-xl px-3 text-sm font-medium" style={{ background: "transparent", color: theme.node.text }} onClick={onClick} title="打开本地 Codex 面板">
<span className="size-2 rounded-full" style={{ background: dotColor }} />
<span className="max-w-[180px] truncate">{label}</span>
</button>

View File

@@ -73,7 +73,7 @@ import { batchSourceRestriction } from "@/lib/canvas/canvas-batch-connection";
import { deriveStoryboardPipelineProgress } from "@/lib/canvas/canvas-storyboard-progress";
import { CanvasAgentChangeToast, CanvasMergeStatusToast, CanvasUploadStatusToast } from "./canvas-project-feedback";
import { backendProviderConfig, getGenerationCount } from "@/lib/canvas/canvas-project-generation";
import { CanvasTopBar } from "./canvas-project-top-bar";
import { CanvasTopBar, CanvasWorkspaceModeSwitch } from "./canvas-project-top-bar";
import { LibTVImportDialog } from "./components/libtv-import-dialog";
import { TapNowImportDialog } from "./components/tapnow-import-dialog";
import { CanvasFocusModeBar } from "@/components/canvas/canvas-focus-mode-bar";
@@ -1759,8 +1759,6 @@ function InfiniteCanvasPage() {
{!focusMode ? (
<CanvasTopBar
title={currentProject?.title || "未命名画布"}
workspaceMode={workspaceMode}
onWorkspaceModeChange={setWorkspaceMode}
titleDraft={titleDraft}
isTitleEditing={titleEditing}
onTitleDraftChange={setTitleDraft}
@@ -1798,6 +1796,18 @@ function InfiniteCanvasPage() {
/>
) : null}
{!focusMode ? (
<div
data-canvas-no-zoom
className="pointer-events-none absolute bottom-[calc(var(--canvas-inset-y)+var(--space-16))] right-[var(--canvas-inset-x)] z-[var(--z-toolbar)] lg:bottom-[var(--canvas-inset-y)]"
onMouseDown={(event) => event.stopPropagation()}
onPointerDown={(event) => event.stopPropagation()}
onWheel={(event) => event.stopPropagation()}
>
<CanvasWorkspaceModeSwitch mode={workspaceMode} onChange={setWorkspaceMode} />
</div>
) : null}
<CanvasNodeSearchModal
open={nodeSearchOpen}
nodes={nodes}
@@ -2152,7 +2162,7 @@ function InfiniteCanvasPage() {
{!focusMode ? (
<div
data-canvas-no-zoom
className="absolute bottom-[calc(var(--canvas-inset-y)+var(--space-16))] left-4 z-[var(--z-panel)] flex items-end gap-2 lg:bottom-[var(--canvas-inset-y)]"
className="absolute bottom-[calc(var(--canvas-inset-y)+var(--space-16))] left-[var(--canvas-inset-x)] z-[var(--z-panel)] flex items-end gap-2 lg:bottom-[var(--canvas-inset-y)]"
onMouseDown={(event) => event.stopPropagation()}
onPointerDown={(event) => event.stopPropagation()}
onWheel={(event) => event.stopPropagation()}

View File

@@ -1394,12 +1394,46 @@
.canvas-topbar-cluster {
min-height: var(--space-10);
border: 0;
border: 1px solid var(--dock-border);
border-radius: var(--r-lg);
background: var(--canvas-topbar-surface);
background: var(--dock-surface);
padding: var(--space-half) var(--space-2);
box-shadow: var(--workspace-overlay-shadow);
backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturate));
box-shadow: var(--elevation-overlay);
backdrop-filter: blur(var(--dock-blur)) saturate(var(--glass-saturate));
}
/* 顶部操作区复用底部 FloatingDock 的命令状态,保持两组浮层在不同主题下有同一套反馈。 */
.canvas-topbar-action {
border-color: transparent;
background: transparent;
box-shadow: none;
transition:
background-color var(--motion-instant) ease-out,
color var(--motion-instant) ease-out,
opacity var(--motion-instant) ease-out;
}
.canvas-topbar-action:hover:not(:disabled) {
background: var(--dock-command-hover) !important;
}
.canvas-mode-switch-trigger {
border-color: var(--dock-border);
box-shadow: var(--elevation-overlay);
transition: background-color var(--motion-instant) ease-out, color var(--motion-instant) ease-out;
}
.canvas-mode-switch-trigger:hover:not(:disabled) {
background: var(--dock-command-hover) !important;
}
.canvas-mode-switch-menu {
border-color: var(--dock-border);
transform-origin: bottom right;
}
.canvas-mode-switch-option:hover:not([aria-selected="true"]) {
background: var(--dock-command-hover) !important;
}
.canvas-topbar-project-cluster {