From 34a207eb39cd44c1449f29f4687909e1c4af557c Mon Sep 17 00:00:00 2001 From: QAyong Date: Sat, 22 Aug 2026 03:08:15 +0800 Subject: [PATCH] =?UTF-8?q?feat(canvas):=20=E7=94=BB=E5=B8=83=E8=A7=86?= =?UTF-8?q?=E8=A7=89=20-=20=E4=BC=98=E5=8C=96=E6=B5=AE=E5=8A=A8=E6=8E=A7?= =?UTF-8?q?=E4=BB=B6=E4=B8=8E=E8=BF=9E=E6=8E=A5=E7=BA=BF=E5=8F=AF=E8=AF=BB?= =?UTF-8?q?=E6=80=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/design/canvas-floating-controls.mdx | 64 ++++++++++++++++ docs/design/canvas-node-visual-contrast.mdx | 20 ++++- docs/index.md | 2 + .../components/canvas/canvas-connections.tsx | 27 +++++-- .../pages/canvas/canvas-project-top-bar.tsx | 76 ++++++++----------- web/src/pages/canvas/project.tsx | 18 ++++- web/src/styles/globals.css | 42 +++++++++- 7 files changed, 190 insertions(+), 59 deletions(-) create mode 100644 docs/design/canvas-floating-controls.mdx diff --git a/docs/design/canvas-floating-controls.mdx b/docs/design/canvas-floating-controls.mdx new file mode 100644 index 000000000..c412dc9b6 --- /dev/null +++ b/docs/design/canvas-floating-controls.mdx @@ -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、构建或测试;提交前只做了静态检查。 diff --git a/docs/design/canvas-node-visual-contrast.mdx b/docs/design/canvas-node-visual-contrast.mdx index 7ae4261a5..4f440d010 100644 --- a/docs/design/canvas-node-visual-contrast.mdx +++ b/docs/design/canvas-node-visual-contrast.mdx @@ -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 检查。 \ No newline at end of file +- 在 25%、40% 和 100% 缩放下检查普通连接线、悬停/选中连接线及两个端点的可见度。 +- 本项目按规则未自动运行 TypeScript、构建或测试;提交前只做了静态 diff 检查。 diff --git a/docs/index.md b/docs/index.md index cabfad530..7486672b5 100644 --- a/docs/index.md +++ b/docs/index.md @@ -8,6 +8,8 @@ - [画布节点可读性设计沉淀](design/canvas-node-visual-contrast.mdx):节点外壳、空态和图片创作面板在浅色/深色画布上的表面、边界、阴影与控件状态约束。 +- [画布浮动控件设计沉淀](design/canvas-floating-controls.mdx):顶部操作区、底部 Dock 和右下角工作模式切换的浮动面板、定位与响应式约束。 + ## 本地协作文档(不随仓库分发) - [beautifului 创作设计](beautifului-creation-design.md):本地设计参考,未纳入版本控制。 diff --git a/web/src/components/canvas/canvas-connections.tsx b/web/src/components/canvas/canvas-connections.tsx index 6e065ef27..f674c25dc 100644 --- a/web/src/components/canvas/canvas-connections.tsx +++ b/web/src/components/canvas/canvas-connections.tsx @@ -65,24 +65,41 @@ export const ConnectionPath = React.memo(function ConnectionPath({ /> {showVisual ? : null} + {showVisual ? : null} + {showVisual ? <> + + + : null} {showVisual && emphasized ? : null} diff --git a/web/src/pages/canvas/canvas-project-top-bar.tsx b/web/src/pages/canvas/canvas-project-top-bar.tsx index 67a522f9c..220269af9 100644 --- a/web/src/pages/canvas/canvas-project-top-bar.tsx +++ b/web/src/pages/canvas/canvas-project-top-bar.tsx @@ -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(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 ( <>
-
+
- @@ -168,7 +157,7 @@ export function CanvasTopBar({ {title} - @@ -191,10 +180,8 @@ export function CanvasTopBar({
- - -
- @@ -222,13 +209,13 @@ export function CanvasTopBar({ ], }} > - ) : null} - @@ -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(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 ( -
+
onOpenChange(!open)} + onClick={() => setOpen((value) => !value)} > {simple ? : } @@ -351,18 +341,18 @@ function CanvasWorkspaceModeSwitch({ mode, onChange, open, onOpenChange }: { mod -
+
{open ? ( } title="简洁模式" description="保留核心创作路径,降低参数密度" theme={theme} onClick={() => selectMode("simple")} /> } 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 ( - diff --git a/web/src/pages/canvas/project.tsx b/web/src/pages/canvas/project.tsx index c7df57a1a..65d1eeb01 100644 --- a/web/src/pages/canvas/project.tsx +++ b/web/src/pages/canvas/project.tsx @@ -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 ? ( ) : null} + {!focusMode ? ( +
event.stopPropagation()} + onPointerDown={(event) => event.stopPropagation()} + onWheel={(event) => event.stopPropagation()} + > + +
+ ) : null} + event.stopPropagation()} onPointerDown={(event) => event.stopPropagation()} onWheel={(event) => event.stopPropagation()} diff --git a/web/src/styles/globals.css b/web/src/styles/globals.css index b629ecc9c..8297659d9 100644 --- a/web/src/styles/globals.css +++ b/web/src/styles/globals.css @@ -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 {