From 9e718fcf4a9931af5f0c7664c0d2f4c9389974ba Mon Sep 17 00:00:00 2001 From: vv-get-ti <142303727+vv-get-ti@users.noreply.github.com> Date: Mon, 31 Aug 2026 06:49:43 +0800 Subject: [PATCH] =?UTF-8?q?feat(canvas):=20=E8=87=AA=E7=94=B1=E7=94=BB?= =?UTF-8?q?=E5=B8=83=20-=20=E5=A2=9E=E5=BC=BA=E7=A9=BA=E7=99=BD=E8=B5=B7?= =?UTF-8?q?=E7=82=B9=E3=80=81=E5=A4=96=E8=A7=82=E8=AE=BE=E7=BD=AE=E4=B8=8E?= =?UTF-8?q?=E8=8A=82=E7=82=B9=E8=BF=9E=E6=8E=A5=E4=BD=93=E9=AA=8C=20(#356)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/content/docs/overview/features.mdx | 33 ++++ docs/content/docs/progress/pending-test.mdx | 28 +++ docs/design/canvas-node-visual-contrast.mdx | 25 ++- .../canvas/canvas-appearance-controls.tsx | 170 +++++++++++++++++ .../canvas/canvas-short-drama-entry.tsx | 57 ++++-- web/src/components/canvas/canvas-toolbar.tsx | 76 ++------ web/src/components/canvas/infinite-canvas.tsx | 20 +- .../canvas/use-canvas-create-commands.ts | 23 +++ web/src/lib/canvas-theme.ts | 8 +- web/src/lib/canvas/canvas-appearance.ts | 174 ++++++++++++++++++ .../canvas/canvas-connected-node-placement.ts | 13 ++ web/src/lib/canvas/canvas-starter.ts | 20 ++ web/src/pages/canvas/project.tsx | 72 +++++++- web/src/pages/canvas/shared.tsx | 27 ++- .../use-canvas-connection-controller.ts | 5 +- web/src/pages/canvas/use-canvas-history.ts | 16 +- .../canvas/use-canvas-project-lifecycle.ts | 22 ++- web/src/stores/canvas/use-canvas-store.ts | 12 +- web/test/canvas-appearance.test.ts | 155 ++++++++++++++++ .../canvas-connected-node-placement.test.ts | 13 ++ web/test/canvas-starter.test.ts | 21 +++ web/test/canvas-visual-contrast.test.ts | 48 +++++ 22 files changed, 925 insertions(+), 113 deletions(-) create mode 100644 web/src/components/canvas/canvas-appearance-controls.tsx create mode 100644 web/src/components/canvas/use-canvas-create-commands.ts create mode 100644 web/src/lib/canvas/canvas-appearance.ts create mode 100644 web/src/lib/canvas/canvas-connected-node-placement.ts create mode 100644 web/src/lib/canvas/canvas-starter.ts create mode 100644 web/test/canvas-appearance.test.ts create mode 100644 web/test/canvas-connected-node-placement.test.ts create mode 100644 web/test/canvas-starter.test.ts create mode 100644 web/test/canvas-visual-contrast.test.ts diff --git a/docs/content/docs/overview/features.mdx b/docs/content/docs/overview/features.mdx index c1c2e7f08..1c1df30b0 100644 --- a/docs/content/docs/overview/features.mdx +++ b/docs/content/docs/overview/features.mdx @@ -75,6 +75,39 @@ description: 影策当前已实现的核心工作台能力与使用入口 - `backend/internal/service/eagle.go`:Eagle 响应解析和集合字段归一化。 - `web/src/lib/plugins/builtin/eagle.ts`:外部素材映射、预览和引用处理。 +## 自由空白画布起点 + +新建且没有节点的自由画布会显示“从哪里开始”引导。除短剧流水线和 Agent 生成外,用户可以选择“自由空白画布”,在不预设工作流的情况下开始创作。 + +进入自由空白画布后,画布中央保留一个带加号的起始框。点击加号会打开与底部工具栏相同的添加菜单,可新建文本、绘图、分镜、背板、文件夹、图片、视频、音频和工作流,也可上传文件或打开素材库。插件提供的节点继续由同一命令注册表控制,不维护第二份素材类型清单。 + +自由起点选择按画布持久化;画布仍为空时刷新页面不会重新回到起始引导。中央起始框只负责提供入口,不创建占位节点。 + +### 相关实现 + +- `web/src/components/canvas/canvas-short-drama-entry.tsx`:起始引导、自由空白起始框和中央加号。 +- `web/src/components/canvas/use-canvas-create-commands.ts`:工具栏与空白起点共用的添加命令解析。 +- `web/src/lib/canvas/canvas-starter.ts`:空画布起点状态决策。 +- `web/src/stores/canvas/use-canvas-store.ts`:按画布保存自由起点选择。 + +## 画布外观 + +底部工具栏的“画布外观”面板支持浅色、深色、自定义三种主题,以及点阵、线稿、空白三种网格。浅色画布使用比白色节点更沉的中性雾灰背景,深色画布使用比原深色节点更亮的中性灰背景,让画布底层与内容表面保持稳定区分,同时避免蓝灰背景与黑色工具浮层产生色温割裂。 + +自定义主题会继承用户进入前的浅色或深色基础界面,并允许在“浅色界面”和“深色界面”之间切换。界面样式复用原项目整套节点、工具浮层、文字、边框和交互状态,不开放这些元素的任意改色;切换界面样式不会覆盖已经选择的画布背景和网格颜色。 + +自定义主题还允许调整画布背景色、背景明亮度、网格颜色和网格强度。颜色输入支持三位或六位 HEX,可带或不带 `#`;形成合法色码后实时写入当前画布,编辑过程中不会被父级状态覆盖,关闭外观面板也不会撤销。选择新的画布背景色时明亮度自动归零,确保画布首先精确显示输入色;之后仍可沿 OKLCH 亮度轴单独调整。不提供背景透明度,避免画布底色与底层颜色混合后产生偏色。“网格强度”使用 `0%` 最淡、`100%` 最明显的方向,避免“透明度”命名歧义。 + +自定义外观可以应用到当前画布,也可以保存为当前浏览器、当前账号 scope 下的新建画布默认值。当前画布外观沿用画布项目的既有持久化和同步流程;浏览器默认值不跨设备同步。 + +### 相关实现 + +- `web/src/lib/canvas-theme.ts`:浅色、深色画布固定背景与原有节点、浮层 token。 +- `web/src/lib/canvas/canvas-appearance.ts`:自定义外观归一化、解析和本机账号默认值。 +- `web/src/components/canvas/canvas-toolbar.tsx`:画布外观面板入口。 +- `web/src/components/canvas/canvas-appearance-controls.tsx`:主题、颜色、明亮度和网格控件。 +- `web/src/components/canvas/infinite-canvas.tsx`:应用主题背景并保持网格渲染参数。 + ## 画布节点自动整理 画布底部的“自动整理节点”按钮用于快速改善节点排布,适合处理节点散落、间距不一致或流程方向不清晰的画布。 diff --git a/docs/content/docs/progress/pending-test.mdx b/docs/content/docs/progress/pending-test.mdx index 8967943d3..fbf8c6733 100644 --- a/docs/content/docs/progress/pending-test.mdx +++ b/docs/content/docs/progress/pending-test.mdx @@ -14,6 +14,34 @@ description: 影策已实现功能的手工验证清单与未确认边界 - [ ] 在存在图片、视频和文本长任务时发送 SIGTERM,确认 readiness 立即返回 503、新任务被拒绝、已领取任务在宽限期内完成,超时后租约可由重启实例恢复。 - [ ] 构建带版本参数的前后端镜像,确认 `/api/system/version`、管理后台版本、OCI Commit 和 Release Tag 一致。 +## 自由空白画布与画布外观 + +- [x] 新建画布引导新增“自由空白画布”主入口;进入后显示中央空白起始框和加号,不创建占位节点。 +- [x] 中央加号复用底部工具栏的添加命令,当前浏览器已确认文本、绘图、分镜、背板、文件夹、图片、视频、导演台、音频、工作流、上传文件和素材库入口完整显示。 +- [x] 自由起点按画布持久化,当前浏览器刷新后仍停留在自由空白起始框;浅色、深色两种主题已完成常规桌面尺寸验收。 +- [x] 浅色画布固定为 `#EDEEEE`,深色画布固定为中性灰 `#262626`;原项目的节点、起始框、工具浮层、边框和阴影颜色保持不变。 +- [x] 画布外观增加“自定义”主题,可调整背景颜色、背景明亮度、网格颜色和网格强度;不开放节点、工具浮层、边框和阴影任意改色。 +- [x] 自定义主题增加“浅色界面/深色界面”,复用原项目对应的节点和工具浮层样式;切换界面样式不覆盖自定义画布和网格颜色。 +- [x] 原“网格透明度”改名为“网格强度”,保持 `0%` 最淡、`100%` 最明显。 +- [x] 颜色选择器与合法的 `#RRGGBB` 输入实时写入当前画布;点击面板外只关闭面板,不撤销刚才的颜色。 +- [x] HEX 输入支持三位/六位、可带或不带 `#`;输入编辑不会被父级状态覆盖,选择新背景色时明亮度归零以精确显示输入色。 +- [x] 删除背景透明度控件并忽略旧存储字段;背景明亮度改为沿 OKLCH 亮度轴调整,`0%` 精确保留输入色码。 +- [x] 自定义主题继承进入前的浅色或深色基础界面;同一基础主题可以恢复上次自定义配置,跨深浅主题则重新继承。 +- [x] 第一次进入或重置自定义主题时,明亮度默认为 `0%`、网格颜色默认为 `#B6B3B3`、网格强度默认为 `70%`;已有用户保存值不被覆盖。 +- [x] 当前画布外观随画布项目保存;“保存为默认”写入当前浏览器并按账号 scope 隔离,不跨设备同步。 +- [x] 深色点阵和线稿网格同步调整为 `rgba(205,205,205,0.28)`,浅色网格与网格图层渲染透明度保持既有值,并有专项回归断言。 +- [x] 自由空白画布与外观专项自动化测试通过,前端生产构建通过。 +- [ ] 在用户当前登录的 Edge 中验收颜色选择器、十六进制输入、明亮度调节、深浅主题继承和本机账号默认值。 +- [ ] 在窄屏和触屏尺寸检查三张起始卡、中央起始框、加号菜单的换行、滚动与点击目标;确认菜单不遮挡底部工具栏。 +- [ ] 由产品验收浅色/深色固定底色,以及原节点和工具浮层恢复后的最终视觉观感。 +- [ ] 从自由起点分别新建每种内置节点和插件节点,并从上传、素材库各导入一次素材,确认第一个节点创建后起始框消失且刷新后节点数据不丢失。 + +## 画布拖线创建节点落点 + +- [x] 从普通节点连接端拖到空白处创建节点时,松手点改为新节点靠近来源一侧的边缘中点;单击快捷创建和批量连接仍保留原布局。位置计算与批量连接专项测试、TypeScript 检查和前端生产构建通过。 +- [ ] 分别从节点右侧输出端和左侧输入端拖线创建图片、视频、文本与音频节点,确认新节点向松手点外侧展开,连线落在边缘中点且不会回头遮挡拖线区域。 +- [ ] 单击连接加号创建节点,确认仍自动放在相邻位置并保留 96px 间距;使用批量连接创建节点,确认原有落点和连接关系不变。 + ## 素材库网格密度 - [x] 素材库桌面端缩小卡片并改为每行 6 张;新增素材占首格,默认每页 35 项,可切换 70/105 项,首屏正好组成 6×6 完整网格;中窄屏按 4/3/1 列收敛,前端生产构建通过。 diff --git a/docs/design/canvas-node-visual-contrast.mdx b/docs/design/canvas-node-visual-contrast.mdx index 4f440d010..1a0d340a5 100644 --- a/docs/design/canvas-node-visual-contrast.mdx +++ b/docs/design/canvas-node-visual-contrast.mdx @@ -2,12 +2,13 @@ > 范围:无限画布中的节点外壳、节点内容空态、节点标题、图片创作面板和节点间连接线。 > 目标:节点和连接在浅色、深色,以及点阵/线稿/空白画布背景上都能被快速识别和操作。 -> 状态:已实现首版,浏览器回归待确认。 +> 状态:已实现并完成常规桌面尺寸下的浅色、深色浏览器回归。 ## 1. 问题定义 -画布节点可能直接落在白色或黑色背景上。只依赖全局卡片表面会造成两个问题: +画布节点可能直接落在白色或黑色背景上。只依赖全局卡片表面会造成四个问题: +- 浅色画布与白色节点没有表面明度差,深色画布与节点的明度差也不足。 - 节点边界消失,空态内容看起来像画布的一部分。 - 图片创作面板的“将使用默认模型与参数”提示、输入区和操作控件缺少稳定的表面层级。 - 节点间普通连接线使用过低的透明度和线宽,在低缩放比例或点阵背景上难以追踪。 @@ -18,7 +19,9 @@ ### 2.1 节点使用画布主题 token -`canvasThemes`(画布主题 token)中的 `node` 语义负责节点自身的可读性: +`canvasThemes`(画布主题 token)中的 `canvas` 与 `node` 语义共同负责画布层级: + +- `canvas.background`:创作空间的底层表面,与节点主体保持稳定明度差。 - `fill`:节点主体表面。 - `panel`:节点内部面板和空态子表面。 @@ -26,11 +29,13 @@ - `shadow` / `hoverShadow`:静止与悬停时的空间层级。 - `text` / `muted` / `faint`:正文、辅助说明和占位内容。 -浅色和深色主题必须成对提供这些 token。节点不能回退到全局 `--card-surface`,因为它不知道当前画布背景和节点层级。 +浅色和深色主题必须成对提供这些 token。节点不能回退到全局 `--card-surface`,因为它不知道当前画布背景和节点层级。本轮调整固定的 `canvas.background`,并单独增强深色点阵与线稿网格;节点、浮层、边框、阴影和网格图层渲染透明度保持既有值。 + +深色画布底层使用比原节点和浮层更亮的中性灰,使原有深色工具表面作为更深一层自然显现,避免蓝灰背景与中性黑工具框的色温割裂;浅色画布底层使用比白色节点更沉的中性雾灰。该方案不通过整体提亮工具表面制造层级。自定义主题只调整画布底层和网格,允许设置背景色、明亮度、网格颜色与强度以及基础浅色/深色界面;节点、工具浮层、文字、边框和阴影继续使用原项目主题 token。 ### 2.2 节点外壳与空态 -`CanvasNode`(画布节点外壳)使用 `node.fill`、`node.edge` 和主题阴影。选中状态继续使用现有 active stroke,边界只用于提供识别度,不作为装饰性竖线或强调色。 +`CanvasNode`(画布节点外壳)继续使用原项目的 `node.fill` 和主题阴影。普通状态保留透明的固定 1px 边界占位,不改变 Note 与其他节点的既有外观;选中状态继续沿用原有 active stroke。 `CanvasNodeContent`(节点内容渲染)的绘图空态在节点内部增加一个轻量子表面和图标边界,使“打开绘图”在白色和黑色背景下都能被识别;真实媒体内容不增加额外遮罩,不改变原始比例。 @@ -64,7 +69,7 @@ | 连接线默认 | 中性颜色的外层 + 清晰内芯 + 端点圆点 | 在低缩放和点阵背景下保持连贯可见 | | 连接线悬停/选中 | 强调色、增强线宽、流动虚线 | 快速识别当前连接,不改变连接数据 | -本次不调整画布背景色、节点尺寸、面板定位、参考图尺寸选项、生成参数和资源同步逻辑。 +本次不调整节点尺寸、面板定位、参考图尺寸选项、生成参数和资源同步逻辑。 ## 4. 实现地图 @@ -80,11 +85,13 @@ 已完成: - `git diff --check` 通过。 -- Impeccable 受限扫描未发现本次新增的反模式;扫描到的渐变文字、宽高过渡和既有布局规则位于其他历史区域。 +- `bun test test/canvas-starter.test.ts test/canvas-visual-contrast.test.ts` 通过(8 项)。 +- `bun run build` 通过,包含 TypeScript 检查和 Vite 生产构建;只有既有的大分块体积提示。 +- 本地前端与后端入口可用;已登录浏览器确认浅色背景 `#EDEEEE`、深色背景 `#262626` 正常生效,原节点与工具浮层颜色保持不变;自定义主题支持背景色与明亮度、网格颜色与强度以及基础浅色/深色界面,深色点阵和线稿网格使用 `rgba(205,205,205,0.28)`。 待确认: -- 浏览器中分别切换浅色/深色主题,检查图片创作面板默认、悬停、选中和展开编辑状态。 +- 在真实媒体节点中分别检查图片创作面板默认、悬停、选中和展开编辑状态。 - 在点阵、线稿和空白背景下检查节点边缘、空绘图态和“将使用默认模型与参数”的对比度。 - 在 25%、40% 和 100% 缩放下检查普通连接线、悬停/选中连接线及两个端点的可见度。 -- 本项目按规则未自动运行 TypeScript、构建或测试;提交前只做了静态 diff 检查。 +- 在窄屏和触屏尺寸补充检查节点、起始框与添加菜单的布局边界。 diff --git a/web/src/components/canvas/canvas-appearance-controls.tsx b/web/src/components/canvas/canvas-appearance-controls.tsx new file mode 100644 index 000000000..c4faa28ad --- /dev/null +++ b/web/src/components/canvas/canvas-appearance-controls.tsx @@ -0,0 +1,170 @@ +import { useEffect, useState, type ReactNode } from "react"; +import { Button, ColorPicker, Input, Segmented, Slider } from "antd"; +import { CircleDot, Grid2x2, Moon, Paintbrush, RotateCcw, Save, Square, Sun } from "lucide-react"; + +import { + canvasAppearanceForTheme, + customCanvasAppearanceFromTheme, + enterCustomCanvasAppearance, + normalizeHexColor, + type CanvasAppearance, + type CanvasCustomAppearance, +} from "@/lib/canvas/canvas-appearance"; +import type { CanvasBackgroundMode, CanvasColorTheme, CanvasTheme } from "@/lib/canvas-theme"; + +const LIGHT_PRESETS = ["#EDEEEE", "#F3DCE5", "#EEE5D8", "#DFE9E6"]; +const DARK_PRESETS = ["#262626", "#30272B", "#302D26", "#25302E"]; + +export function CanvasAppearanceControls({ + appearance, + backgroundMode, + colorTheme, + theme, + onAppearanceChange, + onSaveAppearanceDefault, + onBackgroundModeChange, +}: { + appearance: CanvasAppearance; + backgroundMode: CanvasBackgroundMode; + colorTheme: CanvasColorTheme; + theme: CanvasTheme; + onAppearanceChange: (appearance: CanvasAppearance) => void; + onSaveAppearanceDefault: (appearance: CanvasAppearance) => void; + onBackgroundModeChange: (mode: CanvasBackgroundMode) => void; +}) { + const [draft, setDraft] = useState(appearance); + + useEffect(() => setDraft(appearance), [appearance]); + + const selectFixedTheme = (target: CanvasColorTheme) => { + const next = canvasAppearanceForTheme(target, draft); + setDraft(next); + onAppearanceChange(next); + }; + const selectCustomTheme = () => { + const next = enterCustomCanvasAppearance(draft, colorTheme); + setDraft(next); + onAppearanceChange(next); + }; + const updateCustom = (patch: Partial) => { + const current = draft.mode === "custom" && draft.custom + ? draft + : enterCustomCanvasAppearance(draft, colorTheme); + const next: CanvasAppearance = { mode: "custom", custom: { ...current.custom!, ...patch } }; + setDraft(next); + onAppearanceChange(next); + }; + const resetCustom = () => { + const baseTheme = draft.custom?.baseTheme || colorTheme; + const next = customCanvasAppearanceFromTheme(baseTheme); + setDraft(next); + onAppearanceChange(next); + }; + const saveAsDefault = () => { + onAppearanceChange(draft); + onSaveAppearanceDefault(draft); + }; + const presets = draft.custom?.baseTheme === "dark" ? DARK_PRESETS : LIGHT_PRESETS; + + return ( + <> +
主题模式
+
+ selectFixedTheme("light")}> + selectFixedTheme("dark")}> + +
+ + {draft.mode === "custom" && draft.custom ? ( +
+
+ 背景预设 + {draft.custom.baseTheme === "dark" ? "深色" : "浅色"}界面 +
+
+ {presets.map((color) => ( +
+ updateCustom({ baseTheme: value as CanvasColorTheme })} + options={[ + { value: "light", label: 浅色界面 }, + { value: "dark", label: 深色界面 }, + ]} + /> + updateCustom({ backgroundColor, backgroundBrightness: 0 })} /> + updateCustom({ backgroundBrightness })} /> + updateCustom({ gridColor })} /> + updateCustom({ gridOpacity })} /> + + +
+ ) : null} + +
空间网格
+ onBackgroundModeChange(value as CanvasBackgroundMode)} + options={[ + { value: "dots", label: 点 }, + { value: "lines", label: 线 }, + { value: "blank", label: 空白 }, + ]} + /> + + ); +} + +function ThemeButton({ active, label, theme, onClick, children }: { active: boolean; label: string; theme: CanvasTheme; onClick: () => void; children: ReactNode }) { + return ( + + ); +} + +function ColorField({ label, value, theme, onChange }: { label: string; value: string; theme: CanvasTheme; onChange: (value: string) => void }) { + const [textValue, setTextValue] = useState(value); + const [editing, setEditing] = useState(false); + useEffect(() => { + if (!editing) setTextValue(value); + }, [editing, value]); + const commit = () => { + const normalized = normalizeHexColor(textValue); + setEditing(false); + if (normalized) { + setTextValue(normalized); + onChange(normalized); + } else { + setTextValue(value); + } + }; + return ( + + ); +} + +function SliderField({ label, value, min, max, suffix, onChange }: { label: string; value: number; min: number; max: number; suffix: string; onChange: (value: number) => void }) { + return ( +
+ {label} + + {value > 0 && min < 0 ? "+" : ""}{value}{suffix} +
+ ); +} diff --git a/web/src/components/canvas/canvas-short-drama-entry.tsx b/web/src/components/canvas/canvas-short-drama-entry.tsx index 166c45033..11a7cb053 100644 --- a/web/src/components/canvas/canvas-short-drama-entry.tsx +++ b/web/src/components/canvas/canvas-short-drama-entry.tsx @@ -1,8 +1,9 @@ -import { Fragment, type CSSProperties, type ReactNode } from "react"; -import { Dropdown } from "antd"; +import { Fragment, useState, type CSSProperties, type ReactNode } from "react"; +import { Dropdown, Popover } from "antd"; import { AlignLeft, ArrowRight, Bot, Check, ChevronDown, ChevronUp, Clapperboard, FolderKanban, Images, MoreHorizontal, Palette, Pencil, Plus, Sparkles, Type, Upload, X } from "lucide-react"; import { canvasThemes } from "@/lib/canvas-theme"; +import { CanvasCreateMenu, type CanvasCreateCommand } from "@/components/canvas/canvas-create-menu"; import type { CanvasShortDramaProgress, CanvasShortDramaStepId } from "@/lib/canvas/canvas-short-drama"; import { useThemeStore } from "@/stores/use-theme-store"; import type { CanvasNodeData } from "@/types/canvas"; @@ -23,9 +24,10 @@ export function CanvasLinkedProjectEmptyState({ projectName, hasChapter, onAddFi ); } -export function CanvasShortDramaEmptyState({ onCreatePipeline, onOpenAgent, onUpload, onAddText, onAddScript }: { +export function CanvasShortDramaEmptyState({ onCreatePipeline, onOpenAgent, onStartFreeform, onUpload, onAddText, onAddScript }: { onCreatePipeline: () => void; onOpenAgent: () => void; + onStartFreeform: () => void; onUpload: () => void; onAddText: () => void; onAddScript: () => void; @@ -39,7 +41,7 @@ export function CanvasShortDramaEmptyState({ onCreatePipeline, onOpenAgent, onUp

从哪里开始?

选择一条主路径,之后仍可随时切换。

-
+
} title="自己创作" @@ -60,6 +62,16 @@ export function CanvasShortDramaEmptyState({ onCreatePipeline, onOpenAgent, onUp focusStyle={focusStyle} onClick={onOpenAgent} /> + } + title="自由空白画布" + description="不预设流程,自由添加文本、图片、音频和视频。" + action="从空白画布开始" + accent={theme.node.muted} + theme={theme} + focusStyle={focusStyle} + onClick={onStartFreeform} + />
void; onAddText: () => void }) { +export function CanvasFreeformEmptyState({ commands }: { commands: CanvasCreateCommand[] }) { const theme = canvasThemes[useThemeStore((state) => state.theme)]; + const [createOpen, setCreateOpen] = useState(false); + const createCommands = commands.map((command) => ({ + ...command, + onClick: () => { + setCreateOpen(false); + command.onClick(); + }, + })); return (
-
-

从空白画布开始

添加文本或导入已有素材。

-
- - -
+
+

自由空白画布

+

不预设流程,从任意一种素材开始创作。

+ event.stopPropagation()}>
} + > + + +

点击 + 添加文本、图片、视频、音频或导入素材

); diff --git a/web/src/components/canvas/canvas-toolbar.tsx b/web/src/components/canvas/canvas-toolbar.tsx index 749d217f6..61cf1da77 100644 --- a/web/src/components/canvas/canvas-toolbar.tsx +++ b/web/src/components/canvas/canvas-toolbar.tsx @@ -1,19 +1,20 @@ import { AnimatePresence, motion } from "motion/react"; import { useEffect, useRef, useState, type MouseEvent as ReactMouseEvent, type ReactNode } from "react"; -import { Segmented, Switch } from "antd"; -import { CircleDot, Grid2x2, Moon, Palette, Sun, Square, Info } from "lucide-react"; +import { Switch } from "antd"; +import { Palette, Info } from "lucide-react"; -import { AnimatedThemeToggler } from "@/components/ui/animated-theme-toggler"; import { FloatingDock } from "@/components/ui/aceternity/floating-dock"; import { SpotlightSurface } from "@/components/ui/aceternity/spotlight-surface"; +import { CanvasAppearanceControls } from "@/components/canvas/canvas-appearance-controls"; import { CanvasCreateMenu, type CanvasCreateCommand } from "@/components/canvas/canvas-create-menu"; +import { useCanvasCreateCommands } from "@/components/canvas/use-canvas-create-commands"; import { ToolbarSettingsModal } from "@/components/canvas/toolbars/toolbar-settings-modal"; import { aceternityMotion } from "@/lib/aceternity-motion"; import { canvasDockStyle } from "@/lib/canvas/canvas-aceternity-style"; -import { canvasThemes, type CanvasBackgroundMode, type CanvasColorTheme, type CanvasTheme } from "@/lib/canvas-theme"; -import { defaultToolbarPrefs, readToolbarPrefs, resolveAddNodeMenuCommands, resolveToolbarEntries, type AddNodeMenuCommand, type ToolContext, type ToolbarHandlers, type ToolbarPrefs } from "@/lib/canvas/tool-registry"; +import type { CanvasAppearance } from "@/lib/canvas/canvas-appearance"; +import { canvasThemes, type CanvasBackgroundMode, type CanvasTheme } from "@/lib/canvas-theme"; +import { defaultToolbarPrefs, readToolbarPrefs, resolveToolbarEntries, type ToolContext, type ToolbarHandlers, type ToolbarPrefs } from "@/lib/canvas/tool-registry"; import { useThemeStore } from "@/stores/use-theme-store"; -import { usePluginStore } from "@/stores/use-plugin-store"; import type { CanvasNodeTypeId, CanvasToolMode, CanvasWorkspaceMode } from "@/types/canvas"; export function CanvasToolbar({ @@ -24,6 +25,7 @@ export function CanvasToolbar({ isProjectLinked, canUndo, canRedo, + appearance, backgroundMode, showImageInfo, onAddImage, @@ -44,6 +46,8 @@ export function CanvasToolbar({ onDelete, onClear, onDeselect, + onAppearanceChange, + onSaveAppearanceDefault, onBackgroundModeChange, onShowImageInfoChange, onOpenMyAssets, @@ -56,6 +60,7 @@ export function CanvasToolbar({ isProjectLinked: boolean; canUndo: boolean; canRedo: boolean; + appearance: CanvasAppearance; backgroundMode: CanvasBackgroundMode; showImageInfo: boolean; onAddImage: () => void; @@ -76,6 +81,8 @@ export function CanvasToolbar({ onDelete: () => void; onClear: () => void; onDeselect: () => void; + onAppearanceChange: (appearance: CanvasAppearance) => void; + onSaveAppearanceDefault: (appearance: CanvasAppearance) => void; onBackgroundModeChange: (mode: CanvasBackgroundMode) => void; onShowImageInfoChange: (show: boolean) => void; onOpenMyAssets: () => void; @@ -84,9 +91,6 @@ export function CanvasToolbar({ const rootRef = useRef(null); const dockRef = useRef(null); const colorTheme = useThemeStore((state) => state.theme); - const installations = usePluginStore((state) => state.installations); - const pluginStates = usePluginStore((state) => state.pluginStates); - const setTheme = useThemeStore((state) => state.setTheme); const theme = canvasThemes[colorTheme]; const [addOpen, setAddOpen] = useState(false); const [appearanceOpen, setAppearanceOpen] = useState(false); @@ -111,6 +115,8 @@ export function CanvasToolbar({ const closeFloatingPanels = (event: PointerEvent) => { const target = event.target instanceof Node ? event.target : null; if (target && rootRef.current?.contains(target)) return; + const element = event.target instanceof Element ? event.target : null; + if (element?.closest(".ant-color-picker,.ant-popover")) return; setAddOpen(false); setAppearanceOpen(false); }; @@ -174,21 +180,10 @@ export function CanvasToolbar({ handlers, }; - const enabledPluginIds = new Set(installations.filter((item) => pluginStates[item.manifest.id]?.effectiveEnabled ?? item.enabled).map((item) => item.manifest.id)); - const items = resolveToolbarEntries("main", ctx, prefs ?? defaultToolbarPrefs("main")); - // 解析添加节点菜单命令——onClick 绑定到 runAddAction 以在执行后关闭面板 - const addNodeCommands = resolveAddNodeMenuCommands({ ...ctx, enabledPluginIds }); - const toCommand = (cmd: AddNodeMenuCommand): CanvasCreateCommand => ({ - id: cmd.id, - label: cmd.label, - icon: cmd.icon, - badge: cmd.badge, - section: cmd.section, - onClick: () => runAddAction(() => cmd.run(ctx)), - }); - const createCommands = addNodeCommands.map(toCommand); + // 中央空白起点与主工具栏共用同一份命令解析,避免素材类型和插件节点逐渐分叉。 + const createCommands = useCanvasCreateCommands(ctx, runAddAction); return (
@@ -206,25 +201,10 @@ export function CanvasToolbar({ {appearanceOpen ? ( - + event.stopPropagation()}> } title="画布外观" subtitle="调整整个创作空间" theme={theme} /> -
主题模式
-
- 浅色 - 深色 -
-
空间网格
- onBackgroundModeChange(value as CanvasBackgroundMode)} - options={[ - { value: "dots", label: 点 }, - { value: "lines", label: 线 }, - { value: "blank", label: 空白 }, - ]} - /> +
图片信息 @@ -262,24 +242,6 @@ function PanelHeading({ icon, title, subtitle, theme }: { icon: ReactNode; title ); } -function CanvasThemeButton({ colorTheme, targetTheme, onThemeChange, children }: { colorTheme: CanvasColorTheme; targetTheme: CanvasColorTheme; onThemeChange: (theme: CanvasColorTheme) => void; children: ReactNode }) { - const theme = canvasThemes[colorTheme]; - const active = colorTheme === targetTheme; - return ( - - {children} - - ); -} - function getPanelX(dock: HTMLDivElement | null, target: HTMLElement) { if (!dock) return 0; const rootBox = dock.parentElement?.getBoundingClientRect() || dock.getBoundingClientRect(); diff --git a/web/src/components/canvas/infinite-canvas.tsx b/web/src/components/canvas/infinite-canvas.tsx index 60f30de1c..c6bce813c 100644 --- a/web/src/components/canvas/infinite-canvas.tsx +++ b/web/src/components/canvas/infinite-canvas.tsx @@ -1,6 +1,7 @@ import React, { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react"; -import { canvasThemes, type CanvasBackgroundMode } from "@/lib/canvas-theme"; +import { resolveCanvasAppearance, resolveCanvasGridColor, type CanvasAppearance } from "@/lib/canvas/canvas-appearance"; +import type { CanvasBackgroundMode } from "@/lib/canvas-theme"; import { applyCanvasLiveViewport, canvasDotGridPx, canvasDotPx, subscribeCanvasViewportPreview } from "@/lib/canvas/canvas-live-viewport"; import { useThemeStore } from "@/stores/use-theme-store"; import type { ViewportTransform } from "@/types/canvas"; @@ -8,6 +9,7 @@ import type { ViewportTransform } from "@/types/canvas"; type InfiniteCanvasProps = { containerRef: React.RefObject; viewport: ViewportTransform; + appearance?: CanvasAppearance; backgroundMode?: CanvasBackgroundMode; onViewportChange: (viewport: ViewportTransform) => void; onViewportPreviewChange?: (viewport: ViewportTransform) => void; @@ -40,10 +42,9 @@ type PinchState = { initialScale: number; }; -export function InfiniteCanvas({ containerRef, viewport, backgroundMode = "lines", onViewportChange, onViewportPreviewChange, onCanvasMouseDown, boxSelectEnabled = false, onCanvasDoubleClick, onCanvasDeselect, onContextMenu, onDrop, onFileDragEnter, onFileDragLeave, onFileDragOver, graphicsLayer, children }: InfiniteCanvasProps) { +export function InfiniteCanvas({ containerRef, viewport, appearance, backgroundMode = "lines", onViewportChange, onViewportPreviewChange, onCanvasMouseDown, boxSelectEnabled = false, onCanvasDoubleClick, onCanvasDeselect, onContextMenu, onDrop, onFileDragEnter, onFileDragLeave, onFileDragOver, graphicsLayer, children }: InfiniteCanvasProps) { const colorTheme = useThemeStore((state) => state.theme); - const theme = canvasThemes[colorTheme]; - const canvasBackground = colorTheme === "light" ? "#e6e6e6" : theme.canvas.background; + const resolvedAppearance = resolveCanvasAppearance(appearance, colorTheme); const panState = useRef({ isPanning: false, pointerId: -1, @@ -374,7 +375,7 @@ export function InfiniteCanvas({ containerRef, viewport, backgroundMode = "lines ref={containerRef} className={`relative h-full w-full select-none overflow-hidden touch-none ${isPanning ? "cursor-grabbing" : boxSelectEnabled ? "cursor-crosshair" : "cursor-grab"}`} style={{ - background: canvasBackground, + background: resolvedAppearance.background, overscrollBehavior: "none", "--canvas-live-x": `${viewport.x}px`, "--canvas-live-y": `${viewport.y}px`, @@ -404,7 +405,7 @@ export function InfiniteCanvas({ containerRef, viewport, backgroundMode = "lines }} onDrop={onDrop} > - + {graphicsLayer}
state.theme); + const gridColor = resolveCanvasGridColor(appearance, colorTheme, mode); + const backgroundImage = mode === "dots" ? `radial-gradient(circle, ${gridColor} var(--canvas-dot-size), transparent calc(var(--canvas-dot-size) + 0.2px))` : `linear-gradient(${gridColor} 1px, transparent 1px), linear-gradient(90deg, ${gridColor} 1px, transparent 1px)`; if (mode === "blank") return null; return ( diff --git a/web/src/components/canvas/use-canvas-create-commands.ts b/web/src/components/canvas/use-canvas-create-commands.ts new file mode 100644 index 000000000..c9af15336 --- /dev/null +++ b/web/src/components/canvas/use-canvas-create-commands.ts @@ -0,0 +1,23 @@ +import { resolveAddNodeMenuCommands, type AddNodeMenuContext } from "@/lib/canvas/tool-registry"; +import { usePluginStore } from "@/stores/use-plugin-store"; + +import type { CanvasCreateCommand } from "./canvas-create-menu"; + +export function useCanvasCreateCommands(context: AddNodeMenuContext, runCommand?: (command: () => void) => void): CanvasCreateCommand[] { + const installations = usePluginStore((state) => state.installations); + const pluginStates = usePluginStore((state) => state.pluginStates); + const enabledPluginIds = new Set(installations.filter((item) => pluginStates[item.manifest.id]?.effectiveEnabled ?? item.enabled).map((item) => item.manifest.id)); + + return resolveAddNodeMenuCommands({ ...context, enabledPluginIds }).map((command) => ({ + id: command.id, + label: command.label, + icon: command.icon, + badge: command.badge, + section: command.section, + onClick: () => { + const run = () => command.run(context); + if (runCommand) runCommand(run); + else run(); + }, + })); +} diff --git a/web/src/lib/canvas-theme.ts b/web/src/lib/canvas-theme.ts index 02f691f15..f8446ce98 100644 --- a/web/src/lib/canvas-theme.ts +++ b/web/src/lib/canvas-theme.ts @@ -4,7 +4,7 @@ export type CanvasBackgroundMode = "dots" | "lines" | "blank"; export const canvasThemes = { light: { canvas: { - background: "#ffffff", + background: "#edeeee", dot: "rgba(15,23,42,.20)", line: "rgba(15,23,42,.15)", selectionFill: "rgba(17,17,17,.10)", @@ -70,9 +70,9 @@ export const canvasThemes = { }, dark: { canvas: { - background: "#0b0b0b", - dot: "rgba(178,178,178,.18)", - line: "rgba(178,178,178,.14)", + background: "#262626", + dot: "rgba(205,205,205,.28)", + line: "rgba(205,205,205,.28)", selectionFill: "rgba(255,255,255,.12)", }, node: { diff --git a/web/src/lib/canvas/canvas-appearance.ts b/web/src/lib/canvas/canvas-appearance.ts new file mode 100644 index 000000000..9936e563f --- /dev/null +++ b/web/src/lib/canvas/canvas-appearance.ts @@ -0,0 +1,174 @@ +import { canvasThemes, type CanvasBackgroundMode, type CanvasColorTheme } from "@/lib/canvas-theme"; +import { scopedLocalStorage } from "@/lib/user-scope"; + +export type CanvasAppearanceMode = CanvasColorTheme | "custom"; + +export type CanvasCustomAppearance = { + baseTheme: CanvasColorTheme; + backgroundColor: string; + backgroundBrightness: number; + gridColor: string; + gridOpacity: number; +}; + +export type CanvasAppearance = { + mode: CanvasAppearanceMode; + custom?: CanvasCustomAppearance; +}; + +export type CanvasAppearanceDefault = { + appearance: CanvasAppearance; + backgroundMode: CanvasBackgroundMode; +}; + +export type ResolvedCanvasAppearance = { + baseTheme: CanvasColorTheme; + background: string; + grid: string; +}; + +const CANVAS_APPEARANCE_DEFAULT_KEY = "infinite-canvas:canvas-appearance-default"; +const HEX_COLOR_PATTERN = /^#?([0-9a-f]{3}|[0-9a-f]{6})$/i; +const CUSTOM_GRID_COLOR = "#B6B3B3"; +const CUSTOM_GRID_OPACITY = 70; + +export function canvasAppearanceForTheme(theme: CanvasColorTheme, previous?: CanvasAppearance): CanvasAppearance { + return previous?.custom?.baseTheme === theme ? { mode: theme, custom: previous.custom } : { mode: theme }; +} + +export function customCanvasAppearanceFromTheme(theme: CanvasColorTheme): CanvasAppearance { + return { + mode: "custom", + custom: { + baseTheme: theme, + backgroundColor: canvasThemes[theme].canvas.background.toUpperCase(), + backgroundBrightness: 0, + gridColor: CUSTOM_GRID_COLOR, + gridOpacity: CUSTOM_GRID_OPACITY, + }, + }; +} + +export function enterCustomCanvasAppearance(current: CanvasAppearance, currentTheme: CanvasColorTheme) { + if (current.custom) return { ...current, mode: "custom" } as CanvasAppearance; + return customCanvasAppearanceFromTheme(currentTheme); +} + +export function canvasAppearanceBaseTheme(appearance: CanvasAppearance | undefined, fallback: CanvasColorTheme): CanvasColorTheme { + if (appearance?.mode === "light" || appearance?.mode === "dark") return appearance.mode; + return appearance?.custom?.baseTheme === "light" || appearance?.custom?.baseTheme === "dark" + ? appearance.custom.baseTheme + : fallback; +} + +export function normalizeCanvasAppearance(value: unknown, fallback: CanvasColorTheme): CanvasAppearance { + if (!value || typeof value !== "object") return canvasAppearanceForTheme(fallback); + const candidate = value as Partial; + const mode = candidate.mode === "light" || candidate.mode === "dark" || candidate.mode === "custom" ? candidate.mode : fallback; + const custom = normalizeCustomAppearance(candidate.custom); + if (mode === "custom" && !custom) return customCanvasAppearanceFromTheme(fallback); + return custom ? { mode, custom } : { mode }; +} + +export function resolveCanvasAppearance(appearance: CanvasAppearance | undefined, fallback: CanvasColorTheme): ResolvedCanvasAppearance { + const normalized = normalizeCanvasAppearance(appearance, fallback); + const baseTheme = canvasAppearanceBaseTheme(normalized, fallback); + const base = canvasThemes[baseTheme]; + if (normalized.mode !== "custom" || !normalized.custom) { + return { + baseTheme, + background: base.canvas.background, + grid: base.canvas.line, + }; + } + + const custom = normalized.custom; + return { + baseTheme, + background: adjustHexLightnessOklch(custom.backgroundColor, custom.backgroundBrightness), + grid: rgbaFromHex(custom.gridColor, custom.gridOpacity / 100), + }; +} + +export function resolveCanvasGridColor(appearance: CanvasAppearance | undefined, fallback: CanvasColorTheme, mode: CanvasBackgroundMode) { + const normalized = normalizeCanvasAppearance(appearance, fallback); + if (normalized.mode === "custom" && normalized.custom) return rgbaFromHex(normalized.custom.gridColor, normalized.custom.gridOpacity / 100); + const theme = canvasThemes[canvasAppearanceBaseTheme(normalized, fallback)]; + return mode === "dots" ? theme.canvas.dot : theme.canvas.line; +} + +export function normalizeHexColor(value: string) { + const match = value.trim().match(HEX_COLOR_PATTERN); + if (!match) return null; + const hex = match[1].length === 3 + ? match[1].split("").map((channel) => channel.repeat(2)).join("") + : match[1]; + return `#${hex.toUpperCase()}`; +} + +export function readCanvasAppearanceDefault(): CanvasAppearanceDefault | null { + const value = scopedLocalStorage.getItem(CANVAS_APPEARANCE_DEFAULT_KEY); + if (!value) return null; + try { + const parsed = JSON.parse(value) as Partial; + if (parsed.backgroundMode !== "dots" && parsed.backgroundMode !== "lines" && parsed.backgroundMode !== "blank") return null; + const fallback = canvasAppearanceBaseTheme(parsed.appearance, "dark"); + return { + appearance: normalizeCanvasAppearance(parsed.appearance, fallback), + backgroundMode: parsed.backgroundMode, + }; + } catch { + return null; + } +} + +export function writeCanvasAppearanceDefault(value: CanvasAppearanceDefault) { + const fallback = canvasAppearanceBaseTheme(value.appearance, "dark"); + scopedLocalStorage.setItem(CANVAS_APPEARANCE_DEFAULT_KEY, JSON.stringify({ + appearance: normalizeCanvasAppearance(value.appearance, fallback), + backgroundMode: value.backgroundMode, + })); +} + +function normalizeCustomAppearance(value: unknown): CanvasCustomAppearance | undefined { + if (!value || typeof value !== "object") return undefined; + const candidate = value as Partial; + const backgroundColor = typeof candidate.backgroundColor === "string" ? normalizeHexColor(candidate.backgroundColor) : null; + const gridColor = typeof candidate.gridColor === "string" ? normalizeHexColor(candidate.gridColor) : null; + if (!backgroundColor || !gridColor || (candidate.baseTheme !== "light" && candidate.baseTheme !== "dark")) return undefined; + return { + baseTheme: candidate.baseTheme, + backgroundColor, + backgroundBrightness: clampNumber(candidate.backgroundBrightness, -30, 30, 0), + gridColor, + gridOpacity: clampNumber(candidate.gridOpacity, 0, 100, CUSTOM_GRID_OPACITY), + }; +} + +function clampNumber(value: unknown, min: number, max: number, fallback: number) { + return typeof value === "number" && Number.isFinite(value) ? Math.min(max, Math.max(min, value)) : fallback; +} + +function rgbaFromHex(value: string, opacity: number) { + const normalized = normalizeHexColor(value) || "#808080"; + const [red, green, blue] = [1, 3, 5].map((offset) => Number.parseInt(normalized.slice(offset, offset + 2), 16)); + return `rgba(${red},${green},${blue},${clampNumber(opacity, 0, 1, 1).toFixed(3).replace(/0+$/, "").replace(/\.$/, "")})`; +} + +function adjustHexLightnessOklch(value: string, amount: number) { + const normalized = normalizeHexColor(value) || "#808080"; + if (!amount) return normalized; + const [red, green, blue] = [1, 3, 5] + .map((offset) => Number.parseInt(normalized.slice(offset, offset + 2), 16) / 255) + .map((channel) => channel <= 0.04045 ? channel / 12.92 : ((channel + 0.055) / 1.055) ** 2.4); + const l = Math.cbrt(0.4122214708 * red + 0.5363325363 * green + 0.0514459929 * blue); + const m = Math.cbrt(0.2119034982 * red + 0.6806995451 * green + 0.1073969566 * blue); + const s = Math.cbrt(0.0883024619 * red + 0.2817188376 * green + 0.6299787005 * blue); + const lightness = 0.2104542553 * l + 0.793617785 * m - 0.0040720468 * s; + const a = 1.9779984951 * l - 2.428592205 * m + 0.4505937099 * s; + const b = 0.0259040371 * l + 0.7827717662 * m - 0.808675766 * s; + const chroma = Math.sqrt(a * a + b * b); + const hue = chroma < 0.0001 ? 0 : (Math.atan2(b, a) * 180 / Math.PI + 360) % 360; + const adjustedLightness = Math.min(1, Math.max(0, lightness + amount / 100)); + return `oklch(${(adjustedLightness * 100).toFixed(2)}% ${chroma.toFixed(4)} ${hue.toFixed(2)})`; +} diff --git a/web/src/lib/canvas/canvas-connected-node-placement.ts b/web/src/lib/canvas/canvas-connected-node-placement.ts new file mode 100644 index 000000000..ef904d5e3 --- /dev/null +++ b/web/src/lib/canvas/canvas-connected-node-placement.ts @@ -0,0 +1,13 @@ +import type { Position } from "@/types/canvas"; + +type NodeSize = { + width: number; + height: number; +}; + +export function connectedNodeCenterFromEdgeDrop(dropPosition: Position, nodeSize: NodeSize, handleType: "source" | "target"): Position { + return { + x: dropPosition.x + (handleType === "source" ? nodeSize.width / 2 : -nodeSize.width / 2), + y: dropPosition.y, + }; +} diff --git a/web/src/lib/canvas/canvas-starter.ts b/web/src/lib/canvas/canvas-starter.ts new file mode 100644 index 000000000..a5536c789 --- /dev/null +++ b/web/src/lib/canvas/canvas-starter.ts @@ -0,0 +1,20 @@ +export type CanvasStarterMode = "guided" | "freeform"; + +export type CanvasEmptyStateKind = "none" | "guided" | "freeform" | "linked"; + +export function resolveCanvasEmptyStateKind({ + nodeCount, + shortDramaEnabled, + isProjectLinked, + starterMode, +}: { + nodeCount: number; + shortDramaEnabled: boolean; + isProjectLinked: boolean; + starterMode?: CanvasStarterMode; +}): CanvasEmptyStateKind { + if (nodeCount > 0) return "none"; + if (!shortDramaEnabled) return "freeform"; + if (isProjectLinked) return "linked"; + return starterMode === "freeform" ? "freeform" : "guided"; +} diff --git a/web/src/pages/canvas/project.tsx b/web/src/pages/canvas/project.tsx index 93f4881b2..7f1829464 100644 --- a/web/src/pages/canvas/project.tsx +++ b/web/src/pages/canvas/project.tsx @@ -12,6 +12,7 @@ import { uploadImage } from "@/services/image-storage"; import { imageMetadata } from "@/lib/canvas/canvas-generation-task-sync"; import copyToClipboard from "copy-to-clipboard"; import { nanoid } from "nanoid"; +import { canvasAppearanceBaseTheme, canvasAppearanceForTheme, normalizeCanvasAppearance, resolveCanvasAppearance, writeCanvasAppearanceDefault, type CanvasAppearance } from "@/lib/canvas/canvas-appearance"; import { canvasThemes, type CanvasBackgroundMode } from "@/lib/canvas-theme"; import { persistCanvasMediaPerformanceMode, readCanvasMediaPerformanceMode } from "@/lib/canvas/canvas-performance-mode"; import { summarizeCanvasContext } from "@/lib/canvas/canvas-context-summary"; @@ -55,6 +56,7 @@ import { InfiniteCanvas } from "@/components/canvas/infinite-canvas"; import { Minimap } from "@/components/canvas/canvas-mini-map"; import { CanvasNodePromptPanel, type CanvasNodeGenerationMode } from "@/components/canvas/canvas-node-prompt-panel"; import { CanvasToolbar } from "@/components/canvas/canvas-toolbar"; +import { useCanvasCreateCommands } from "@/components/canvas/use-canvas-create-commands"; import { AssetPickerModal, type InsertAssetPayload } from "@/components/canvas/asset-picker-modal"; import { getProject } from "@/services/api/projects"; import { CanvasZoomControls } from "@/components/canvas/canvas-zoom-controls"; @@ -71,6 +73,7 @@ import { CanvasConnectionCreateMenu, CanvasNodePanelOverlay } from "@/components import { CanvasOverlayLayerContainer, CanvasOverlayLayerProvider } from "@/components/canvas/canvas-overlay-layer"; import { CanvasLeaferGraphicsLayer } from "@/components/canvas/canvas-leafer-graphics-layer"; import { CanvasFreeformEmptyState, CanvasLinkedProjectEmptyState, CanvasShortDramaEmptyState, CanvasShortDramaGuide, CanvasStoryInputNodeContent, CanvasStylePlaceholderNodeContent } from "@/components/canvas/canvas-short-drama-entry"; +import { resolveCanvasEmptyStateKind } from "@/lib/canvas/canvas-starter"; import { failedImageBatchChildren, markImageBatchRetrying, reconcileImageBatchRoot, restoreUnsubmittedImageBatchChild } from "@/lib/canvas/canvas-image-batch-retry"; import { createCanvasNode, getInputSummary, isHiddenBatchChild, persistCanvasWorkspaceMode, readCanvasWorkspaceMode } from "@/lib/canvas/canvas-project-domain"; import { stampCanvasNodeChanges } from "@/lib/canvas/canvas-node-timestamps"; @@ -215,7 +218,9 @@ function InfiniteCanvasPage() { const assets = useAssetStore((state) => state.assets); const assetsHydrated = useAssetStore((state) => state.hydrated); const cleanupAssetImages = useAssetStore((state) => state.cleanupImages); - const theme = canvasThemes[useThemeStore((state) => state.theme)]; + const colorTheme = useThemeStore((state) => state.theme); + const setTheme = useThemeStore((state) => state.setTheme); + const theme = canvasThemes[colorTheme]; const defaultDrawingEngine = useUserStore((state) => state.drawingEngine.defaultEngine); const shortDramaEnabled = useUserStore((state) => state.features.shortDramaEnabled); const directorOnboardingScope = useUserStore((state) => state.user?.id?.trim() || ""); @@ -244,6 +249,7 @@ function InfiniteCanvasPage() { const [hoveredNodeId, setHoveredNodeId] = useState(null); const [contextMenu, setContextMenu] = useState(null); const [isMiniMapOpen, setIsMiniMapOpen] = useState(false); + const [canvasAppearance, setCanvasAppearance] = useState(() => canvasAppearanceForTheme(colorTheme)); const [backgroundMode, setBackgroundMode] = useState("lines"); const [showImageInfo, setShowImageInfo] = useState(false); const [canvasTool, setCanvasTool] = useState("move"); @@ -335,18 +341,32 @@ function InfiniteCanvasPage() { }); }, [canvasStorageScope, projectId]); + const resolvedCanvasAppearance = useMemo(() => resolveCanvasAppearance(canvasAppearance, colorTheme), [canvasAppearance, colorTheme]); + const applyCanvasAppearance = useCallback((next: CanvasAppearance) => { + const fallback = canvasAppearanceBaseTheme(next, colorTheme); + const normalized = normalizeCanvasAppearance(next, fallback); + setCanvasAppearance(normalized); + setTheme(canvasAppearanceBaseTheme(normalized, fallback)); + }, [colorTheme, setTheme]); + const saveCanvasAppearanceDefault = useCallback((next: CanvasAppearance) => { + writeCanvasAppearanceDefault({ appearance: next, backgroundMode }); + message.success("已保存为当前账号在本机的新建画布默认外观"); + }, [backgroundMode, message]); + const { getHistoryCleanupContext, historyPausedRef, historyState, redoCanvas, resetHistory, undoCanvas } = useCanvasHistory({ projectLoaded, nodes, connections, chatSessions, activeChatId, + canvasAppearance, backgroundMode, showImageInfo, setNodes, setConnections, setChatSessions, setActiveChatId, + applyCanvasAppearance, setBackgroundMode, setShowImageInfo, setSelectedNodeIds, @@ -368,6 +388,7 @@ function InfiniteCanvasPage() { connections, chatSessions, activeChatId, + canvasAppearance, backgroundMode, showImageInfo, viewport, @@ -379,6 +400,7 @@ function InfiniteCanvasPage() { setConnections, setChatSessions, setActiveChatId, + setCanvasAppearance, setBackgroundMode, setShowImageInfo, setViewport, @@ -1944,20 +1966,47 @@ function InfiniteCanvasPage() { } focusCanvasNode(styleNode.id); }, [focusCanvasNode, message, nodesRef]); - const emptyCanvasState = nodes.length ? null : !shortDramaEnabled ? ( - handleUploadRequest()} onAddText={() => createNode(CanvasNodeType.Text)} /> - ) : currentProject?.projectId ? ( + const freeformCreateCommands = useCanvasCreateCommands({ + workspaceMode, + isProjectLinked: Boolean(shortDramaEnabled && currentProject?.projectId), + handlers: { + onAddText: () => createNode(CanvasNodeType.Text), + onAddImage: () => createNode(CanvasNodeType.Image), + onAddVideo: () => createNode(CanvasNodeType.Video), + onAddAudio: () => createNode(CanvasNodeType.Audio), + onAddScript: () => createNode(CanvasNodeType.Script), + onAddFrame: () => createNode(CanvasNodeType.Frame), + onAddFolder: createFolder, + onAddDrawing: () => createNode(CanvasNodeType.Drawing), + onAddWorkflow: () => createNode(CanvasNodeType.Config), + onAddExtensionNode: (type) => createNode(type), + onChooseStyle: () => setStylePickerOpen(true), + onOpenDirector: () => setDirectorTemplateRequest({}), + onUpload: () => handleUploadRequest(), + onOpenMyAssets: () => openCanvasAssetLibrary(), + onOpenProjectCharacters: () => openProjectAssets("character"), + }, + }); + const emptyStateKind = resolveCanvasEmptyStateKind({ + nodeCount: nodes.length, + shortDramaEnabled, + isProjectLinked: Boolean(currentProject?.projectId), + starterMode: currentProject?.starterMode, + }); + const emptyCanvasState = emptyStateKind === "freeform" ? ( + + ) : emptyStateKind === "linked" ? ( { const first = linkedProjectQuery.data?.units.slice().sort((left, right) => left.position - right.position)[0]; - if (first) void handleProjectChapterInsert({ id: first.id, projectId: currentProject.projectId!, title: first.title, position: first.position }); + if (first) void handleProjectChapterInsert({ id: first.id, projectId: linkedProjectId, title: first.title, position: first.position }); }} onOpenAssets={() => openProjectAssets()} onAddText={() => createNode(CanvasNodeType.Text)} /> - ) : ( + ) : emptyStateKind === "guided" ? ( { @@ -1965,11 +2014,12 @@ function InfiniteCanvasPage() { setAgentMode("online"); openAgent("online"); }} + onStartFreeform={() => updateProject(projectId, { starterMode: "freeform" })} onUpload={() => handleUploadRequest()} onAddText={() => createNode(CanvasNodeType.Text)} onAddScript={() => createNode(CanvasNodeType.Script)} /> - ); + ) : null; if (!projectLoaded) return ; return ( @@ -1980,7 +2030,7 @@ function InfiniteCanvasPage() { > 跳转到画布主内容 -
+
{!focusMode && shortDramaEnabled && currentProject?.projectId ? ( openProjectAssets()} /> ) : null} @@ -2078,6 +2128,7 @@ function InfiniteCanvasPage() { createNode(CanvasNodeType.Image)} @@ -2237,6 +2289,8 @@ function InfiniteCanvasPage() { onDelete={() => deleteNodes(new Set(selectedNodeIds))} onClear={() => setClearConfirmOpen(true)} onDeselect={deselectCanvas} + onAppearanceChange={applyCanvasAppearance} + onSaveAppearanceDefault={saveCanvasAppearanceDefault} onBackgroundModeChange={setBackgroundMode} onShowImageInfoChange={setShowImageInfo} onOpenMyAssets={() => { diff --git a/web/src/pages/canvas/shared.tsx b/web/src/pages/canvas/shared.tsx index b835ce90e..5c18375a7 100644 --- a/web/src/pages/canvas/shared.tsx +++ b/web/src/pages/canvas/shared.tsx @@ -13,6 +13,7 @@ import { InfiniteCanvas } from "@/components/canvas/infinite-canvas"; import { FullScreenLoader } from "@/components/ui/aceternity/full-screen-loader"; import { WorkspaceState } from "@/components/layout/workspace-state"; import { NODE_DEFAULT_SIZE } from "@/constant/canvas"; +import { canvasAppearanceBaseTheme, canvasAppearanceForTheme, normalizeCanvasAppearance, resolveCanvasAppearance, type CanvasAppearance } from "@/lib/canvas/canvas-appearance"; import { canvasThemes } from "@/lib/canvas-theme"; import { FOLDER_COLLAPSED_HEIGHT, FOLDER_COLLAPSED_WIDTH, isCanvasFolderNode, isFrameNode, isNodeHiddenByCollapsedFrame, resolveFrameConnection } from "@/lib/canvas/canvas-frame"; import { ensureMediaNodeMinimumSize } from "@/lib/canvas/canvas-node-size"; @@ -26,7 +27,8 @@ type DragState = { primaryId: string; nodeIds: string[]; startX: number; startY: export default function SharedCanvasPage() { const { token = "" } = useParams(); const { message } = App.useApp(); - const theme = canvasThemes[useThemeStore((state) => state.theme)]; + const colorTheme = useThemeStore((state) => state.theme); + const theme = canvasThemes[colorTheme]; const containerRef = useRef(null); const viewportRef = useRef({ x: 0, y: 0, k: 1 }); const dragRef = useRef(null); @@ -34,6 +36,7 @@ export default function SharedCanvasPage() { const [title, setTitle] = useState("共享画布"); const [nodes, setNodes] = useState([]); const [connections, setConnections] = useState>["project"]["connections"]>([]); + const [appearance, setAppearance] = useState(() => canvasAppearanceForTheme(colorTheme)); const [backgroundMode, setBackgroundMode] = useState<"lines" | "dots" | "blank">("lines"); const [viewport, setViewport] = useState({ x: 0, y: 0, k: 1 }); const [selectedNodeId, setSelectedNodeId] = useState(null); @@ -74,12 +77,22 @@ export default function SharedCanvasPage() { useEffect(() => { let active = true; + const themeBeforeShare = useThemeStore.getState().theme; + let appliedShareTheme: typeof themeBeforeShare | null = null; + let themeChangedAfterApply = false; + const unsubscribeTheme = useThemeStore.subscribe((state, previous) => { + if (appliedShareTheme && state.theme !== previous.theme && state.theme !== appliedShareTheme) themeChangedAfterApply = true; + }); setLoading(true); getPublicCanvasShare(token).then(({ project }) => { if (!active) return; setTitle(project.title || "共享画布"); setNodes((project.nodes || []).map(ensureMediaNodeMinimumSize)); setConnections(project.connections || []); + const nextAppearance = project.appearance ? normalizeCanvasAppearance(project.appearance, themeBeforeShare) : canvasAppearanceForTheme(themeBeforeShare); + setAppearance(nextAppearance); + appliedShareTheme = canvasAppearanceBaseTheme(nextAppearance, themeBeforeShare); + useThemeStore.getState().setTheme(appliedShareTheme); setBackgroundMode(project.backgroundMode || "lines"); const initial = project.viewport || { x: 0, y: 0, k: 1 }; viewportRef.current = initial; @@ -89,7 +102,13 @@ export default function SharedCanvasPage() { }).finally(() => { if (active) setLoading(false); }); - return () => { active = false; }; + return () => { + active = false; + unsubscribeTheme(); + if (appliedShareTheme && !themeChangedAfterApply && useThemeStore.getState().theme === appliedShareTheme) { + useThemeStore.getState().setTheme(themeBeforeShare); + } + }; }, [token]); useEffect(() => { @@ -222,7 +241,7 @@ export default function SharedCanvasPage() { if (loadError) return
} />
; return ( -
+
@@ -232,7 +251,7 @@ export default function SharedCanvasPage() {
- { viewportRef.current = next; }} onCanvasDeselect={() => { setSelectedNodeId(null); setContextMenu(null); }} onContextMenu={(event) => openContextMenu(event)} onDrop={(event) => { event.preventDefault(); unauthorized(); }}> + { viewportRef.current = next; }} onCanvasDeselect={() => { setSelectedNodeId(null); setContextMenu(null); }} onContextMenu={(event) => openContextMenu(event)} onDrop={(event) => { event.preventDefault(); unauthorized(); }}> {visibleConnections.map(({ connection, from, to }) => setInfoNodeId(to.id)} />)} diff --git a/web/src/pages/canvas/use-canvas-connection-controller.ts b/web/src/pages/canvas/use-canvas-connection-controller.ts index 022525c11..44a94f20e 100644 --- a/web/src/pages/canvas/use-canvas-connection-controller.ts +++ b/web/src/pages/canvas/use-canvas-connection-controller.ts @@ -5,6 +5,7 @@ import { nanoid } from "nanoid"; import type { PendingConnectionCreate } from "@/components/canvas/canvas-workspace-overlays"; import { getNodeSpec } from "@/constant/canvas"; import { batchSourceRestriction, buildBatchConnectionCreateRequest, hasBatchConnectionCandidate, planBatchConnections, type CanvasBatchConnectionPreview } from "@/lib/canvas/canvas-batch-connection"; +import { connectedNodeCenterFromEdgeDrop } from "@/lib/canvas/canvas-connected-node-placement"; import { canvasConnectionError } from "@/lib/canvas/canvas-connection-policy"; import { attachNodeToStoryboardRow, createCanvasNode, getConnectionTargetAnchor, isHiddenBatchChild, normalizeConnection, storyboardHandleAtY, storyboardPromptTemplateMetadata, storyboardRowFromHandle } from "@/lib/canvas/canvas-project-domain"; import { createCanvasDrawingFromImage } from "@/lib/canvas/canvas-drawing-storage"; @@ -255,7 +256,9 @@ export function useCanvasConnectionController({ : sourceNodeForQuickCreate.position.x - 96 - spec.width / 2, y: anchorY, } - : pending.position; + : batchSourceNodeIds.length + ? pending.position + : connectedNodeCenterFromEdgeDrop(pending.position, spec, pending.connection.handleType); const newNode = createCanvasNode(nodeType, position, metadata); if (nodeType === CanvasNodeType.Config && selectedWorkflowProvider) newNode.title = selectedWorkflowProvider === "runninghub" ? "RunningHub 工作流" : "ComfyUI Bridge"; if (storyboardRow) newNode.title = `镜头 ${storyboardRow.shotNumber} · 视频`; diff --git a/web/src/pages/canvas/use-canvas-history.ts b/web/src/pages/canvas/use-canvas-history.ts index 4a155abf0..fd2826523 100644 --- a/web/src/pages/canvas/use-canvas-history.ts +++ b/web/src/pages/canvas/use-canvas-history.ts @@ -1,5 +1,6 @@ import { useCallback, useEffect, useRef, useState, type Dispatch, type SetStateAction } from "react"; +import type { CanvasAppearance } from "@/lib/canvas/canvas-appearance"; import type { CanvasBackgroundMode } from "@/lib/canvas-theme"; import type { CanvasAssistantSession, CanvasConnection, CanvasNodeData, ContextMenuState } from "@/types/canvas"; @@ -8,6 +9,7 @@ export type CanvasHistorySnapshot = { connections: CanvasConnection[]; chatSessions: CanvasAssistantSession[]; activeChatId: string | null; + canvasAppearance: CanvasAppearance; backgroundMode: CanvasBackgroundMode; showImageInfo: boolean; }; @@ -34,6 +36,7 @@ type CanvasHistoryPatch = { connections?: EntityPatch; chatSessions?: EntityPatch; activeChatId?: ValuePatch; + canvasAppearance?: ValuePatch; backgroundMode?: ValuePatch; showImageInfo?: ValuePatch; }; @@ -44,6 +47,7 @@ type UseCanvasHistoryOptions = CanvasHistorySnapshot & { setConnections: Dispatch>; setChatSessions: Dispatch>; setActiveChatId: Dispatch>; + applyCanvasAppearance: (appearance: CanvasAppearance) => void; setBackgroundMode: Dispatch>; setShowImageInfo: Dispatch>; setSelectedNodeIds: Dispatch>>; @@ -57,12 +61,14 @@ export function useCanvasHistory({ connections, chatSessions, activeChatId, + canvasAppearance, backgroundMode, showImageInfo, setNodes, setConnections, setChatSessions, setActiveChatId, + applyCanvasAppearance, setBackgroundMode, setShowImageInfo, setSelectedNodeIds, @@ -78,8 +84,8 @@ export function useCanvasHistory({ const [historyState, setHistoryState] = useState({ canUndo: false, canRedo: false }); const createHistorySnapshot = useCallback( - (): CanvasHistorySnapshot => ({ nodes, connections, chatSessions, activeChatId, backgroundMode, showImageInfo }), - [activeChatId, backgroundMode, chatSessions, connections, nodes, showImageInfo], + (): CanvasHistorySnapshot => ({ nodes, connections, chatSessions, activeChatId, canvasAppearance, backgroundMode, showImageInfo }), + [activeChatId, backgroundMode, canvasAppearance, chatSessions, connections, nodes, showImageInfo], ); const clearCommitTimer = useCallback(() => { @@ -109,6 +115,7 @@ export function useCanvasHistory({ setConnections(snapshot.connections); setChatSessions(snapshot.chatSessions); setActiveChatId(snapshot.activeChatId); + applyCanvasAppearance(snapshot.canvasAppearance); setBackgroundMode(snapshot.backgroundMode); setShowImageInfo(snapshot.showImageInfo); setSelectedNodeIds(new Set()); @@ -120,7 +127,7 @@ export function useCanvasHistory({ applyTimerRef.current = null; setHistoryState({ canUndo: historyRef.current.past.length > 0, canRedo: historyRef.current.future.length > 0 }); }); - }, [clearCommitTimer, setActiveChatId, setBackgroundMode, setChatSessions, setConnections, setContextMenu, setNodes, setSelectedConnectionId, setSelectedNodeIds, setShowImageInfo]); + }, [applyCanvasAppearance, clearCommitTimer, setActiveChatId, setBackgroundMode, setChatSessions, setConnections, setContextMenu, setNodes, setSelectedConnectionId, setSelectedNodeIds, setShowImageInfo]); const undoCanvas = useCallback(() => { const patch = historyRef.current.past.pop(); @@ -176,6 +183,7 @@ function snapshotsShareReferences(before: CanvasHistorySnapshot, after: CanvasHi && before.connections === after.connections && before.chatSessions === after.chatSessions && before.activeChatId === after.activeChatId + && before.canvasAppearance === after.canvasAppearance && before.backgroundMode === after.backgroundMode && before.showImageInfo === after.showImageInfo; } @@ -186,6 +194,7 @@ function createCanvasHistoryPatch(before: CanvasHistorySnapshot, after: CanvasHi patch.connections = createEntityPatch(before.connections, after.connections); patch.chatSessions = createEntityPatch(before.chatSessions, after.chatSessions); if (before.activeChatId !== after.activeChatId) patch.activeChatId = { before: before.activeChatId, after: after.activeChatId }; + if (before.canvasAppearance !== after.canvasAppearance) patch.canvasAppearance = { before: before.canvasAppearance, after: after.canvasAppearance }; if (before.backgroundMode !== after.backgroundMode) patch.backgroundMode = { before: before.backgroundMode, after: after.backgroundMode }; if (before.showImageInfo !== after.showImageInfo) patch.showImageInfo = { before: before.showImageInfo, after: after.showImageInfo }; return Object.values(patch).some(Boolean) ? patch : null; @@ -219,6 +228,7 @@ function applyCanvasHistoryPatch(snapshot: CanvasHistorySnapshot, patch: CanvasH connections: patch.connections ? applyEntityPatch(snapshot.connections, patch.connections, side) : snapshot.connections, chatSessions: patch.chatSessions ? applyEntityPatch(snapshot.chatSessions, patch.chatSessions, side) : snapshot.chatSessions, activeChatId: patch.activeChatId ? patch.activeChatId[side] : snapshot.activeChatId, + canvasAppearance: patch.canvasAppearance ? patch.canvasAppearance[side] : snapshot.canvasAppearance, backgroundMode: patch.backgroundMode ? patch.backgroundMode[side] : snapshot.backgroundMode, showImageInfo: patch.showImageInfo ? patch.showImageInfo[side] : snapshot.showImageInfo, }; diff --git a/web/src/pages/canvas/use-canvas-project-lifecycle.ts b/web/src/pages/canvas/use-canvas-project-lifecycle.ts index 99c7741ab..fb2a661ec 100644 --- a/web/src/pages/canvas/use-canvas-project-lifecycle.ts +++ b/web/src/pages/canvas/use-canvas-project-lifecycle.ts @@ -2,6 +2,7 @@ import { useCallback, useEffect, useRef, useState, type Dispatch, type MutableRe import { App } from "antd"; import { useNavigate } from "react-router"; +import { canvasAppearanceBaseTheme, canvasAppearanceForTheme, normalizeCanvasAppearance, type CanvasAppearance } from "@/lib/canvas/canvas-appearance"; import type { CanvasBackgroundMode } from "@/lib/canvas-theme"; import { removeCanvasDrawing } from "@/lib/canvas/canvas-drawing-storage"; import { normalizeCanvasNodeTimestamps } from "@/lib/canvas/canvas-node-timestamps"; @@ -9,6 +10,7 @@ import { hydrateAssistantImages, hydrateCanvasImages, resetInterruptedGeneration import { listAddedSkills, type Skill } from "@/services/api/skills"; import { createCanvasProjectWithRemoteSync, deleteCanvasProjectsWithRemoteSync, saveRemoteUserDataNow } from "@/services/user-data-sync"; import { flushCanvasStorePersistence, useCanvasStore } from "@/stores/canvas/use-canvas-store"; +import { useThemeStore } from "@/stores/use-theme-store"; import type { CanvasAssistantSession, CanvasConnection, CanvasNodeData, CanvasNodeMetadata, ViewportTransform } from "@/types/canvas"; import type { CanvasHistorySnapshot } from "./use-canvas-history"; @@ -19,6 +21,7 @@ type UseCanvasProjectLifecycleOptions = { connections: CanvasConnection[]; chatSessions: CanvasAssistantSession[]; activeChatId: string | null; + canvasAppearance: CanvasAppearance; backgroundMode: CanvasBackgroundMode; showImageInfo: boolean; viewport: ViewportTransform; @@ -30,6 +33,7 @@ type UseCanvasProjectLifecycleOptions = { setConnections: Dispatch>; setChatSessions: Dispatch>; setActiveChatId: Dispatch>; + setCanvasAppearance: Dispatch>; setBackgroundMode: Dispatch>; setShowImageInfo: Dispatch>; setViewport: Dispatch>; @@ -46,6 +50,7 @@ export function useCanvasProjectLifecycle({ connections, chatSessions, activeChatId, + canvasAppearance, backgroundMode, showImageInfo, viewport, @@ -57,6 +62,7 @@ export function useCanvasProjectLifecycle({ setConnections, setChatSessions, setActiveChatId, + setCanvasAppearance, setBackgroundMode, setShowImageInfo, setViewport, @@ -87,11 +93,16 @@ export function useCanvasProjectLifecycle({ const applyRestoredProject = (restoredNodes: CanvasNodeData[], restoredSessions: CanvasAssistantSession[]) => { if (cancelled) return; + const fallbackTheme = useThemeStore.getState().theme; + const restoredAppearance = project.appearance + ? normalizeCanvasAppearance(project.appearance, fallbackTheme) + : canvasAppearanceForTheme(fallbackTheme); const snapshot: CanvasHistorySnapshot = { nodes: restoredNodes, connections: project.connections, chatSessions: restoredSessions, activeChatId: project.activeChatId || null, + canvasAppearance: restoredAppearance, backgroundMode: project.backgroundMode || "dots", showImageInfo: project.showImageInfo || false, }; @@ -102,6 +113,8 @@ export function useCanvasProjectLifecycle({ setConnections(snapshot.connections); setChatSessions(snapshot.chatSessions); setActiveChatId(snapshot.activeChatId); + setCanvasAppearance(snapshot.canvasAppearance); + useThemeStore.getState().setTheme(canvasAppearanceBaseTheme(snapshot.canvasAppearance, fallbackTheme)); setBackgroundMode(snapshot.backgroundMode); setShowImageInfo(snapshot.showImageInfo); setViewport(project.viewport); @@ -128,7 +141,7 @@ export function useCanvasProjectLifecycle({ return () => { cancelled = true; }; - }, [hydrated, message, navigate, openProject, projectId, resetHistory, setActiveChatId, setBackgroundMode, setChatSessions, setConnections, setNodes, setShowImageInfo, setViewport]); + }, [hydrated, message, navigate, openProject, projectId, resetHistory, setActiveChatId, setBackgroundMode, setCanvasAppearance, setChatSessions, setConnections, setNodes, setShowImageInfo, setViewport]); useEffect(() => { if (!projectLoaded) return; @@ -147,8 +160,8 @@ export function useCanvasProjectLifecycle({ useEffect(() => { if (!projectLoaded || historyPausedRef.current) return; - updateProject(projectId, { nodes, connections, chatSessions, activeChatId, backgroundMode, showImageInfo }); - }, [activeChatId, backgroundMode, chatSessions, connections, historyPausedRef, nodes, projectId, projectLoaded, showImageInfo, updateProject]); + updateProject(projectId, { nodes, connections, chatSessions, activeChatId, appearance: canvasAppearance, backgroundMode, showImageInfo }); + }, [activeChatId, backgroundMode, canvasAppearance, chatSessions, connections, historyPausedRef, nodes, projectId, projectLoaded, showImageInfo, updateProject]); useEffect(() => { if (!projectLoaded) return; @@ -202,6 +215,7 @@ export function useCanvasProjectLifecycle({ connections: connectionsRef.current, chatSessions, activeChatId, + appearance: canvasAppearance, backgroundMode, showImageInfo, viewport: viewportRef.current, @@ -220,7 +234,7 @@ export function useCanvasProjectLifecycle({ message.warning(`本地画布布局已保存,云端同步失败:${detail}`); } return true; - }, [activeChatId, backgroundMode, chatSessions, connectionsRef, currentProject?.directorScenes, message, nodesRef, projectId, showImageInfo, updateProject, viewportRef]); + }, [activeChatId, backgroundMode, canvasAppearance, chatSessions, connectionsRef, currentProject?.directorScenes, message, nodesRef, projectId, showImageInfo, updateProject, viewportRef]); const clearCanvasFiles = useCallback(() => { cleanupCanvasFiles({ projectId, nodes: [], chatSessions: [] }); diff --git a/web/src/stores/canvas/use-canvas-store.ts b/web/src/stores/canvas/use-canvas-store.ts index a720c04df..7b2279749 100644 --- a/web/src/stores/canvas/use-canvas-store.ts +++ b/web/src/stores/canvas/use-canvas-store.ts @@ -2,10 +2,12 @@ import { create } from "zustand"; import { persist, type PersistStorage, type StorageValue } from "zustand/middleware"; import { nanoid } from "nanoid"; +import { normalizeCanvasAppearance, readCanvasAppearanceDefault, type CanvasAppearance } from "@/lib/canvas/canvas-appearance"; import { parseCanvasStorageDocument, rebaseCanvasProjects, serializeCanvasStorageDocument, type CanvasStorageDocument } from "@/lib/canvas/canvas-storage-revision"; import { localForageStorageForScope } from "@/lib/localforage-storage"; import { getActiveUserScope } from "@/lib/user-scope"; import type { CanvasBackgroundMode } from "@/lib/canvas-theme"; +import type { CanvasStarterMode } from "@/lib/canvas/canvas-starter"; import type { CanvasAssistantSession, CanvasConnection, CanvasNodeData, ViewportTransform } from "@/types/canvas"; import type { DirectorScene } from "@/types/director"; import type { TimelineProject } from "@/types/timeline"; @@ -20,6 +22,8 @@ export type CanvasProject = { connections: CanvasConnection[]; chatSessions: CanvasAssistantSession[]; activeChatId: string | null; + starterMode?: CanvasStarterMode; + appearance?: CanvasAppearance; backgroundMode: CanvasBackgroundMode; showImageInfo: boolean; viewport: ViewportTransform; @@ -36,7 +40,7 @@ type CanvasStore = { renameProject: (id: string, title: string) => void; deleteProjects: (ids: string[]) => void; replaceProjects: (projects: CanvasProject[]) => void; - updateProject: (id: string, patch: Partial>) => void; + updateProject: (id: string, patch: Partial>) => void; }; const initialViewport: ViewportTransform = { x: 0, y: 0, k: 1 }; @@ -418,6 +422,7 @@ export const useCanvasStore = create()( createProject: (title = "未命名画布", projectId) => { const now = new Date().toISOString(); const id = nanoid(); + const appearanceDefault = readCanvasAppearanceDefault(); const project: CanvasProject = { id, projectId, @@ -428,7 +433,8 @@ export const useCanvasStore = create()( connections: [], chatSessions: [], activeChatId: null, - backgroundMode: "lines", + appearance: appearanceDefault?.appearance, + backgroundMode: appearanceDefault?.backgroundMode || "lines", showImageInfo: false, viewport: initialViewport, directorScenes: [], @@ -448,6 +454,8 @@ export const useCanvasStore = create()( connections: source.connections || [], chatSessions: source.chatSessions || [], activeChatId: source.activeChatId || null, + starterMode: source.starterMode, + appearance: source.appearance ? normalizeCanvasAppearance(source.appearance, "dark") : undefined, backgroundMode: source.backgroundMode || "lines", showImageInfo: source.showImageInfo || false, viewport: source.viewport || initialViewport, diff --git a/web/test/canvas-appearance.test.ts b/web/test/canvas-appearance.test.ts new file mode 100644 index 000000000..fc6a27baa --- /dev/null +++ b/web/test/canvas-appearance.test.ts @@ -0,0 +1,155 @@ +import { beforeEach, describe, expect, test } from "bun:test"; + +import { + canvasAppearanceForTheme, + customCanvasAppearanceFromTheme, + enterCustomCanvasAppearance, + normalizeCanvasAppearance, + normalizeHexColor, + readCanvasAppearanceDefault, + resolveCanvasAppearance, + resolveCanvasGridColor, + writeCanvasAppearanceDefault, +} from "../src/lib/canvas/canvas-appearance"; + +const values = new Map(); + +beforeEach(() => { + values.clear(); + Object.defineProperty(globalThis, "window", { + configurable: true, + value: { + localStorage: { + getItem: (key: string) => values.get(key) || null, + setItem: (key: string, value: string) => values.set(key, value), + removeItem: (key: string) => values.delete(key), + }, + }, + }); +}); + +describe("canvas custom appearance", () => { + test("inherits the active fixed theme the first time custom mode is selected", () => { + const light = enterCustomCanvasAppearance(canvasAppearanceForTheme("light"), "light"); + expect(light).toEqual({ + mode: "custom", + custom: { + baseTheme: "light", + backgroundColor: "#EDEEEE", + backgroundBrightness: 0, + gridColor: "#B6B3B3", + gridOpacity: 70, + }, + }); + + const dark = enterCustomCanvasAppearance(canvasAppearanceForTheme("dark"), "dark"); + expect(dark.custom).toMatchObject({ baseTheme: "dark", backgroundColor: "#262626", backgroundBrightness: 0, gridColor: "#B6B3B3", gridOpacity: 70 }); + }); + + test("restores a previous custom profile only under the same base theme", () => { + const previous = customCanvasAppearanceFromTheme("light"); + previous.custom = { ...previous.custom!, backgroundColor: "#F3DCE5" }; + const fixedLight = canvasAppearanceForTheme("light", previous); + expect(enterCustomCanvasAppearance(fixedLight, "light").custom).toEqual(previous.custom); + + const fixedDark = canvasAppearanceForTheme("dark", previous); + expect(fixedDark.custom).toBeUndefined(); + expect(enterCustomCanvasAppearance(fixedDark, "dark").custom).toMatchObject({ + baseTheme: "dark", + backgroundColor: "#262626", + }); + }); + + test("keeps color-picker popups inside the appearance interaction boundary", async () => { + const toolbarSource = await Bun.file(new URL("../src/components/canvas/canvas-toolbar.tsx", import.meta.url)).text(); + const controlsSource = await Bun.file(new URL("../src/components/canvas/canvas-appearance-controls.tsx", import.meta.url)).text(); + expect(toolbarSource).toContain('closest(".ant-color-picker,.ant-popover")'); + expect(controlsSource).toContain("if (normalized) onChange(normalized)"); + }); + + test("commits custom colors immediately instead of discarding them when the panel closes", async () => { + const controlsSource = await Bun.file(new URL("../src/components/canvas/canvas-appearance-controls.tsx", import.meta.url)).text(); + const updateCustomSource = controlsSource.slice( + controlsSource.indexOf("const updateCustom"), + controlsSource.indexOf("const resetCustom"), + ); + expect(updateCustomSource).toContain("onAppearanceChange(next)"); + expect(updateCustomSource).not.toContain("onAppearancePreviewChange(next)"); + }); + + test("keeps hex editing live across parent updates and common hex formats", async () => { + expect(normalizeHexColor("F3DCE5")).toBe("#F3DCE5"); + expect(normalizeHexColor("#f3d")).toBe("#FF33DD"); + + const controlsSource = await Bun.file(new URL("../src/components/canvas/canvas-appearance-controls.tsx", import.meta.url)).text(); + expect(controlsSource).toContain("if (!editing) setTextValue(value)"); + expect(controlsSource).toContain("updateCustom({ backgroundColor, backgroundBrightness: 0 })"); + }); + + test("adjusts only the custom canvas substrate and grid", () => { + const appearance = normalizeCanvasAppearance({ + mode: "custom", + custom: { + baseTheme: "light", + backgroundColor: "#F3DCE5", + backgroundBrightness: 0, + backgroundOpacity: 10, + gridColor: "#9D7182", + gridOpacity: 22, + }, + }, "dark"); + + const resolved = resolveCanvasAppearance(appearance, "dark"); + expect(resolved.baseTheme).toBe("light"); + expect(resolved.background).toBe("#F3DCE5"); + expect(resolveCanvasGridColor(appearance, "dark", "lines")).toBe("rgba(157,113,130,0.22)"); + + appearance.custom!.backgroundBrightness = 10; + const brighter = resolveCanvasAppearance(appearance, "dark").background; + appearance.custom!.backgroundBrightness = -10; + const darker = resolveCanvasAppearance(appearance, "dark").background; + + const brighterParts = brighter.match(/^oklch\(([\d.]+)% ([\d.]+) ([\d.]+)\)$/); + const darkerParts = darker.match(/^oklch\(([\d.]+)% ([\d.]+) ([\d.]+)\)$/); + expect(brighterParts).not.toBeNull(); + expect(darkerParts).not.toBeNull(); + expect(Number(brighterParts![1])).toBeGreaterThan(Number(darkerParts![1])); + expect(brighterParts!.slice(2)).toEqual(darkerParts!.slice(2)); + }); + + test("switches the custom interface style without changing custom canvas colors", () => { + const appearance = customCanvasAppearanceFromTheme("dark"); + appearance.custom = { + ...appearance.custom!, + baseTheme: "light", + backgroundColor: "#F3DCE5", + gridColor: "#9D7182", + gridOpacity: 22, + }; + + expect(resolveCanvasAppearance(appearance, "dark")).toEqual({ + baseTheme: "light", + background: "#F3DCE5", + grid: "rgba(157,113,130,0.22)", + }); + }); + + test("does not expose background opacity after legacy values are ignored", async () => { + const controlsSource = await Bun.file(new URL("../src/components/canvas/canvas-appearance-controls.tsx", import.meta.url)).text(); + expect(controlsSource).not.toContain('label="背景透明度"'); + expect(controlsSource).toContain('aria-label="界面样式"'); + expect(controlsSource).toContain('label="网格强度"'); + }); + + test("stores defaults locally with the active account scope", () => { + window.localStorage.setItem("infinite-canvas:active-user-scope", "account-A"); + const value = { appearance: customCanvasAppearanceFromTheme("dark"), backgroundMode: "lines" as const }; + writeCanvasAppearanceDefault(value); + + expect(values.has("infinite-canvas:canvas-appearance-default:user:account-A")).toBe(true); + expect(readCanvasAppearanceDefault()).toEqual(value); + + window.localStorage.setItem("infinite-canvas:active-user-scope", "account-B"); + expect(readCanvasAppearanceDefault()).toBeNull(); + }); +}); diff --git a/web/test/canvas-connected-node-placement.test.ts b/web/test/canvas-connected-node-placement.test.ts new file mode 100644 index 000000000..ac52d93f7 --- /dev/null +++ b/web/test/canvas-connected-node-placement.test.ts @@ -0,0 +1,13 @@ +import { describe, expect, it } from "bun:test"; + +import { connectedNodeCenterFromEdgeDrop } from "@/lib/canvas/canvas-connected-node-placement"; + +describe("connectedNodeCenterFromEdgeDrop", () => { + it("uses an output drop as the new target node's left-edge midpoint", () => { + expect(connectedNodeCenterFromEdgeDrop({ x: 900, y: 320 }, { width: 720, height: 405 }, "source")).toEqual({ x: 1260, y: 320 }); + }); + + it("uses an input drop as the new source node's right-edge midpoint", () => { + expect(connectedNodeCenterFromEdgeDrop({ x: 900, y: 320 }, { width: 340, height: 120 }, "target")).toEqual({ x: 730, y: 320 }); + }); +}); diff --git a/web/test/canvas-starter.test.ts b/web/test/canvas-starter.test.ts new file mode 100644 index 000000000..c90e63ba6 --- /dev/null +++ b/web/test/canvas-starter.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, test } from "bun:test"; + +import { resolveCanvasEmptyStateKind } from "../src/lib/canvas/canvas-starter"; + +describe("canvas starter", () => { + test("offers the guided entry for a new short-drama canvas", () => { + expect(resolveCanvasEmptyStateKind({ nodeCount: 0, shortDramaEnabled: true, isProjectLinked: false })).toBe("guided"); + }); + + test("persists the freeform choice while the canvas remains empty", () => { + expect(resolveCanvasEmptyStateKind({ nodeCount: 0, shortDramaEnabled: true, isProjectLinked: false, starterMode: "freeform" })).toBe("freeform"); + }); + + test("keeps linked projects on their project-specific empty state", () => { + expect(resolveCanvasEmptyStateKind({ nodeCount: 0, shortDramaEnabled: true, isProjectLinked: true, starterMode: "freeform" })).toBe("linked"); + }); + + test("hides every starter after the first node is created", () => { + expect(resolveCanvasEmptyStateKind({ nodeCount: 1, shortDramaEnabled: true, isProjectLinked: false, starterMode: "freeform" })).toBe("none"); + }); +}); diff --git a/web/test/canvas-visual-contrast.test.ts b/web/test/canvas-visual-contrast.test.ts new file mode 100644 index 000000000..7d3f11ecb --- /dev/null +++ b/web/test/canvas-visual-contrast.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, test } from "bun:test"; + +import { canvasThemes } from "../src/lib/canvas-theme"; + +describe("canvas visual contrast", () => { + test("keeps the canvas substrate distinct from node surfaces in both themes", () => { + for (const theme of Object.values(canvasThemes)) { + expect(theme.canvas.background).not.toBe(theme.node.fill); + expect(theme.node.panel).not.toBe(theme.canvas.background); + } + }); + + test("changes only the canvas substrate while retaining original surfaces", () => { + expect(canvasThemes.light.canvas.background).toBe("#edeeee"); + expect(canvasThemes.light.node.fill).toBe("#ffffff"); + expect(canvasThemes.light.node.edge).toBe("rgba(15,23,42,.16)"); + expect(canvasThemes.light.node.shadow).toBe("0 6px 18px rgba(15,23,42,.08)"); + expect(canvasThemes.light.node.hoverShadow).toBe("0 10px 24px rgba(15,23,42,.12)"); + expect(canvasThemes.light.toolbar.panel).toBe("rgba(255,255,255,.94)"); + expect(canvasThemes.light.spatial.elevated).toBe("rgba(255,255,255,.94)"); + + expect(canvasThemes.dark.canvas.background).toBe("#262626"); + expect(canvasThemes.dark.node.fill).toBe("#181818"); + expect(canvasThemes.dark.node.edge).toBe("rgba(255,255,255,.18)"); + expect(canvasThemes.dark.node.shadow).toBe("0 8px 24px rgba(0,0,0,.34)"); + expect(canvasThemes.dark.node.hoverShadow).toBe("0 12px 30px rgba(0,0,0,.46)"); + expect(canvasThemes.dark.toolbar.panel).toBe("rgba(20,20,20,.97)"); + expect(canvasThemes.dark.toolbar.border).toBe("rgba(255,255,255,.1)"); + expect(canvasThemes.dark.spatial.elevated).toBe("rgba(15,15,15,.97)"); + }); + + test("pins intentional grid tokens while retaining canvas grid opacity", async () => { + expect(canvasThemes.light.canvas.dot).toBe("rgba(15,23,42,.20)"); + expect(canvasThemes.light.canvas.line).toBe("rgba(15,23,42,.15)"); + expect(canvasThemes.dark.canvas.dot).toBe("rgba(205,205,205,.28)"); + expect(canvasThemes.dark.canvas.line).toBe("rgba(205,205,205,.28)"); + + const source = await Bun.file(new URL("../src/components/canvas/infinite-canvas.tsx", import.meta.url)).text(); + expect(source).toContain('opacity: mode === "dots" ? 0.34 : 0.46'); + }); + + test("keeps the original transparent edge for standard canvas nodes", async () => { + const source = await Bun.file(new URL("../src/components/canvas/canvas-node.tsx", import.meta.url)).text(); + + expect(source).toContain('border: isComposerNode ? "0" : "1px solid transparent"'); + expect(source).not.toContain('border: isComposerNode ? "0" : `1px solid ${theme.node.edge}`'); + }); +});