feat(canvas): 自由画布 - 增强空白起点、外观设置与节点连接体验 (#356)
This commit is contained in:
@@ -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`:应用主题背景并保持网格渲染参数。
|
||||
|
||||
## 画布节点自动整理
|
||||
|
||||
画布底部的“自动整理节点”按钮用于快速改善节点排布,适合处理节点散落、间距不一致或流程方向不清晰的画布。
|
||||
|
||||
@@ -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 列收敛,前端生产构建通过。
|
||||
|
||||
@@ -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 检查。
|
||||
- 在窄屏和触屏尺寸补充检查节点、起始框与添加菜单的布局边界。
|
||||
|
||||
170
web/src/components/canvas/canvas-appearance-controls.tsx
Normal file
170
web/src/components/canvas/canvas-appearance-controls.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 (
|
||||
|
||||
23
web/src/components/canvas/use-canvas-create-commands.ts
Normal file
23
web/src/components/canvas/use-canvas-create-commands.ts
Normal 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();
|
||||
},
|
||||
}));
|
||||
}
|
||||
@@ -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: {
|
||||
|
||||
174
web/src/lib/canvas/canvas-appearance.ts
Normal file
174
web/src/lib/canvas/canvas-appearance.ts
Normal 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)})`;
|
||||
}
|
||||
13
web/src/lib/canvas/canvas-connected-node-placement.ts
Normal file
13
web/src/lib/canvas/canvas-connected-node-placement.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
20
web/src/lib/canvas/canvas-starter.ts
Normal file
20
web/src/lib/canvas/canvas-starter.ts
Normal 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";
|
||||
}
|
||||
@@ -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={() => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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} · 视频`;
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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: [] });
|
||||
|
||||
@@ -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,
|
||||
|
||||
155
web/test/canvas-appearance.test.ts
Normal file
155
web/test/canvas-appearance.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
13
web/test/canvas-connected-node-placement.test.ts
Normal file
13
web/test/canvas-connected-node-placement.test.ts
Normal 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 });
|
||||
});
|
||||
});
|
||||
21
web/test/canvas-starter.test.ts
Normal file
21
web/test/canvas-starter.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
48
web/test/canvas-visual-contrast.test.ts
Normal file
48
web/test/canvas-visual-contrast.test.ts
Normal 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}`');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user