feat(canvas): 画布视觉 - 优化浮动控件与连接线可读性
This commit is contained in:
64
docs/design/canvas-floating-controls.mdx
Normal file
64
docs/design/canvas-floating-controls.mdx
Normal 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、构建或测试;提交前只做了静态检查。
|
||||
@@ -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 检查。
|
||||
|
||||
@@ -8,6 +8,8 @@
|
||||
|
||||
- [画布节点可读性设计沉淀](design/canvas-node-visual-contrast.mdx):节点外壳、空态和图片创作面板在浅色/深色画布上的表面、边界、阴影与控件状态约束。
|
||||
|
||||
- [画布浮动控件设计沉淀](design/canvas-floating-controls.mdx):顶部操作区、底部 Dock 和右下角工作模式切换的浮动面板、定位与响应式约束。
|
||||
|
||||
## 本地协作文档(不随仓库分发)
|
||||
|
||||
- [beautifului 创作设计](beautifului-creation-design.md):本地设计参考,未纳入版本控制。
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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()}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user