feat(canvas): 自由画布 - 增强空白起点、外观设置与节点连接体验 (#356)

This commit is contained in:
vv-get-ti
2026-08-31 06:49:43 +08:00
committed by GitHub
parent 955e4964e9
commit 9e718fcf4a
22 changed files with 925 additions and 113 deletions

View File

@@ -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`:应用主题背景并保持网格渲染参数。
## 画布节点自动整理
画布底部的“自动整理节点”按钮用于快速改善节点排布,适合处理节点散落、间距不一致或流程方向不清晰的画布。

View File

@@ -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 列收敛,前端生产构建通过。

View File

@@ -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 检查。
- 在窄屏和触屏尺寸补充检查节点、起始框与添加菜单的布局边界。

View File

@@ -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<CanvasCustomAppearance>) => {
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 (
<>
<div className="mt-3 text-[var(--fs-micro)] font-semibold uppercase opacity-45">主题模式</div>
<div className="mt-1 grid grid-cols-3 gap-1 rounded-[var(--dock-item-radius-labeled)] border p-1" style={{ background: theme.spatial.surface, borderColor: theme.toolbar.border }}>
<ThemeButton active={draft.mode === "light"} label="浅色" theme={theme} onClick={() => selectFixedTheme("light")}><Sun className="size-3.5" /></ThemeButton>
<ThemeButton active={draft.mode === "dark"} label="深色" theme={theme} onClick={() => selectFixedTheme("dark")}><Moon className="size-3.5" /></ThemeButton>
<ThemeButton active={draft.mode === "custom"} label="自定义" theme={theme} onClick={selectCustomTheme}><Paintbrush className="size-3.5" /></ThemeButton>
</div>
{draft.mode === "custom" && draft.custom ? (
<div className="mt-2.5 space-y-2.5 rounded-[var(--dock-item-radius-labeled)] border p-2.5" style={{ background: theme.spatial.surface, borderColor: theme.toolbar.border }}>
<div className="flex items-center justify-between gap-2">
<span className="text-[var(--fs-tiny)] font-semibold">背景预设</span>
<span className="text-[var(--fs-micro)] opacity-50">{draft.custom.baseTheme === "dark" ? "深色" : "浅色"}界面</span>
</div>
<div className="grid grid-cols-4 gap-1.5">
{presets.map((color) => (
<button key={color} type="button" className="h-7 rounded-md border transition-transform hover:scale-[1.04] focus-visible:outline focus-visible:outline-2 motion-reduce:transition-none motion-reduce:hover:scale-100" style={{ background: color, borderColor: draft.custom?.backgroundColor === color ? theme.node.activeStroke : theme.toolbar.border }} aria-label={`使用背景颜色 ${color}`} title={color} onClick={() => updateCustom({ backgroundColor: color, backgroundBrightness: 0 })} />
))}
</div>
<Segmented
block
size="small"
aria-label="界面样式"
value={draft.custom.baseTheme}
onChange={(value) => updateCustom({ baseTheme: value as CanvasColorTheme })}
options={[
{ value: "light", label: <span className="inline-flex items-center gap-1"><Sun className="size-3.5" />浅色界面</span> },
{ value: "dark", label: <span className="inline-flex items-center gap-1"><Moon className="size-3.5" />深色界面</span> },
]}
/>
<ColorField label="画布背景" value={draft.custom.backgroundColor} theme={theme} onChange={(backgroundColor) => updateCustom({ backgroundColor, backgroundBrightness: 0 })} />
<SliderField label="明亮度" value={draft.custom.backgroundBrightness} min={-30} max={30} suffix="%" onChange={(backgroundBrightness) => updateCustom({ backgroundBrightness })} />
<ColorField label="网格颜色" value={draft.custom.gridColor} theme={theme} onChange={(gridColor) => updateCustom({ gridColor })} />
<SliderField label="网格强度" value={draft.custom.gridOpacity} min={0} max={100} suffix="%" onChange={(gridOpacity) => updateCustom({ gridOpacity })} />
<Button block size="small" type="text" icon={<RotateCcw className="size-3.5" />} onClick={resetCustom}>从继承主题重新开始</Button>
<Button block size="small" type="primary" icon={<Save className="size-3.5" />} onClick={saveAsDefault}>保存为默认</Button>
</div>
) : null}
<div className="mt-3 text-[var(--fs-micro)] font-semibold uppercase opacity-45">空间网格</div>
<Segmented
className="mt-1 w-full !rounded-[var(--dock-item-radius-labeled)] !p-0.5 [&_.ant-segmented-group]:!flex [&_.ant-segmented-item]:!min-h-7 [&_.ant-segmented-item]:!flex-1 [&_.ant-segmented-item-label]:!min-h-7 [&_.ant-segmented-item-label]:!text-[var(--fs-tiny)] [&_.ant-segmented-item-label]:!leading-7"
value={backgroundMode}
onChange={(value) => onBackgroundModeChange(value as CanvasBackgroundMode)}
options={[
{ value: "dots", label: <span className="inline-flex items-center gap-1.5"><CircleDot className="size-3.5" />点</span> },
{ value: "lines", label: <span className="inline-flex items-center gap-1.5"><Grid2x2 className="size-3.5" />线</span> },
{ value: "blank", label: <span className="inline-flex items-center gap-1.5"><Square className="size-3.5" />空白</span> },
]}
/>
</>
);
}
function ThemeButton({ active, label, theme, onClick, children }: { active: boolean; label: string; theme: CanvasTheme; onClick: () => void; children: ReactNode }) {
return (
<button type="button" className="inline-flex h-8 min-w-0 items-center justify-center gap-1 rounded-[var(--dock-item-radius)] px-1 text-[var(--fs-tiny)] font-semibold transition-colors" style={active ? { background: theme.node.text, color: theme.node.panel } : { color: theme.toolbar.item }} aria-label={`切换到${label}主题`} aria-pressed={active} title={`切换到${label}主题`} onClick={onClick}>
{children}<span className="truncate">{label}</span>
</button>
);
}
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 (
<label className="grid grid-cols-[72px_28px_minmax(0,1fr)] items-center gap-1.5 text-[var(--fs-tiny)] font-medium">
<span>{label}</span>
<ColorPicker disabledAlpha size="small" value={value} onChange={(color) => onChange(color.toHexString().toUpperCase())} />
<Input size="small" value={textValue} aria-label={`${label}色码`} style={{ color: theme.node.text }} onFocus={() => setEditing(true)} onChange={(event) => {
const next = event.target.value;
setTextValue(next);
const normalized = normalizeHexColor(next);
if (normalized) onChange(normalized);
}} onBlur={commit} onPressEnter={commit} />
</label>
);
}
function SliderField({ label, value, min, max, suffix, onChange }: { label: string; value: number; min: number; max: number; suffix: string; onChange: (value: number) => void }) {
return (
<div className="grid grid-cols-[72px_minmax(0,1fr)_38px] items-center gap-1.5 text-[var(--fs-tiny)] font-medium">
<span>{label}</span>
<Slider className="m-0" min={min} max={max} value={value} tooltip={{ open: false }} onChange={onChange} />
<span className="text-right tabular-nums opacity-60">{value > 0 && min < 0 ? "+" : ""}{value}{suffix}</span>
</div>
);
}

View File

@@ -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
<h2 className="text-lg font-semibold">从哪里开始?</h2>
<p className="mt-1 text-sm" style={{ color: theme.node.muted }}>选择一条主路径,之后仍可随时切换。</p>
</div>
<div className="grid gap-3 md:grid-cols-2">
<div className="grid gap-3 md:grid-cols-3">
<PathCard
icon={<Clapperboard className="size-5" />}
title="自己创作"
@@ -60,6 +62,16 @@ export function CanvasShortDramaEmptyState({ onCreatePipeline, onOpenAgent, onUp
focusStyle={focusStyle}
onClick={onOpenAgent}
/>
<PathCard
icon={<Plus className="size-5" />}
title="自由空白画布"
description="不预设流程,自由添加文本、图片、音频和视频。"
action="从空白画布开始"
accent={theme.node.muted}
theme={theme}
focusStyle={focusStyle}
onClick={onStartFreeform}
/>
</div>
<div className="mt-3 flex justify-center">
<Dropdown
@@ -82,16 +94,39 @@ export function CanvasShortDramaEmptyState({ onCreatePipeline, onOpenAgent, onUp
);
}
export function CanvasFreeformEmptyState({ onUpload, onAddText }: { onUpload: () => 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 (
<div className="pointer-events-none absolute inset-0 z-20 grid place-items-center px-4 pb-20 pt-24">
<div className="pointer-events-auto w-full max-w-[440px] rounded-lg border p-4 shadow-sm backdrop-blur" data-canvas-no-zoom style={{ background: theme.toolbar.panel, borderColor: theme.toolbar.border, color: theme.node.text }}>
<div className="text-center"><h2 className="text-base font-semibold">从空白画布开始</h2><p className="mt-1 text-xs" style={{ color: theme.node.muted }}>添加文本或导入已有素材。</p></div>
<div className="mt-4 grid grid-cols-2 gap-2">
<button type="button" onClick={onAddText} className="inline-flex h-10 items-center justify-center gap-2 rounded-md border text-sm font-medium" style={{ borderColor: theme.node.stroke, background: theme.node.fill }}><Type className="size-4" />新建文本</button>
<button type="button" onClick={onUpload} className="inline-flex h-10 items-center justify-center gap-2 rounded-md border text-sm font-medium" style={{ borderColor: theme.node.stroke, background: theme.node.fill }}><Upload className="size-4" />导入素材</button>
</div>
<div className="pointer-events-auto flex min-h-[260px] w-full max-w-[520px] flex-col items-center justify-center rounded-2xl border border-dashed px-8 py-10 text-center backdrop-blur" data-canvas-no-zoom style={{ background: theme.node.fill, borderColor: theme.node.edge, boxShadow: theme.node.shadow, color: theme.node.text }}>
<h2 className="text-base font-semibold">自由空白画布</h2>
<p className="mt-1 text-xs" style={{ color: theme.node.muted }}>不预设流程,从任意一种素材开始创作。</p>
<Popover
arrow={false}
open={createOpen}
onOpenChange={setCreateOpen}
placement="bottom"
trigger="click"
content={<div className="w-[420px] max-w-[calc(100vw-48px)] p-1" onWheel={(event) => event.stopPropagation()}><CanvasCreateMenu commands={createCommands} /></div>}
>
<button
type="button"
aria-label="添加第一项"
className="mt-7 grid size-14 place-items-center rounded-full border outline-none transition-transform hover:scale-105 focus-visible:ring-2 motion-reduce:transition-none motion-reduce:hover:scale-100"
style={{ background: theme.toolbar.panel, borderColor: theme.node.edge, color: theme.node.text, boxShadow: theme.node.shadow, "--tw-ring-color": theme.accent.primary } as CSSProperties}
>
<Plus className="size-6" />
</button>
</Popover>
<p className="mt-4 text-[var(--fs-label)]" style={{ color: theme.node.muted }}>点击 + 添加文本、图片、视频、音频或导入素材</p>
</div>
</div>
);

View File

@@ -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<HTMLDivElement>(null);
const dockRef = useRef<HTMLDivElement>(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 (
<div ref={rootRef} data-canvas-no-zoom className="pointer-events-none absolute inset-x-[var(--canvas-inset-x)] bottom-[var(--canvas-inset-y)] z-[var(--z-toolbar)] flex justify-center">
@@ -206,25 +201,10 @@ export function CanvasToolbar({
<AnimatePresence>
{appearanceOpen ? (
<motion.div initial={{ opacity: 0, scaleY: 0.9, y: 8 }} animate={{ opacity: 1, scaleY: 1, y: 0 }} exit={{ opacity: 0, scaleY: 0.92, y: 6 }} transition={{ duration: aceternityMotion.duration.panel, ease: aceternityMotion.easing.enter }} className="pointer-events-auto absolute bottom-[var(--canvas-dock-popover-offset)] z-[var(--dock-z-popover)] w-[224px] max-w-[calc(100vw-24px)]" style={{ left: panelX || "50%", transformOrigin: "bottom center", x: "-50%" }}>
<motion.div initial={{ opacity: 0, scaleY: 0.9, y: 8 }} animate={{ opacity: 1, scaleY: 1, y: 0 }} exit={{ opacity: 0, scaleY: 0.92, y: 6 }} transition={{ duration: aceternityMotion.duration.panel, ease: aceternityMotion.easing.enter }} className="pointer-events-auto absolute bottom-[var(--canvas-dock-popover-offset)] z-[var(--dock-z-popover)] w-[320px] max-w-[calc(100vw-24px)]" style={{ left: panelX || "50%", transformOrigin: "bottom center", x: "-50%" }}>
<SpotlightSurface spotlightColor={theme.toolbar.itemHover} initial={{ opacity: 0, scale: 0.97 }} animate={{ opacity: 1, scale: 1 }} exit={{ opacity: 0, scale: 0.97, transition: { duration: 0 } }} transition={{ duration: aceternityMotion.duration.instant, ease: aceternityMotion.easing.enter }} className="aceternity-floating-panel overflow-hidden rounded-[var(--panel-radius)] border p-2.5 backdrop-blur-2xl" style={{ background: theme.spatial.elevated, borderColor: theme.toolbar.border, color: theme.toolbar.item }} onWheel={(event) => event.stopPropagation()}>
<PanelHeading icon={<Palette className="size-4" />} title="画布外观" subtitle="调整整个创作空间" theme={theme} />
<div className="mt-3 text-[var(--fs-micro)] font-semibold uppercase opacity-45">主题模式</div>
<div className="mt-1 grid grid-cols-2 gap-1 rounded-[var(--dock-item-radius-labeled)] border p-1" style={{ background: theme.spatial.surface, borderColor: theme.toolbar.border }}>
<CanvasThemeButton colorTheme={colorTheme} targetTheme="light" onThemeChange={setTheme}><Sun className="size-3.5" />浅色</CanvasThemeButton>
<CanvasThemeButton colorTheme={colorTheme} targetTheme="dark" onThemeChange={setTheme}><Moon className="size-3.5" />深色</CanvasThemeButton>
</div>
<div className="mt-3 text-[var(--fs-micro)] font-semibold uppercase opacity-45">空间网格</div>
<Segmented
className="mt-1 w-full !rounded-[var(--dock-item-radius-labeled)] !p-0.5 [&_.ant-segmented-group]:!flex [&_.ant-segmented-item]:!min-h-7 [&_.ant-segmented-item]:!flex-1 [&_.ant-segmented-item-label]:!min-h-7 [&_.ant-segmented-item-label]:!text-[var(--fs-tiny)] [&_.ant-segmented-item-label]:!leading-7"
value={backgroundMode}
onChange={(value) => onBackgroundModeChange(value as CanvasBackgroundMode)}
options={[
{ value: "dots", label: <span className="inline-flex items-center gap-1.5"><CircleDot className="size-3.5" />点</span> },
{ value: "lines", label: <span className="inline-flex items-center gap-1.5"><Grid2x2 className="size-3.5" />线</span> },
{ value: "blank", label: <span className="inline-flex items-center gap-1.5"><Square className="size-3.5" />空白</span> },
]}
/>
<CanvasAppearanceControls appearance={appearance} backgroundMode={backgroundMode} colorTheme={colorTheme} theme={theme} onAppearanceChange={onAppearanceChange} onSaveAppearanceDefault={onSaveAppearanceDefault} onBackgroundModeChange={onBackgroundModeChange} />
<div className="mt-2.5 flex items-center justify-between gap-2 rounded-[var(--dock-item-radius-labeled)] border px-2.5 py-2" style={{ background: theme.spatial.surface, borderColor: theme.toolbar.border }}>
<span className="inline-flex min-w-0 items-center gap-1.5 text-[var(--fs-tiny)] font-semibold"><Info className="size-3" />图片信息</span>
<Switch size="small" checked={showImageInfo} onChange={onShowImageInfoChange} />
@@ -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 (
<AnimatedThemeToggler
theme={colorTheme}
targetTheme={targetTheme}
onThemeChange={onThemeChange}
className="inline-flex h-8 min-w-0 items-center justify-center gap-1.5 rounded-[var(--dock-item-radius)] px-2 text-xs font-semibold transition-colors"
style={active ? { background: theme.node.text, color: theme.node.panel } : { color: theme.toolbar.item }}
aria-label={`切换到${targetTheme === "dark" ? "深色" : "浅色"}主题`}
title={`切换到${targetTheme === "dark" ? "深色" : "浅色"}主题`}
>
{children}
</AnimatedThemeToggler>
);
}
function getPanelX(dock: HTMLDivElement | null, target: HTMLElement) {
if (!dock) return 0;
const rootBox = dock.parentElement?.getBoundingClientRect() || dock.getBoundingClientRect();

View File

@@ -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<HTMLDivElement | null>;
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}
>
<CanvasGrid mode={backgroundMode} />
<CanvasGrid appearance={appearance} mode={backgroundMode} />
{graphicsLayer}
<div
data-canvas-world-layer
@@ -418,9 +419,10 @@ export function InfiniteCanvas({ containerRef, viewport, backgroundMode = "lines
);
}
function CanvasGrid({ mode }: { mode: CanvasBackgroundMode }) {
const theme = canvasThemes[useThemeStore((state) => state.theme)];
const backgroundImage = mode === "dots" ? `radial-gradient(circle, ${theme.canvas.dot} var(--canvas-dot-size), transparent calc(var(--canvas-dot-size) + 0.2px))` : `linear-gradient(${theme.canvas.line} 1px, transparent 1px), linear-gradient(90deg, ${theme.canvas.line} 1px, transparent 1px)`;
function CanvasGrid({ appearance, mode }: { appearance?: CanvasAppearance; mode: CanvasBackgroundMode }) {
const colorTheme = useThemeStore((state) => 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 (

View File

@@ -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();
},
}));
}

View File

@@ -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: {

View File

@@ -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<CanvasAppearance>;
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<CanvasAppearanceDefault>;
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<CanvasCustomAppearance>;
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)})`;
}

View File

@@ -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,
};
}

View File

@@ -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";
}

View File

@@ -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<string | null>(null);
const [contextMenu, setContextMenu] = useState<ContextMenuState | null>(null);
const [isMiniMapOpen, setIsMiniMapOpen] = useState(false);
const [canvasAppearance, setCanvasAppearance] = useState<CanvasAppearance>(() => canvasAppearanceForTheme(colorTheme));
const [backgroundMode, setBackgroundMode] = useState<CanvasBackgroundMode>("lines");
const [showImageInfo, setShowImageInfo] = useState(false);
const [canvasTool, setCanvasTool] = useState<CanvasToolMode>("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 ? (
<CanvasFreeformEmptyState onUpload={() => 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" ? (
<CanvasFreeformEmptyState commands={freeformCreateCommands} />
) : emptyStateKind === "linked" ? (
<CanvasLinkedProjectEmptyState
projectName={linkedProjectQuery.data?.project.name || currentProject.title}
projectName={linkedProjectQuery.data?.project.name || currentProject?.title || "项目画布"}
hasChapter={Boolean(linkedProjectQuery.data?.units.length)}
onAddFirstChapter={() => {
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" ? (
<CanvasShortDramaEmptyState
onCreatePipeline={createShortDramaPipeline}
onOpenAgent={() => {
@@ -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 <CanvasRefreshShell />;
return (
@@ -1980,7 +2030,7 @@ function InfiniteCanvasPage() {
>
跳转到画布主内容
</a>
<main id="canvas-main" tabIndex={-1} className="flex h-full min-h-0 overflow-hidden outline-none" style={{ background: theme.canvas.background, color: theme.node.text }}>
<main id="canvas-main" tabIndex={-1} className="flex h-full min-h-0 overflow-hidden outline-none" style={{ background: resolvedCanvasAppearance.background, color: theme.node.text }}>
{!focusMode && shortDramaEnabled && currentProject?.projectId ? (
<CanvasProjectSidebar projectId={currentProject.projectId} detail={linkedProjectQuery.data} onAddChapter={handleProjectChapterInsert} onLocateStyle={locateProjectStyleNode} onOpenAssets={() => openProjectAssets()} />
) : null}
@@ -2078,6 +2128,7 @@ function InfiniteCanvasPage() {
<InfiniteCanvas
containerRef={containerRef}
viewport={viewport}
appearance={canvasAppearance}
backgroundMode={backgroundMode}
graphicsLayer={
<CanvasLeaferGraphicsLayer
@@ -2217,6 +2268,7 @@ function InfiniteCanvasPage() {
isProjectLinked={Boolean(shortDramaEnabled && currentProject?.projectId)}
canUndo={historyState.canUndo}
canRedo={historyState.canRedo}
appearance={canvasAppearance}
backgroundMode={backgroundMode}
showImageInfo={showImageInfo}
onAddImage={() => 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={() => {

View File

@@ -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<HTMLDivElement>(null);
const viewportRef = useRef<ViewportTransform>({ x: 0, y: 0, k: 1 });
const dragRef = useRef<DragState | null>(null);
@@ -34,6 +36,7 @@ export default function SharedCanvasPage() {
const [title, setTitle] = useState("共享画布");
const [nodes, setNodes] = useState<CanvasNodeData[]>([]);
const [connections, setConnections] = useState<Awaited<ReturnType<typeof getPublicCanvasShare>>["project"]["connections"]>([]);
const [appearance, setAppearance] = useState<CanvasAppearance>(() => canvasAppearanceForTheme(colorTheme));
const [backgroundMode, setBackgroundMode] = useState<"lines" | "dots" | "blank">("lines");
const [viewport, setViewport] = useState<ViewportTransform>({ x: 0, y: 0, k: 1 });
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(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 <div className="grid h-screen place-items-center px-5" style={{ background: theme.canvas.background }}><WorkspaceState icon="error" title="分享链接不可用" description={loadError} action={<Link to="/"><Button>返回首页</Button></Link>} /></div>;
return (
<main className="relative h-screen overflow-hidden" style={{ background: theme.canvas.background, color: theme.node.text }}>
<main className="relative h-screen overflow-hidden" style={{ background: resolveCanvasAppearance(appearance, colorTheme).background, color: theme.node.text }}>
<header className="pointer-events-none absolute inset-x-0 top-0 z-[var(--z-panel-floating)] flex h-16 items-center justify-between px-5">
<div className="pointer-events-auto flex min-w-0 items-center gap-3">
<Share2 className="size-4" style={{ color: theme.node.muted }} />
@@ -232,7 +251,7 @@ export default function SharedCanvasPage() {
<Link className="pointer-events-auto" to="/login"><Button type="text" icon={<LogIn className="size-4" />}>登录</Button></Link>
</header>
<InfiniteCanvas containerRef={containerRef} viewport={viewport} backgroundMode={backgroundMode} onViewportChange={onViewportChange} onViewportPreviewChange={(next) => { viewportRef.current = next; }} onCanvasDeselect={() => { setSelectedNodeId(null); setContextMenu(null); }} onContextMenu={(event) => openContextMenu(event)} onDrop={(event) => { event.preventDefault(); unauthorized(); }}>
<InfiniteCanvas containerRef={containerRef} viewport={viewport} appearance={appearance} backgroundMode={backgroundMode} onViewportChange={onViewportChange} onViewportPreviewChange={(next) => { viewportRef.current = next; }} onCanvasDeselect={() => { setSelectedNodeId(null); setContextMenu(null); }} onContextMenu={(event) => openContextMenu(event)} onDrop={(event) => { event.preventDefault(); unauthorized(); }}>
<svg className="absolute overflow-visible" viewBox={`${connectionBounds.left} ${connectionBounds.top} ${connectionBounds.width} ${connectionBounds.height}`} style={{ left: connectionBounds.left, top: connectionBounds.top, width: connectionBounds.width, height: connectionBounds.height, pointerEvents: "none", zIndex: 0 }}>
{visibleConnections.map(({ connection, from, to }) => <ConnectionPath key={connection.id} connection={connection} from={from} to={to} active={false} onSelect={() => setInfoNodeId(to.id)} />)}
</svg>

View File

@@ -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} · 视频`;

View File

@@ -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<CanvasConnection>;
chatSessions?: EntityPatch<CanvasAssistantSession>;
activeChatId?: ValuePatch<string | null>;
canvasAppearance?: ValuePatch<CanvasAppearance>;
backgroundMode?: ValuePatch<CanvasBackgroundMode>;
showImageInfo?: ValuePatch<boolean>;
};
@@ -44,6 +47,7 @@ type UseCanvasHistoryOptions = CanvasHistorySnapshot & {
setConnections: Dispatch<SetStateAction<CanvasConnection[]>>;
setChatSessions: Dispatch<SetStateAction<CanvasAssistantSession[]>>;
setActiveChatId: Dispatch<SetStateAction<string | null>>;
applyCanvasAppearance: (appearance: CanvasAppearance) => void;
setBackgroundMode: Dispatch<SetStateAction<CanvasBackgroundMode>>;
setShowImageInfo: Dispatch<SetStateAction<boolean>>;
setSelectedNodeIds: Dispatch<SetStateAction<Set<string>>>;
@@ -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,
};

View File

@@ -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<SetStateAction<CanvasConnection[]>>;
setChatSessions: Dispatch<SetStateAction<CanvasAssistantSession[]>>;
setActiveChatId: Dispatch<SetStateAction<string | null>>;
setCanvasAppearance: Dispatch<SetStateAction<CanvasAppearance>>;
setBackgroundMode: Dispatch<SetStateAction<CanvasBackgroundMode>>;
setShowImageInfo: Dispatch<SetStateAction<boolean>>;
setViewport: Dispatch<SetStateAction<ViewportTransform>>;
@@ -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: [] });

View File

@@ -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<Pick<CanvasProject, "projectId" | "nodes" | "connections" | "chatSessions" | "activeChatId" | "backgroundMode" | "showImageInfo" | "viewport" | "directorScenes" | "timeline">>) => void;
updateProject: (id: string, patch: Partial<Pick<CanvasProject, "projectId" | "nodes" | "connections" | "chatSessions" | "activeChatId" | "starterMode" | "appearance" | "backgroundMode" | "showImageInfo" | "viewport" | "directorScenes" | "timeline">>) => void;
};
const initialViewport: ViewportTransform = { x: 0, y: 0, k: 1 };
@@ -418,6 +422,7 @@ export const useCanvasStore = create<CanvasStore>()(
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<CanvasStore>()(
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<CanvasStore>()(
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,

View File

@@ -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<string, string>();
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();
});
});

View File

@@ -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 });
});
});

View File

@@ -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");
});
});

View File

@@ -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}`');
});
});