Integrate Canvas V2 subgraphs, editable edges and AI runs
This commit is contained in:
@@ -54,7 +54,7 @@ Google / Slack 的提供方回调仍是 Foundry 后端地址;后端的项目
|
||||
- 本地演示提供示例图谱、搜索、新建和浏览器保存,支持图谱独立地址与刷新。
|
||||
- 图谱页面使用 React Flow 12.12.1,支持空白处平移、节点拖动、连线、多选、缩放和文本编辑。文字、脚本、图片节点和创作模板可从底部悬浮工具栏插入。
|
||||
- 右上角悬浮框提供撤销、重做、缩放、恢复 100%、适应画布和 JSON 导出;底部支持 JSON 导入及操作帮助。
|
||||
- 真实图谱先读取 Core Canvas 快照,再开放编辑;节点、样式、连线与视图以独立版本串行自动保存。旧本地草稿需用户明确选择恢复;版本冲突停止覆盖并保留恢复记录。演示模式仅本地保存。分区框及成员归属已匹配 Core 022086b 的保存契约,详见 docs/CORE_FRAME_EXTENSION.md。
|
||||
- 真实图谱先读取 Core Canvas 快照,再开放编辑;节点、样式、连线与视图以独立版本串行自动保存。旧本地草稿需用户明确选择恢复;版本冲突停止覆盖并保留恢复记录。演示模式仅本地保存。支持 Core Canvas V1/V2;V2 分区是真实子画布,内部内容按所属画布独立保存。
|
||||
- 分享、Agent、收藏、自由绘图及多人协同尚未接入。画布覆盖整个视口,侧栏和快捷浮框覆盖其上;演示数据不写入线上。
|
||||
- 收起侧栏后,左上角浮框显示 Logo 和用户头像;头像与展开侧栏底部的账户按钮打开同一个菜单。
|
||||
- 本地演示仅用于查看界面,不提供后端认证与权限。
|
||||
@@ -88,57 +88,36 @@ API 测试使用本机临时后端和独立前端进程,检查会话 Cookie、
|
||||
|
||||
## 画布操作
|
||||
|
||||
- 拖动空白处平移;拖动节点标题或边框移动节点。抓手模式下也可从节点内容处平移。
|
||||
- V 选择、H 抓手,按住空格临时平移,Shift 框选;拖动节点两侧的圆点创建连线。
|
||||
- 触控板滚动平移,Ctrl / ⌘ 加滚动或双指捏合缩放。右上角提供缩放和适应画布。
|
||||
- 底部可添加文字、图片、脚本或三节点创作模板。图片支持 PNG/JPEG/WebP/GIF,单张上限 2 MB。
|
||||
- Ctrl / ⌘ + Z 撤销,Shift + Ctrl / ⌘ + Z 重做;支持 JSON 导入导出和 40 步撤销历史。
|
||||
- 状态与导入回归:`node --experimental-strip-types --test tests/canvas.test.mjs tests/workspace.test.mjs`。
|
||||
- 单击选择,双击编辑文本或卡片名称。纯文本与图片可整块拖动;选中时四侧连接点可拖线。
|
||||
- 触控板滚动平移,Ctrl / ⌘ 加滚动或捏合缩放。V 选择,H 抓手,空格临时平移,Shift 框选。
|
||||
- 上方编辑栏提供文字样式、旋转和八向拉伸模式。旋转时 Shift 吸附 15°;八向拉伸时 Shift 固定比例,Esc 退出。图标保留等比缩放预设。
|
||||
- 连线可单独选择:颜色、粗细、透明度、实/虚/点线、箭头与箭尾形状/大小/填充均可设置。双击线或点击添加按钮插入曲线点,拖动锚点和控制柄制作多段贝塞尔链;支持平滑、镜像和独立控制柄。
|
||||
- 连线动画可关闭、始终播放或仅执行时播放,包含沿完整曲线的流光、流动虚线和脉冲;方向、颜色和时长可设。执行动画由真实任务边事件驱动。
|
||||
- 底部 AI 模式可保持开启;选择文本/图片/卡片后,上方为编辑栏,下方为固定屏幕尺寸的 AI 面板。模型从后端目录读取,支持原地生成、取消及可选下游传播;冲突结果保留供明确应用。
|
||||
- 新选框通过服务端事务创建并关联真实子画布。标签用三个图标切换隐藏/固定字号/随缩放,悬停查看解释。选框可收起、展开及调整概览比例,子图真实内容尺寸不变。
|
||||
- 展开内容使用子画布投影,编辑保存到实际 owner;不会在父图复制一份。拖入/移出使用多画布事务;新跨图连线按后端能力通过 connections 命令创建端口链。删除选框解散并保留内容。
|
||||
- 原地生成与用户拖动可合并;内容冲突保留本地草稿。网络结果不确定时暂停编辑,用相同幂等请求重试或重新读取。每个画布有独立版本与串行保存器。
|
||||
- 图标面板支持 2313 个 Lucide 和 24 个 Tabler 图标试用、分类/搜索、虚拟列表及本地图形分片加载。未打开面板不加载目录。
|
||||
- 有子图时导出完整 owner 文档集合;关系型子图备份不直接覆盖导入到其他画布。历史 V1/关系不兼容草稿可导出,不能覆盖 V2 子图关系。
|
||||
|
||||
## 悬浮栏尺寸
|
||||
## Canvas V2 接口与检查
|
||||
|
||||
左上角显示当前图谱标题,复用页面已读取的资料,不额外请求后端;长标题省略并可悬停查看。左上、右上、底部与帮助按钮共用 `toolbar.module.css`:图标/Logo/浮框头像 20px,图标按钮含 padding 的总尺寸为 32×32px,主文字 14px,容器内边距 4px,间隙 4px,圆角 8px。悬浮栏距屏幕边缘统一为 12px;右上角窄屏时下移避免与标题重叠。
|
||||
后端基线由用户交付的部署 `b4e537b`、CI #912/#913 与指定测试画布迁移摘要确认。跨图 connections 扩展以线上 `capabilities.subgraphs.connections` 为准;客户端先检查能力,再发事务。前端未重复运行迁移或调用线上付费模型。
|
||||
|
||||
## 图谱改名
|
||||
同源代理转发 V2 Accept、If-Match、Idempotency-Key 和事件分页查询,保留 ETag/schema/Retry-After;受管图片通过带会话的二进制代理读取。快照上限 5 MiB,图片上传支持受限 multipart;服务令牌不会发给浏览器。
|
||||
|
||||
左上角标题第一次点击选中,选中后再次点击进入编辑;回车或失焦保存,Esc 取消,F2 也可直接编辑。真实图谱改名先读取最新资料和版本,再携带 If-Match 更新名称,保留描述与内容;版本冲突时提示重试。演示模式保存到本地。保存后标题、侧栏列表及导出文件名同步更新。
|
||||
验证命令:
|
||||
|
||||
左上和底部工具栏按右上视图控件的分组节奏增加横向留白(间隙 6px、横向内边距 6px),按钮仍为 32×32px,竖向内边距仍为 4px。三处分隔线统一;左侧标题不再额外叠加外边距,右上现有样式保持不变。
|
||||
```sh
|
||||
npm run typecheck
|
||||
node --experimental-strip-types --test tests/*.test.mjs
|
||||
CANVAS_UI_TOOLS=/path/to/isolated-tools node tests/canvas-ui-check.mjs
|
||||
```
|
||||
|
||||
卡片选中时,四边正中心淡入固定尺寸的小连接点;取消选中后淡出隐藏,不再显示大加号。拖动连接点建立贝塞尔连线,曲线直接连接两端卡片边缘中心,缩放及旋转后同步更新;选中后点击连接点打开菜单,选择文本、上传图片或脚本后再创建节点并连线。选中卡片拖线到空白处,也会在释放位置打开类型菜单。新增节点与连线组成一步撤销。
|
||||
DOM 工具仅需在临时目录安装 esbuild/jsdom,见测试文件开头;不启动浏览器。生产构建与 `tests/api-smoke.mjs` 使用隔离副本和临时本地服务,避免影响现有开发服务器。实际结果及限制见 VALIDATION.md。
|
||||
|
||||
## 元素变换、样式与继承
|
||||
Headless Canvas API 的拆包方案保留在本地忽略的 `docs/HEADLESS_CANVAS_API_DRAFT.md`,当前没有创建或发布该库。其他已被替代的元素/分区草案已清理。
|
||||
|
||||
- 右下角缩放符号始终显示,拖动会选中并调整大小,已移除八向缩放控件。样式浮栏中的旋转按钮开启旋转模式,拖动元素旋转,Shift 吸附 15°;点击外部或按 Esc 退出。纯文本宽、高独立调整,文字随宽度换行,字号由样式栏控制;图标保持等比缩放。
|
||||
- 卡片标题位于框外,整个框内是文本编辑区域;卡片与纯文本均取消独立的聚焦内框。
|
||||
- 样式浮栏提供文字颜色、字体、字号、加粗、斜体、下划线、对齐、行距、背景及旋转;样式作用于整个文本节点。选中与取消选中、菜单均有动画,尊重系统减少动态效果偏好。
|
||||
- 底部 T 添加纯文本,便签按钮添加文本卡片。纯文本没有卡片外框、连接点或四向加号。
|
||||
- 底部图形按钮打开左侧图标浮层,可搜索并分批加载 2313 个 Lucide 图标;图标支持移动、缩放、旋转和颜色设置,无需额外图标目录服务。
|
||||
- 大小、角度、样式、字号和图标标识通过独立 Canvas 快照接口存入数据库,并保留本地恢复记录及 JSON 导出。保存使用独立版本及 If-Match,串行提交并合并等待中的更新,冲突后停止自动写入。分区框、名称模式和成员归属已随 Core 022086b 部署支持,使用同一个快照接口保存。
|
||||
- 业务类继承为 `CanvasElement → CardElement → TextCardElement / ImageCardElement / ScriptCardElement`;`PlainTextElement` 和 `IconElement` 直接继承 `CanvasElement`。基础元素拥有缩放和旋转能力,卡片层追加连线和分支能力。React 组件通过组合共享 `TransformableNode`。
|
||||
- `/dashboard/{workspace}/{board}` 的两个 ID 分别标识工作空间和图谱。
|
||||
- 回归:`node --experimental-strip-types --test tests/canvas-style.test.mjs tests/canvas-geometry.test.mjs tests/canvas.test.mjs tests/workspace.test.mjs`。
|
||||
## 会话期限
|
||||
|
||||
## 框选区
|
||||
|
||||
底部「添加框选区」插入可调整大小的分区;选中框可改名,名称提供隐藏、固定屏幕字号、随画布缩放三种模式。拖动边框或名称移动分区,右下角调整大小。框在内容下方,内部空白仍可平移画布。
|
||||
|
||||
只有实际拖入的内容才建立归属,以内容中心进入分区为准;拖出后退出,重叠区域优先较小分区。移动分区只带动已有成员,不会自动收纳覆盖到的其他内容;删除分区会保留成员位置与内容。单次拖动及归属变化可以一步撤销,分区设置和归属进入草稿与 JSON。
|
||||
|
||||
## 服务端快照与 slug 路由
|
||||
|
||||
Core 57aab71 提供基础快照,022086b 已补齐分区框并部署。前端允许 `/workspaces/{workspace}/materials/{board}/canvas`,保留 ETag / If-Match,只为 Canvas 路由放宽至 5 MiB。打开画布并行读取材料信息和快照;读取失败时不开放空白编辑器,避免覆盖远程数据。800ms 防抖提交,更新串行化;失败停止自动写入并提供重试、导出和重新读取。旧草稿和未同步恢复记录都需明确选择,默认使用服务端快照。
|
||||
|
||||
URL 的 workspace 与 board 可分别使用 ID 或 slug,支持全部四种组合。以接口返回的 canonical ID 做身份校验、本地恢复隔离与保存路径,侧栏高亮、标题及改名支持 slug;新侧栏链接优先使用可用 slug。改名不自动更改 slug。
|
||||
|
||||
分区字段契约:[CORE_FRAME_EXTENSION.md](docs/CORE_FRAME_EXTENSION.md)。前端已按该契约保存分区,无需额外数据库结构或接口。
|
||||
|
||||
首页现提供可点击的画布预览、三段创作说明、FAQ 和本地演示入口;已登录访问 `/` 仍跳转无画布的 `/dashboard`。
|
||||
|
||||
## 最新编辑交互
|
||||
|
||||
文本卡片、脚本和纯文本统一为单击选中、双击进入编辑(采用系统双击窗口);选中后隔很久再单击不会误进编辑。非编辑状态可直接拖动整个文本区域,不再使用纯文本左上角拖动按钮;Esc 或失焦退出编辑。卡片外部标题可双击改名。
|
||||
|
||||
底部同时提供图片卡片与纯图片入口;纯图片按原图比例插入,没有卡片标题/连接点,支持整图拖动、等比缩放及旋转。分区工具栏显式提高层级,修复可见但无法操作的问题。完整继承关系见 [CANVAS_ELEMENTS.md](docs/CANVAS_ELEMENTS.md)。
|
||||
|
||||
会话策略已按确认方案准备前后端实现:30 天闲置续期、90 天绝对期限,Core PR #10 已随主线 7847167 的部署 #889 成功上线;既有过期会话不会复活,有效会话可在下一次鉴权时续期。详见 [SESSION_LIFETIME.md](docs/SESSION_LIFETIME.md)。纯图片快照配套 Core PR #11(codex/plain-images)已随部署 #889 上线;其余已部署节点保存保持兼容。
|
||||
会话为可撤销的随机令牌。30 天闲置续期、90 天绝对期限由 Core 控制,代理按剩余期限更新 HttpOnly Cookie;既有过期会话不恢复。详细本地说明为 `docs/SESSION_LIFETIME.md`。
|
||||
|
||||
+19
-171
@@ -1,179 +1,27 @@
|
||||
# 本地验收记录
|
||||
# Canvas V2 验收记录(2026-11-06)
|
||||
|
||||
## 双击编辑、纯图片、节点名称、完整图标库与会话策略(2026-11-06)
|
||||
## 前端检查
|
||||
|
||||
- CanvasTextEditor 统一文本卡片、脚本与纯文本:单击选择,原生 dblclick 编辑;拖动不进入编辑,Esc/失焦退出。纯文本移除左上角手柄,选中后整块区域可拖动。
|
||||
- 右下角缩放符号在未选中时也显示,拖动先选中;抓手模式仍可见但不响应缩放。连接点保持仅选中显示。
|
||||
- 新增 PlainImageElement / PlainImageNode,按图片原始尺寸等比插入及缩放,支持旋转、拖动与分区归属,无卡片标题和连线。新增可撤销节点改名。
|
||||
- 修复 Frame 的 NodeToolbar 默认 zIndex=0 被画布交互层覆盖的问题,工具栏显式设为 zIndex=100、pointerEvents=auto;分区名称输入和模式选择可响应。
|
||||
- Lucide 改为包内完整动态目录,2317 个名称合并数字别名后提供 2313 个唯一存储标识。支持中英文关键词、每批 80 项和按需图标模块加载;测试校验全部标识满足 Core 格式且无冲突。
|
||||
- 前端 Cookie 改为 30 天上限,并按 Core 的 X-Foundry-Session-Expires-In 续期;保留 HttpOnly/SameSite/HTTPS Secure,401 和非法续期头不续期。Core 策略使用独立工作分支 PR,不直接推送或合并 Core main。
|
||||
- 51 项前端测试、类型检查及隔离生产构建通过;接口冒烟验证 30 天 Cookie、绝对期限附近缩短到剩余秒数、非法头与 401 不续期,并保持已有快照和路由流程通过。
|
||||
- Core 会话策略提交 34c461a 的远程 CI #881 通过,包含构建、迁移和隔离数据库测试;PR #10 已合并。纯图片配套 Core 提交 460b05f 的 CI #883 同样通过,PR #11 也已合并;最新主线 7847167 的 CI #888 与部署 #889 均通过。代理未调用合并或部署操作。
|
||||
- 未访问项目浏览器,因此双击/拖动手感、弹层视觉与真实账户体验仍待用户试用;前端按首页、工作台、文档分组提交,统一推送;原有 next-env.d.ts 与 pnpm 未提交文件保留。
|
||||
- `npm run typecheck` 通过。
|
||||
- `node --experimental-strip-types --test tests/*.test.mjs`:69 项测试通过,覆盖既有行为、V2 无损往返、端口方向与曲线字段校验、投影与逆变换、局部/远端三方合并、草稿兼容、八向 Shift 等比、LCA 连接计划和父子共同拖动不重复位移。
|
||||
- `CANVAS_UI_TOOLS=/private/tmp/endoleap-ui-check node tests/canvas-ui-check.mjs`:真实 React Flow + DOM 事件回归通过,使用临时安装的 esbuild/jsdom,不访问浏览器。
|
||||
- DOM 覆盖:旋转与撤销重做、三图标标签切换、八向手柄、连线选择/曲线点/marker/动画;子画布独立读取与保存、收起/展开、创建及撤销重做;模型按输出类型过滤、文本传播、图片原节点回写、取消与生成期间保留几何。
|
||||
- 新跨图连接以实际 Handle 鼠标事件触发,验证能力门槛、最低共同祖先、准确的所有参与版本,以及已经提交但丢失响应后的相同 body/key 重试;不会重复创建端口或边。
|
||||
- API 冒烟覆盖 V2 Accept/If-Match/Idempotency-Key、子图/连接/运行命令白名单、事件分页参数、带 Cookie 的图片二进制响应、MIME 限制、multipart 上传、原有 OAuth/会话/同源校验和 5 MiB 限制。
|
||||
- 隔离生产构建 `npm run build -- --webpack` 及最终 `node tests/api-smoke.mjs` 均通过;构建在临时副本执行,不改变正在使用的开发服务器产物。
|
||||
|
||||
## 框选区、服务端快照、slug 与新首页(2026-11-06)
|
||||
## 后端与测试画布证据边界
|
||||
|
||||
- 框选区采用独立 FrameElement,支持命名、hidden/fixed/scaled 三种标签显示,固定字号通过逆视图缩放保持屏幕尺寸;分区不旋转、不连线。成员记录使用绝对坐标和 frameId,只有内容拖动结束时更新归属;移动框及调整大小不会收纳被覆盖内容。
|
||||
- 单个或多选内容拖入/拖出可加入/退出,移动已有分区会同步移动成员,显式选中的成员不会移动两遍。删除框保留成员,撤销可恢复框与归属。Frame/成员/标签设置均通过草稿和 JSON 往返校验。
|
||||
- 对照已部署 Core 57aab71 接入 Canvas GET/PATCH、5 MiB、ETag/If-Match 与独立版本。加载失败不创建空快照;自动保存串行提交并合并等待更新,拒绝写入后停止自动重试。412 提供重新读取和导出,其他错误允许明确重试。
|
||||
- 旧草稿与未同步恢复记录通过原生模态对话框由用户选择,默认服务端优先;草稿与服务端不一致时不自动覆盖。导航/关闭时同步写入本地恢复记录,成功服务器保存后标记为干净备份。
|
||||
- workspace 与 board 的 ID/slug 四种组合可读取同一个 canonical 资源,恢复记录与保存使用 canonical ID;侧栏高亮、当前名称及改名修复不再把 slug 当作 ID 比较。
|
||||
- 首页以 CSS Module 隔离重做:留白与分栏、黑色按钮、衬线文字、SVG 线稿与交互预览、三段工作方式、FAQ、真实本地演示入口;匿名/登录入口分流保持。包含窄屏样式和减少动态效果设置。
|
||||
- 46 项自动测试通过,包含分区拖入/拖出及不误收纳、成员移动与删除撤销、三种标签序列化、串行保存/合并/失败重试/412停止、旧草稿迁移决策,以及全部 ID/slug 路径组合。
|
||||
- 隔离 Webpack 生产构建及扩展 API 冒烟通过;HTTP 回归覆盖 Canvas 读写、1 MiB 以上快照还原、ETag/If-Match、412、5 MiB 限制、四种 slug/ID 组合,既有会话/路由/88 个品牌资源保持通过。
|
||||
- Core 随后通过 PR #9 交付分区字段,022086b 的部署 #877 已确认成功。对照 normalizeCanvas 与后端分区测试核对 frame/frameId/labelMode,前端代理模拟往返覆盖三种标签及成员归属;真实账号的数据库写入及跨设备恢复尚未操作,没有修改真实用户数据。
|
||||
- 按用户约定未访问项目浏览器;实际视觉、拖动手感和真实账户跨设备验收待用户反馈。本阶段尚未 commit / push。
|
||||
用户交付 Core `b4e537b` 部署、ECS CI #912/#913,以及 `/Users/zzyang/foundry-canvas-v2-migration-summary.json`:指定父画布版本 209→210,1 个真实子画布、2 个显式成员,11 个节点和 2 条边保留;重新读取和幂等验证通过。
|
||||
|
||||
## 移除大加号与连线端点修正(2026-11-06)
|
||||
用户提供的隔离后端真实模型验收包括文本生成和 PNG 图片产物;本轮前端没有再次调用线上模型、修改测试画布或重复迁移。模型 ID 在运行时读取,不硬编码验收 ID。
|
||||
|
||||
- 选中时不再放大连接点或显示加号,四边中心的小连接点仅选中时淡入显示,取消选中后淡出,保留拖线及点击打开节点类型菜单。未选中节点仍可作为连线终点;隐藏连接点不支持发起拖线。
|
||||
- React Flow 默认曲线端点落在 Handle 外沿,选中时尺寸变化和旋转后的测量会带来偏移。新增共享卡片边缘锚点计算,已连接曲线与拖线预览都直接使用实际尺寸、位置、角度计算起终点,兼容旧无名左右连接。
|
||||
- 新增几何回归覆盖缩放、旋转、选中状态变化和路径两端坐标。31 项测试、类型检查、隔离 Webpack 生产构建和差异检查通过。按约定未访问项目浏览器,改动暂留本地。
|
||||
新增 connections 扩展已对照用户提供的接口说明和本机 Core `07fd678` 源码实现;主线合并为 `630ed42`。生产是否可调用以 capabilities.subgraphs.connections=true 及后端部署确认作为门槛,不把文档或合并本身当作已部署。
|
||||
|
||||
## 首页按登录态分流(2026-11-06)
|
||||
## 限制和交付状态
|
||||
|
||||
- `/` 改为服务端页面:无 foundry_session 时显示 landing page;有 Cookie 时向 Core profile 验证,有效会话 307 跳转 `/dashboard`;失效或无法验证的会话显示 landing page。验证请求 no-store,不把会话值传入客户端组件。
|
||||
- 原有 landing 页面移至 src/components/landing-page.tsx,保留 FAQ 交互;`/dashboard` 继续使用现有无画布工作台,图谱独立路由不变。
|
||||
- 类型检查、独立临时目录的 Webpack 生产构建通过;生产输出将 `/` 标记为动态路由。
|
||||
- 完整 API 冒烟通过,新增有效会话首页 307/Location、私有响应缓存、会话值不进入响应内容、无会话和失效会话 landing,以及 dashboard 不渲染 canvas 的检查;既有 API 和 88 个品牌资源回归通过。
|
||||
- 未访问项目浏览器。本次保留本地改动,按用户最新要求将提交/推送留到合适阶段统一管理。
|
||||
|
||||
## 连接交互、样式栏与图标(2026-11-06)
|
||||
|
||||
- 移除“保存在此浏览器”状态行;卡片连接点恢复四边中心,未选中显示点,选中后动画变为加号。同一个 React Flow Handle 支持拖出贝塞尔线;点击加号打开文本/图片/脚本菜单,图片需用户选图,取消不会创建空节点。选中卡片拖线到空白处,在释放位置打开菜单。
|
||||
- 连接点大小与加号显隐、选中边框有 CSS 过渡,样式栏与菜单提供 Motion 进入/退出动画;尊重减少动态效果偏好。
|
||||
- 标题移到框外,textarea 充满卡片内部,卡片与纯文本均覆盖全局聚焦阴影,去掉额外内框。
|
||||
- 删除八向缩放和常驻旋转柄,仅保留右下角缩放符号。旋转从样式栏开启,拖动元素旋转,点击外部或 Esc 退出;Shift 吸附 15°。
|
||||
- 样式支持颜色、背景、字体、字号、粗体、斜体、下划线、对齐及行距;节点样式可撤销、导入导出和本地保存。根据用户后续反馈,纯文本改为宽高独立调整、自动换行,缩放不改变字号;仅图标保持等比。
|
||||
- 底部图标入口打开左侧浮层,提供 45 个 Lucide 图标及中英文名称搜索,支持插入、缩放、旋转、颜色和 JSON 往返。浮层使用 Portal,避免被展开的工作空间侧栏遮住。
|
||||
- 核对 Core main e8ed5e4 的 content/materials 实现:board.content 目前只允许 schema_version,无法存储节点或样式。尚未对接不存在的 API,也未修改真实账户或服务端数据。完整接口建议、字段和验收标准见 docs/CORE_CANVAS_API.md。
|
||||
- 29 项自动测试及类型检查通过;覆盖选择不同类型的分支、空白处释放位置、样式完整往返与撤销、非法样式拒绝、图标继承与缩放,以及已有画布/工作空间流程。
|
||||
- 最终源代码的隔离 Webpack 生产构建、完整 API/路由/88 个品牌资源冒烟回归均通过;未改动正在使用的开发服务缓存。
|
||||
- 按用户既定安排未访问项目浏览器,实际拖线手感、弹层位置与动画视觉由用户试用反馈;自动状态测试不代表浏览器交互验收。
|
||||
|
||||
## 元素继承、缩放、旋转与纯文本(2026-11-06)
|
||||
|
||||
- 新增业务元素类继承,卡片与纯文本共享基础变换;纯文本不提供连接点或四向分支。
|
||||
- 选中元素提供八向缩放与旋转柄;几何计算使用旋转后的局部坐标,保持对侧锚点。纯文本按比例缩放字号,支持大小、角度、字号的草稿/JSON 持久化和单次手势撤销。
|
||||
- 类型检查通过;24 项自动测试通过,包含八个方向在多个角度下的锚点不变、纯文本缩放限制、旋转吸附、继承能力、导入兼容与撤销。
|
||||
- 独立临时目录的 Webpack 生产构建通过;完整 API/路由/88 个品牌资源冒烟回归通过,未改写正在使用的开发服务缓存。
|
||||
- 遵循用户安排未访问项目浏览器,实际拖动手感和旋转后的连线视觉仍由用户试用反馈。
|
||||
|
||||
- 生产构建:Next.js 16.4.5,TypeScript 检查及 6 个页面静态生成成功。
|
||||
- API 会话测试:HttpOnly / SameSite Cookie、令牌不返回前端、带会话代理、401、跨站拒绝、非法 Origin、路径白名单、OAuth challenge 校验通过。
|
||||
- 线上轻量检查:profile 401;空登录参数 400;Slack 与 Google 启动登录均返回 302,目标分别为 slack.com、accounts.google.com。未执行真人账户登录。
|
||||
- 浏览器:登录/注册切换、演示画布创建、搜索无结果、打开画布、笔记保存、待办勾选,以及未登录访问 /dashboard 返回 /login。
|
||||
- 1440px 首页和 390px 首页/工作台:页面宽度等于视口宽度,未发现横向溢出。首页图片全部加载。
|
||||
- Figma:原为 DIRECT;已添加 figma.com、figmausercontent.com 代理规则并从实际连接确认 DomainSuffix 生效。验证时代理组当前节点为香港 Y03。
|
||||
- 本机生产预览:http://localhost:17321(只监听本机)。
|
||||
- 尚未完成:真人 OAuth / 邮箱验证完整回路、后端项目回调地址匹配、完整编辑器布局持久化、分享/收藏/Agent。
|
||||
- 本项目仅本地创建,未新建 Gitea 远程仓库、未提交或推送。
|
||||
|
||||
|
||||
后续性能检查与首次创建流程修复已完成,最终构建、API smoke、4 项流程回归与隔离浏览器验收通过。详见 PERFORMANCE.md。
|
||||
|
||||
## 真实账号创建验收
|
||||
|
||||
已在用户已登录的 Chrome 中打开 http://localhost:17321/dashboard。初始空间列表为空;使用新建画布流程提交用户原先填写的「周末的灵感」后,创建了「个人空间」和该画布。完整刷新页面后,侧栏和主列表仍显示该画布,确认读取到后端持久化记录。此项为真实账号验收,补充此前只完成隔离模拟测试的限制。
|
||||
|
||||
## App Router 迁移验收(2026-11-05)
|
||||
|
||||
- 全部页面迁入 `src/app`;根布局提供全局样式、`zh-CN`、标题及描述。工作台通过 `next/navigation` 读取演示参数,入口提供 Suspense 边界。
|
||||
- 两个 API 改为 Route Handlers,保留会话 Cookie、Origin 校验、路径和查询参数白名单、15 秒超时、请求取消及 Server-Timing;显式保留 1 MiB 请求体限制。
|
||||
- `npm run build -- --webpack`:通过,构建结果全部为 App Router 路由。
|
||||
- 默认 `npm run build`:当前环境禁止 Turbopack CSS 工作进程绑定本地端口而失败;没有更改项目默认构建器。清理迁移前 `.next` 缓存后,Webpack 编译、类型检查和静态生成均通过。
|
||||
- `npm run typecheck`:通过。
|
||||
- `node --experimental-strip-types --test tests/workspace.test.mjs`:4 项通过。
|
||||
- `node tests/api-smoke.mjs`:通过。覆盖首页、登录、登录回调、真实及演示工作台的 HTTP 响应、语言/标题/描述/viewport、404、Cookie 与令牌隔离、HTTPS Secure Cookie、登录态代理、跨站及非法 Origin、路径及查询白名单、请求方法、非法 JSON、超限请求体、Google/Slack 302、无效 challenge 和 204 退出清除 Cookie。
|
||||
- 本次未重跑浏览器交互与响应式验收:浏览器连接不可用;未进行真人账户登录或部署验证。
|
||||
- 迁移前 Pages Router 快照已先推送到现有仓库 main;本次迁移按用户明确授权直接更新 main,不创建 PR。
|
||||
|
||||
## 合并侧栏与新建图谱(2026-11-05)
|
||||
|
||||
- 移除最左侧独立 icon 栏,侧栏使用纵向 flex;顶部操作、搜索栏和底部账户按钮内部使用横向 flex。
|
||||
- Lucide 图标按钮以 16×16px 图标、上下 4px / 左右 6px 内边距实现;收起后只保留左上角浮框,可恢复侧栏或新建页面。
|
||||
- 账户弹出框使用原生 Popover,支持浏览器的外部点击及 Escape 关闭;菜单提供实际用户资料、空间切换和退出。
|
||||
- 创建契约核对 Foundry Core 主线 `e8ed5e4` 的 materials/workspaces 实现:`kind=board`,后端生成 ID、workspace 归属、版本;前端提供默认名称“未命名图谱”,首次创建时可先建立“个人空间”。
|
||||
- 类型检查通过;6 项 workspace 单元测试通过,包含默认名称、ID 和 workspace 保留、路由编码、首次建空间及失败重试。
|
||||
- 在独立临时目录执行 `npm run build -- --webpack` 成功,未改写当前开发服务的构建缓存。
|
||||
- 隔离生产构建的 API 冒烟测试通过,新增模拟 Foundry 的创建 201、会话转发、默认名称与 schema、按生成 ID 重新读取及图谱路由 200 检查。
|
||||
- 按用户要求不访问浏览器,本次未验证实际界面交互或使用真实账户创建图谱;浏览器和线上验收由用户后续反馈。
|
||||
|
||||
## 侧栏动画、字体与 Google 头像(2026-11-06)
|
||||
|
||||
- 使用固定版本 Motion for React 14.9.1:侧栏宽度和位置以 240ms 缓动变化,浮框以 140ms 淡入淡出;系统减少动态效果偏好下立即切换。收起时侧栏设为 inert/aria-hidden,焦点转移到展开按钮。
|
||||
- 侧栏主文字改为 14px、#37352f;列表内边距改为 4px 8px,行高 20px,最小高度 30px;个人空间等分组标题保持原来的 11px 浅灰色。
|
||||
- 账户弹出框重新设置分区、工作空间图标、用户资料、头像、分隔线、边框和阴影,保留原生 Popover 的 Escape 与外部点击关闭;账户设置和新建工作空间接入实际 profile PATCH / workspaces POST。
|
||||
- Foundry 主线已保存 OAuth picture 并通过 profile.avatar_url 返回,本次补齐前端类型和头像渲染,无需修改后端。图片失败或缺失时回退首字,图片使用 no-referrer。
|
||||
- 类型检查与 6 项 workspace 测试通过;隔离临时目录的 Webpack 生产构建及 API 冒烟检查通过,新增头像 URL 透传、资料修改后保留头像的检查。
|
||||
- 沿用用户“不访问浏览器,由用户试用反馈”的要求,未运行本次浏览器视觉、动画及真实 Google 头像加载验收;未修改真实账户资料。
|
||||
|
||||
## 全屏画布与原始品牌资源(2026-11-06)
|
||||
|
||||
- 删除图谱顶部标题栏及相关 CSS;画布 `position: fixed; inset: 0; height: 100dvh`,侧栏和浮框保留在更高层级,加载与错误消息以浮层显示。
|
||||
- 收起侧栏后,将原新建按钮位置替换为 Logo 链接,目标为主页 `/`;Logo 提供 1x/2x/3x PNG,展开侧栏的新建按钮不变。
|
||||
- 从旧 PROMPT 提交 `47467874841ea7f7b4b362d4ec0e66f236c6a7d6` 复制 88 个原始 PNG/ICO/SVG,逐一核对 Git blob 哈希完全一致;PNG 尺寸为 16px 至 2048px。
|
||||
- 全局 Brand 组件与根布局 favicon/Apple Touch 元信息使用迁入资源。
|
||||
- 按用户约定不访问浏览器,视觉与点击交互由用户试用反馈;自动检查覆盖资源 HTTP 响应和字节一致性、图标元信息及既有接口流程。
|
||||
- 本次类型检查、隔离 Webpack 生产构建、6 项 workspace 测试及扩展 API 冒烟检查均通过;88 个品牌资源的 HTTP 状态和响应字节与本地原文件一致。
|
||||
|
||||
## React Flow 与悬浮工具栏(2026-11-06)
|
||||
|
||||
- 根因:此前画布仅为静态点阵 div,没有安装或渲染 React Flow。本次加入固定版本 `@xyflow/react@12.12.1` 和官方样式,使用 ReactFlowProvider / ReactFlow 实现真正的平移、缩放、节点拖动、连线与多选。
|
||||
- 默认启用空白处左键拖动平移和触控板滚动平移,Ctrl/Meta 激活滚动缩放,支持捏合与空格平移;抓手模式关闭节点拖动,并让节点编辑区域的指针事件穿透到画布。
|
||||
- 底部工具栏提供选择、抓手、文字、图片、脚本、创作模板与导入;右上角提供撤销重做、缩放、100%、适应画布及导出。没有接入虚假的 AI 生成或会员功能。
|
||||
- 左上角增加 Google 头像入口;共享账户 Popover 移出会变为 inert 的侧栏,收起时将弹出位置改为左上角,保留原账户设置、空间切换及退出功能。
|
||||
- 节点、连线和视图暂存 localStorage,按 workspace 与 board 分离;这不是服务端同步。导入校验节点/边/图片来源/视图/大小,导出提供 JSON 备份;存储失败明确提示。
|
||||
- 13 项自动测试通过:节点位置变化与撤销重做、删除连线清理、文字编辑事务、导入导出、恶意图片与无效图谱拒绝、草稿隔离、历史上限及既有工作空间流程。
|
||||
- 类型检查、隔离 Webpack 生产构建和完整 API 冒烟回归通过;原有 88 个品牌资源与账户/Foundry 创建接口保持通过。
|
||||
- 遵循用户不访问浏览器的安排,未做实际浏览器拖动、触控手势和视觉验收;不将状态测试表述为已完成浏览器手感验收。
|
||||
|
||||
## 悬浮栏标题与尺寸统一(2026-11-06)
|
||||
|
||||
- 当前图谱标题通过共享 ActiveBoardProvider 从图谱页面传到左上悬浮栏,按图谱 ID 与 workspace 校验,避免错显上一张图谱;不增加重复 API 请求,长标题省略并保留完整 title 属性。
|
||||
- 统一所有悬浮栏的 surface 样式:16px 图标、28×24px border-box 按钮、12px 字体、4px 内边距、2px 间隙、8px 圆角;浮框头像缩为 16px,账户菜单内头像仍保持原尺寸。
|
||||
- 删除 canvas 工具栏的 content-box 与 8px/9px 内边距覆盖,缩放文字和帮助按钮采用同一高度、字号及容器;下方和右侧距屏幕边缘统一 12px。
|
||||
- 沿用不访问浏览器的安排,本次验证以类型检查、生产构建和现有接口回归为准,未进行浏览器视觉验收。
|
||||
- 本次类型检查、隔离 Webpack 生产构建和完整 API/品牌资源冒烟检查均通过。
|
||||
|
||||
## 悬浮栏 32px 图标试用(2026-11-06)
|
||||
|
||||
- 按用户要求将共享悬浮栏图标、Logo 和头像改为 32×32px,按钮改为 44×40px,主文字 14px;保留 4px 容器内边距,整体高度 50px。
|
||||
- Logo 切换到 32/64/128px 原始资源,账户与帮助弹出框偏移同步调整,窄屏工具栏允许横向滚动。
|
||||
- 类型检查和差异检查通过。本次为尺寸试调,未重复生产构建,未访问浏览器。
|
||||
|
||||
## 修正 32×32 总尺寸(2026-11-06)
|
||||
|
||||
- 用户澄清 32×32 指包含 padding 的按钮总尺寸。统一使用 border-box 的 32×32px 按钮,内部图标、Logo 与浮框头像为 20px,保留上下 4px、左右 6px 内边距;主文字仍为 14px。
|
||||
- 公共图标按钮默认尺寸同步修正,悬浮栏整体高度为 42px;账户和帮助弹出框偏移随之调整,保留高清 Logo 资源。
|
||||
- 类型检查及差异检查通过;本次尺寸修正未重复构建或访问浏览器。
|
||||
|
||||
## 悬浮栏间距微调(2026-11-06)
|
||||
|
||||
- 控件间距从 2px 增加到 4px,统一应用于左上、右上、底部及帮助悬浮栏;保持图标按钮含 padding 总尺寸 32×32px。
|
||||
- 差异检查通过;仅调整 CSS 间距,未重复构建或访问浏览器。
|
||||
|
||||
## 左上角标题改名(2026-11-06)
|
||||
|
||||
- 标题交互为 idle → selected → editing,第一次点击选中,第二次点击编辑;支持 Enter/失焦保存、Esc 取消、F2 编辑与输入法组合输入保护。
|
||||
- 改名前 GET 最新图谱,PATCH 携带带引号的 If-Match 版本、原 description/content;成功后更新共享标题、侧栏列表及导出使用的名称。演示模式写入本地列表,跨图谱切换不会覆盖新图谱的标题。
|
||||
- 名称去除首尾空格,拒绝空名及超过 300 UTF-8 字节的名称;版本冲突不自动覆盖或重试,失败保留输入。
|
||||
- 类型检查和 16 项画布/工作空间测试通过,新增原内容保留、名称校验和版本冲突不重试的检查。
|
||||
- 按既定约定不访问浏览器,未进行实际点击与输入法交互验收。
|
||||
- 隔离 Webpack 生产构建和完整 API 冒烟检查通过;新增改名 PATCH/If-Match、204 响应、重新读取名称和内容保留检查通过。
|
||||
|
||||
## 参照右上控件调整分组留白(2026-11-06)
|
||||
|
||||
- 右上现有尺寸和间距保持不变;左上与底部采用稍宽的 6px 横向间隙和横向内边距,保持 4px 竖向内边距与 32×32px 按钮。
|
||||
- 左上在标题前后增加与右上相同的分隔线,去掉标题额外外边距;三处分隔线改为共享样式。
|
||||
- 类型检查和差异检查通过;本次 CSS 留白试调未重复生产构建或访问浏览器。
|
||||
|
||||
## 选中节点四向加号(2026-11-06)
|
||||
|
||||
- 节点 selected 时显示上/下/左/右四个加号,未选中时隐藏;加号使用 nodrag/nopan 并阻止指针事件传播,避免触发节点或画布拖动。
|
||||
- 点击按实际测量的节点尺寸在对应方向新增文字节点并自动连线,重复添加避开已有节点;选择与视图跟随新节点,添加及连线组成一步撤销。
|
||||
- 四侧使用命名 Handle 与 Loose 连接模式;保留旧无名连接点用于已有连线,导入导出校验并保留新的 sourceHandle/targetHandle。
|
||||
- 类型检查与 18 项自动测试通过,新增四向位置、连接方向持久化、一步撤销和重复添加避让测试。
|
||||
- 按用户既定安排不访问浏览器,本次未进行视觉和实际点击验收。
|
||||
- 隔离 Webpack 生产构建与完整 API/路由/品牌资源冒烟回归均通过。
|
||||
- 按用户约定未访问项目浏览器,未进行视觉布局、真实拖动手感与真实登录会话的浏览器验收。
|
||||
- 子图关系备份不能作为普通单图快照导入其他画布;导出包含完整 owner 文档集合,避免仅备份父图或重复保存投影。
|
||||
- 网络未知结果保留请求并暂停编辑;普通版本冲突保留本地草稿,不强推覆盖。
|
||||
- Headless 库尚未创建,提取方案已改写在本地忽略的 docs/HEADLESS_CANVAS_API_DRAFT.md;已替代的分区/元素/后端需求文档已清理。会话策略文档仍保留。
|
||||
- 原有 next-env.d.ts、pnpm-lock.yaml 和 pnpm-workspace.yaml 不属于本次交付,保留未提交。
|
||||
|
||||
@@ -3,10 +3,12 @@ import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
const allowed =
|
||||
/^(profile|auth\/(login|register|verify-email|exchange|me|logout)|workspaces(?:\/[a-zA-Z0-9_-]+(?:\/(?:materials(?:\/[a-zA-Z0-9_-]+(?:\/canvas)?)?|search))?)?)$/;
|
||||
const canvasRoute = /^workspaces\/[a-zA-Z0-9_-]+\/materials\/[a-zA-Z0-9_-]+\/canvas(?:\/(?:connections|capabilities|subgraphs(?:\/(?:migrate|[a-zA-Z0-9_-]+\/(?:members|dissolve)))?|transactions\/[a-zA-Z0-9_-]+\/undo|ai\/(?:models|runs(?:\/[a-zA-Z0-9_-]+(?:\/(?:cancel|apply))?)?)))?$/;
|
||||
const assetRoute = /^workspaces\/[a-zA-Z0-9_-]+\/assets(?:\/[a-zA-Z0-9_-]+)?$/;
|
||||
const bodyLimit = 1024 * 1024;
|
||||
|
||||
// Route Handlers do not have the Pages Router bodyParser size limit.
|
||||
async function readBody(req: NextRequest, limit = bodyLimit) {
|
||||
async function readBody(req: NextRequest, limit = bodyLimit, binary = false) {
|
||||
const reader = req.body?.getReader();
|
||||
if (!reader) return {};
|
||||
const chunks: Uint8Array[] = [];
|
||||
@@ -25,8 +27,9 @@ async function readBody(req: NextRequest, limit = bodyLimit) {
|
||||
} finally {
|
||||
reader.releaseLock();
|
||||
}
|
||||
if (!size) return {};
|
||||
return JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
||||
if (!size) return binary ? new Uint8Array() : {};
|
||||
const bytes = Buffer.concat(chunks);
|
||||
return binary ? bytes : JSON.parse(bytes.toString("utf8"));
|
||||
}
|
||||
|
||||
async function handler(
|
||||
@@ -46,11 +49,13 @@ async function handler(
|
||||
}
|
||||
}
|
||||
const path = (await params).path.join("/");
|
||||
if (!allowed.test(path)) return json({ error: "not_found" }, 404);
|
||||
if (!allowed.test(path) && !canvasRoute.test(path) && !assetRoute.test(path)) return json({ error: "not_found" }, 404);
|
||||
const asset = assetRoute.test(path);
|
||||
const canvas = canvasRoute.test(path);
|
||||
let requestBody: unknown;
|
||||
if (req.method !== "GET") {
|
||||
try {
|
||||
requestBody = await readBody(req, path.endsWith("/canvas") ? 5 * 1024 * 1024 : bodyLimit);
|
||||
requestBody = await readBody(req, asset ? 21 * 1024 * 1024 : path.endsWith("/canvas") ? 5 * 1024 * 1024 : bodyLimit, asset);
|
||||
} catch (error) {
|
||||
const tooLarge = error instanceof Error && error.message === "body_too_large";
|
||||
return json({ error: tooLarge ? "body_too_large" : "invalid_json" }, tooLarge ? 413 : 400);
|
||||
@@ -58,15 +63,16 @@ async function handler(
|
||||
}
|
||||
const origin = process.env.FOUNDRY_API_ORIGIN || "https://api.foundry.entroia.com";
|
||||
const project = process.env.NEXT_PUBLIC_FOUNDRY_PROJECT || "endoleap";
|
||||
const headers: Record<string, string> = { "Content-Type": "application/json" };
|
||||
const headers: Record<string, string> = { "Content-Type": asset ? (req.headers.get("content-type") || "application/octet-stream") : "application/json" };
|
||||
if (canvas) headers.Accept = "application/vnd.foundry.canvas+json;version=2";
|
||||
const session = req.cookies.get("foundry_session")?.value;
|
||||
if (session) headers.Authorization = "Bearer " + session;
|
||||
for (const h of ["x-foundry-code", "x-foundry-verifier", "if-match"]) {
|
||||
for (const h of ["x-foundry-code", "x-foundry-verifier", "if-match", "idempotency-key"]) {
|
||||
const value = req.headers.get(h);
|
||||
if (value !== null) headers[h] = value;
|
||||
}
|
||||
const query = new URLSearchParams();
|
||||
for (const key of ["kind", "sort", "q", "limit", "offset"]) {
|
||||
for (const key of ["kind", "sort", "q", "limit", "offset", "after_seq", "status", "include_descendants", "include_subgraphs", "cursor"]) {
|
||||
const values = req.nextUrl.searchParams.getAll(key);
|
||||
if (values.length === 1 && values[0].length < 300) query.set(key, values[0]);
|
||||
}
|
||||
@@ -78,14 +84,29 @@ async function handler(
|
||||
{
|
||||
method: req.method,
|
||||
headers,
|
||||
body: req.method === "GET" ? undefined : JSON.stringify(requestBody || {}),
|
||||
body: req.method === "GET" ? undefined : asset ? requestBody as BodyInit : JSON.stringify(requestBody || {}),
|
||||
signal: AbortSignal.any([AbortSignal.timeout(15000), req.signal]),
|
||||
redirect: "manual",
|
||||
cache: "no-store",
|
||||
},
|
||||
);
|
||||
const retryAfter = upstream.headers.get("retry-after");
|
||||
if (retryAfter) responseHeaders.set("Retry-After", retryAfter);
|
||||
const etag = upstream.headers.get("etag");
|
||||
if (etag) responseHeaders.set("ETag", etag);
|
||||
const schema = upstream.headers.get("x-canvas-schema-version");
|
||||
if (schema) responseHeaders.set("X-Canvas-Schema-Version", schema);
|
||||
const renewal = upstream.headers.get(SESSION_EXPIRY_HEADER);
|
||||
if (session && upstream.status !== 401 && renewal !== null && /^\d+$/.test(renewal)) {
|
||||
const secure = process.env.NODE_ENV === "production" && req.headers.get("x-forwarded-proto") === "https";
|
||||
responseHeaders.set("Set-Cookie", sessionCookie(session, sessionMaxAge(renewal), secure));
|
||||
}
|
||||
if (asset && upstream.ok && req.method === "GET") {
|
||||
const type = upstream.headers.get("content-type") || "";
|
||||
if (!/^image\/(png|jpeg|webp|gif)(?:;|$)/i.test(type)) return json({ error: "invalid_asset" }, 502);
|
||||
responseHeaders.set("Content-Type", type); responseHeaders.set("X-Content-Type-Options", "nosniff");
|
||||
return new NextResponse(upstream.body, { status: upstream.status, headers: responseHeaders });
|
||||
}
|
||||
const headersReceived = performance.now();
|
||||
const body = upstream.status === 204 ? null : await upstream.json();
|
||||
responseHeaders.set(
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
"use client";
|
||||
import { useEffect, useState } from "react";
|
||||
import { NodeToolbar, Position } from "@xyflow/react";
|
||||
import { ArrowUp, Square, Sparkles } from "lucide-react";
|
||||
import { api, errorText } from "@/lib/api";
|
||||
import { canvasPath } from "@/lib/canvas-sync";
|
||||
import { CANVAS_ACCEPT, managedImageSource } from "@/lib/canvas-v2";
|
||||
import type { CanvasNode } from "@/lib/canvas";
|
||||
import type { CanvasModel, useCanvasAI } from "./use-canvas-ai";
|
||||
import styles from "./canvas.module.css";
|
||||
export function AIPanel({ id, node, workspace, boardId, ai, save }: { id: string; node: CanvasNode; workspace: string; boardId: string; ai: ReturnType<typeof useCanvasAI>; save: (patch: NonNullable<CanvasNode["data"]["ai"]>) => void }) {
|
||||
const [models, setModels] = useState<CanvasModel[]>([]);
|
||||
const [model, setModel] = useState(node.data.ai?.modelId || "");
|
||||
const [prompt, setPrompt] = useState(node.data.ai?.prompt || "");
|
||||
const [propagate, setPropagate] = useState(node.data.ai?.propagate || false);
|
||||
const [error, setError] = useState("");
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [starting, setStarting] = useState(false);
|
||||
const image = ["image", "plain-image"].includes(node.data.kind);
|
||||
const relevant = [...ai.runs.values()].filter((run) => run.steps?.some((step) => step.board_id === boardId && step.node_id === node.id));
|
||||
const run = relevant.at(-1), step = run?.steps?.find((step) => step.board_id === boardId && step.node_id === node.id);
|
||||
const busy = starting || step?.status === "queued" || step?.status === "running";
|
||||
useEffect(() => {
|
||||
if (!ai.live) { setLoading(false); return; }
|
||||
const controller = new AbortController();
|
||||
setLoading(true); setError("");
|
||||
api<{ models: CanvasModel[] }>(canvasPath(workspace, boardId) + "/ai/models", { signal: controller.signal, headers: { Accept: CANVAS_ACCEPT } }).then((result) => {
|
||||
const compatible = result.models.filter((model) => model.output_modalities.includes(image ? "image" : "text"));
|
||||
setModels(compatible); setModel((id) => compatible.some((model) => model.id === id) ? id : compatible.find((model) => model.available)?.id || "");
|
||||
}).catch((error) => { if (!controller.signal.aborted) setError(errorText(error)); }).finally(() => { if (!controller.signal.aborted) setLoading(false); });
|
||||
return () => controller.abort();
|
||||
}, [workspace, boardId, image, ai.live]);
|
||||
const config = () => ({ enabled: true, modelId: model, prompt, propagate, parameters: {} });
|
||||
const persist = () => { if (model && ai.live) save(config()); };
|
||||
const output = run?.outputs?.find((item) => item.step_id === step?.step_id)?.output;
|
||||
const textOutput = run?.events?.filter((event) => event.type === "step.output" && event.node_id === node.id && event.board_id === boardId).at(-1)?.output?.text;
|
||||
return <NodeToolbar nodeId={id} isVisible position={Position.Bottom} offset={26} style={{ zIndex: 110 }}><section className={`${styles.aiPanel} nodrag nopan nowheel`} aria-label="元素 AI 工具"
|
||||
onPointerDown={(event) => event.stopPropagation()} onClick={(event) => event.stopPropagation()} onDoubleClick={(event) => event.stopPropagation()} onKeyDown={(event) => event.stopPropagation()}>
|
||||
<header><Sparkles size={14} /><strong>{image ? "图像创作" : "文本创作"}</strong><span>{node.data.title}</span></header>
|
||||
<textarea aria-label="AI 指令" placeholder={image ? "描述你希望生成或修改的画面…" : "希望如何处理当前内容?"} value={prompt} maxLength={16000} onChange={(event) => setPrompt(event.target.value)} onBlur={persist} />
|
||||
<footer><select aria-label="AI 模型" disabled={loading || !ai.live || busy} value={model} onChange={(event) => { setModel(event.target.value); if (ai.live) save({ ...config(), modelId: event.target.value }); }}>{!model && <option value="">{loading ? "正在读取模型…" : "暂无可用模型"}</option>}{models.map((model) => <option key={model.id} value={model.id} disabled={!model.available}>{model.label || model.display_name || model.id}{!model.available ? " · 不可用" : ""}</option>)}</select>
|
||||
<label><input type="checkbox" aria-label="继续向下传播" disabled={!!busy} checked={propagate} onChange={(event) => { setPropagate(event.target.checked); if (model && ai.live) save({ ...config(), propagate: event.target.checked }); }} />继续向下传播</label>
|
||||
{busy && run ? <button aria-label="取消生成" title="取消生成" onClick={() => void ai.cancel(run).catch((error) => setError(errorText(error)))}><Square size={15} /></button> : <button aria-label="执行 AI" title="在当前元素生成结果" disabled={starting || !ai.live || !model || loading || !models.some((item) => item.id === model && item.available)} onClick={async () => {
|
||||
setStarting(true); setError(""); try { await ai.run(boardId, node.id, model, prompt, propagate); } catch (error) { setError(errorText(error)); } finally { setStarting(false); }
|
||||
}}><ArrowUp size={18} /></button>}
|
||||
</footer>
|
||||
<p role="status">{!ai.live ? "演示画布暂不执行生成" : ai.conflicts.has(boardId) ? "生成已完成,本地草稿存在冲突,尚未替换当前内容" : busy ? "正在生成,结果将显示在当前元素…" : step ? ({ succeeded: "已更新当前元素", conflict: "内容已改变,生成结果已保留", failed: "生成未完成,原内容已保留", cancelled: "已取消生成", skipped: "此步骤未执行" }[step.status] || step.status) : "结果将显示在当前元素"}</p>
|
||||
{(step?.status === "conflict" || ai.conflicts.has(boardId)) && output && run && <div className={styles.aiConflict}>{textOutput && <pre>{textOutput}</pre>}{output.src && <img src={managedImageSource(output.src)} alt="待应用的生成结果" />}<button onClick={() => void ai.apply(run, node.id, output.output_id, boardId).catch((error) => setError(errorText(error)))}>使用此结果替换当前内容</button></div>}
|
||||
{(error || ai.error) && <p role="alert">{error || errorText(new Error(ai.error))}</p>}
|
||||
</section></NodeToolbar>;
|
||||
}
|
||||
@@ -1,27 +1,124 @@
|
||||
"use client";
|
||||
|
||||
import { BaseEdge, useInternalNode, type ConnectionLineComponentProps, type EdgeProps, type InternalNode } from "@xyflow/react";
|
||||
import { useContext, useRef, useState, type PointerEvent } from "react";
|
||||
import { BaseEdge, useInternalNode, useReactFlow, useViewport, type ConnectionLineComponentProps, type EdgeProps, type InternalNode } from "@xyflow/react";
|
||||
import { useReducedMotion } from "motion/react";
|
||||
import type { CanvasNode } from "@/lib/canvas";
|
||||
import { connectorBezier, nodeConnector, type ConnectorSide } from "@/lib/canvas-geometry";
|
||||
import { connectorBezier, nodeConnector, type ConnectorSide, type Connector } from "@/lib/canvas-geometry";
|
||||
import { curvePath, curveSegments, cubicPoint, insertCurveKnot, minus, plus } from "@/lib/canvas-curves";
|
||||
import type { CanvasEdgeModel, EdgeMarker } from "@/lib/canvas-v2";
|
||||
import { EdgeEditing } from "./edge-editing";
|
||||
import { EdgeToolbarControls } from "./edge-toolbar";
|
||||
import styles from "./canvas.module.css";
|
||||
|
||||
function connector(node: InternalNode<CanvasNode>, handle: string | null | undefined, fallback: ConnectorSide) {
|
||||
const side = handle === "top" || handle === "right" || handle === "bottom" || handle === "left" ? handle : fallback;
|
||||
return nodeConnector({ ...node, position: node.internals.positionAbsolute }, side);
|
||||
function connector(node: InternalNode<CanvasNode>, handle: string | null | undefined, fallback: ConnectorSide): Connector {
|
||||
const port = node.data.ports?.find((port) => `port:${port.id}` === handle);
|
||||
const side = handle === "top" || handle === "right" || handle === "bottom" || handle === "left" ? handle : port?.direction === "input" ? "left" : port ? "right" : fallback;
|
||||
const result = nodeConnector({ ...node, position: node.internals.positionAbsolute }, side);
|
||||
if (port) {
|
||||
const peers = node.data.ports!.filter((item) => item.direction === port.direction);
|
||||
result.point.y += ((peers.indexOf(port) + 1) / (peers.length + 1) - .5) * (node.measured?.height || node.height || 400);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function CanvasEdge(props: EdgeProps) {
|
||||
const source = useInternalNode<CanvasNode>(props.source);
|
||||
const target = useInternalNode<CanvasNode>(props.target);
|
||||
function Marker({ id, value, color }: { id: string; value?: EdgeMarker; color: string }) {
|
||||
const type = value?.type || "none";
|
||||
if (type === "none") return null;
|
||||
return <marker id={id} viewBox="0 0 12 12" markerWidth={value?.size || 12} markerHeight={value?.size || 12} refX={11} refY={6} orient="auto-start-reverse" markerUnits="userSpaceOnUse">
|
||||
<path d={type === "triangle" ? "M1 1L11 6L1 11Z" : type === "open-arrow" ? "M1 1L11 6L1 11" : type === "diamond" ? "M1 6L6 1L11 6L6 11Z" : type === "square" ? "M1 1H11V11H1Z" : "M11 6a5 5 0 1 0-10 0a5 5 0 1 0 10 0"} fill={type === "open-arrow" ? "none" : value?.filled !== false ? color : "#fffefb"} stroke={color} strokeWidth={1.2} />
|
||||
</marker>;
|
||||
}
|
||||
export function CanvasEdge(props: EdgeProps<CanvasEdgeModel>) {
|
||||
const source = useInternalNode<CanvasNode>(props.source), target = useInternalNode<CanvasNode>(props.target);
|
||||
const editing = useContext(EdgeEditing);
|
||||
const flow = useReactFlow();
|
||||
const { zoom } = useViewport();
|
||||
const reduce = useReducedMotion();
|
||||
const [selectedKnot, setSelectedKnot] = useState<string | null>(null);
|
||||
const gesture = useRef<{ id: string; part: "position" | "in" | "out" | "startControl" | "endControl" } | null>(null);
|
||||
if (!source || !target) return null;
|
||||
const from = connector(source, props.sourceHandleId, "right");
|
||||
const to = connector(target, props.targetHandleId, "left");
|
||||
return <BaseEdge id={props.id} path={connectorBezier(from, to)} style={{ ...props.style, strokeLinecap: "round" }} markerStart={props.markerStart} markerEnd={props.markerEnd} interactionWidth={props.interactionWidth} />;
|
||||
const from = connector(source, props.sourceHandleId, "right"), to = connector(target, props.targetHandleId, "left");
|
||||
const data = props.data || {}, appearance = data.appearance || {}, animation = data.animation || {};
|
||||
const segments = curveSegments(from, to, data.path), path = curvePath(segments);
|
||||
const midpoint = cubicPoint(segments[Math.floor(segments.length / 2)], .5);
|
||||
const color = appearance.color || "#aa968b", width = appearance.width || 1.5;
|
||||
const markerId = `edge-${props.id.replace(/[^a-zA-Z0-9_-]/g, "_")}`;
|
||||
const animated = !reduce && (animation.mode === "always" || animation.mode === "executing" && editing.active.has(props.id));
|
||||
const knots = data.path?.knots || [];
|
||||
function add(point?: { x: number; y: number }) {
|
||||
if (knots.length >= 64) return;
|
||||
const next = insertCurveKnot(from, to, data.path, point);
|
||||
editing.patch(props.id, { ...data, path: next.path }); setSelectedKnot(next.knot.id);
|
||||
}
|
||||
function start(event: PointerEvent<SVGCircleElement>, id: string, part: NonNullable<typeof gesture.current>["part"]) {
|
||||
event.preventDefault(); event.stopPropagation(); editing.begin(props.id); gesture.current = { id, part }; setSelectedKnot(id);
|
||||
event.currentTarget.setPointerCapture(event.pointerId);
|
||||
}
|
||||
function move(event: PointerEvent<SVGCircleElement>) {
|
||||
const current = gesture.current;
|
||||
if (!current) return;
|
||||
event.stopPropagation();
|
||||
const point = flow.screenToFlowPosition({ x: event.clientX, y: event.clientY });
|
||||
const next = structuredClone(data.path || { type: "bezier-chain" as const, knots: [] });
|
||||
if (current.part === "startControl" || current.part === "endControl") next[current.part] = minus(point, current.part === "startControl" ? from.point : to.point);
|
||||
else {
|
||||
const knot = next.knots.find((knot) => knot.id === current.id);
|
||||
if (!knot) return;
|
||||
if (current.part === "position") knot.position = point;
|
||||
else {
|
||||
const vector = minus(point, knot.position), other = current.part === "in" ? "out" : "in";
|
||||
const length = Math.hypot(knot[other].x, knot[other].y), nextLength = Math.hypot(vector.x, vector.y);
|
||||
knot[current.part] = vector;
|
||||
if (event.shiftKey) knot.mode = "symmetric";
|
||||
if (knot.mode !== "corner") {
|
||||
const scale = knot.mode === "symmetric" ? 1 : nextLength ? length / nextLength : 0;
|
||||
knot[other] = { x: -vector.x * scale, y: -vector.y * scale };
|
||||
}
|
||||
}
|
||||
}
|
||||
editing.patch(props.id, { ...data, path: next }, true);
|
||||
}
|
||||
function stop(event: PointerEvent<SVGCircleElement>) {
|
||||
event.stopPropagation(); gesture.current = null;
|
||||
if (event.currentTarget.hasPointerCapture(event.pointerId)) event.currentTarget.releasePointerCapture(event.pointerId);
|
||||
}
|
||||
const control = (point: { x: number; y: number }, id: string, part: NonNullable<typeof gesture.current>["part"], label: string) => <circle key={part + id} cx={point.x} cy={point.y} r={(part === "position" ? 5 : 3.5) / zoom} className={styles.curveControl} fill={part === "position" ? "#fffefb" : "#a47764"} stroke="#a47764" strokeWidth={1 / zoom} role="button" aria-label={label} tabIndex={0}
|
||||
onPointerDown={(event) => start(event, id, part)} onPointerMove={move} onPointerUp={stop} onPointerCancel={stop} onLostPointerCapture={() => { gesture.current = null; }} onClick={(event) => { event.stopPropagation(); setSelectedKnot(id); }}
|
||||
onKeyDown={(event) => {
|
||||
event.stopPropagation(); const delta = { ArrowLeft: [-1, 0], ArrowRight: [1, 0], ArrowUp: [0, -1], ArrowDown: [0, 1] }[event.key];
|
||||
if (delta) { event.preventDefault(); const next = structuredClone(data.path || { type: "bezier-chain" as const, knots: [] }); const vector = { x: delta[0] * (event.shiftKey ? 10 : 1), y: delta[1] * (event.shiftKey ? 10 : 1) };
|
||||
if (part === "position") { const knot = next.knots.find((knot) => knot.id === id); if (knot) knot.position = plus(knot.position, vector); }
|
||||
else if (part === "startControl" || part === "endControl") next[part] = plus(minus(point, part === "startControl" ? from.point : to.point), vector);
|
||||
else return;
|
||||
editing.patch(props.id, { ...data, path: next });
|
||||
}
|
||||
}} />;
|
||||
return <g onDoubleClick={(event) => { event.preventDefault(); event.stopPropagation(); add(flow.screenToFlowPosition({ x: event.clientX, y: event.clientY })); }}>
|
||||
<defs><Marker id={markerId + "-head"} value={appearance.head} color={color} /><Marker id={markerId + "-tail"} value={appearance.tail} color={color} /></defs>
|
||||
{props.selected && <path d={path} fill="none" stroke="#a47764" strokeOpacity={.18} strokeWidth={width + 7 / zoom} pointerEvents="none" />}
|
||||
<BaseEdge id={props.id} path={path} style={{ stroke: color, strokeWidth: width, opacity: appearance.opacity ?? 1, strokeDasharray: appearance.line === "dashed" ? "8 5" : appearance.line === "dotted" ? "1 5" : undefined, strokeLinecap: "round" }} markerStart={appearance.tail?.type && appearance.tail.type !== "none" ? `url(#${markerId}-tail)` : undefined} markerEnd={appearance.head?.type && appearance.head.type !== "none" ? `url(#${markerId}-head)` : undefined} interactionWidth={Math.max(20 / zoom, width + 8)} />
|
||||
{animated && (animation.effect === "flow" || !animation.effect ? <g pointerEvents="none"><circle r={Math.max(3, width * 1.5)} fill={animation.color || "#fff"}><animateMotion path={path} dur={`${animation.durationMs || 1600}ms`} repeatCount="indefinite" keyPoints={animation.direction === "reverse" ? "1;0" : "0;1"} keyTimes="0;1" calcMode="linear" /></circle></g> : <path d={path} fill="none" pointerEvents="none" stroke={animation.color || "#fff"} strokeWidth={width + 1} strokeDasharray={animation.effect === "dash" ? "6 14" : undefined} className={animation.effect === "dash" ? styles.edgeDashAnimation : styles.edgePulseAnimation} style={{ animationDuration: `${animation.durationMs || 1600}ms`, animationDirection: animation.direction === "reverse" ? "reverse" : "normal" }} />)}
|
||||
{props.selected && <>
|
||||
<EdgeToolbarControls id={props.id} x={midpoint.x} y={midpoint.y - 24 / zoom} data={data} change={(data) => editing.patch(props.id, data)} count={knots.length} knotMode={(knots.find((knot) => knot.id === selectedKnot) || knots.at(-1))?.mode} changeKnotMode={(mode) => {
|
||||
const next = structuredClone(data.path!); const knot = next.knots.find((knot) => knot.id === selectedKnot) || next.knots.at(-1); if (!knot) return;
|
||||
knot.mode = mode; if (mode !== "corner") {
|
||||
if (!Math.hypot(knot.out.x, knot.out.y)) knot.out = { x: -knot.in.x, y: -knot.in.y };
|
||||
const length = Math.hypot(knot.out.x, knot.out.y), scale = mode === "symmetric" ? 1 : length ? Math.hypot(knot.in.x, knot.in.y) / length : 0;
|
||||
knot.in = { x: -knot.out.x * scale, y: -knot.out.y * scale };
|
||||
}
|
||||
editing.patch(props.id, { ...data, path: next });
|
||||
}} add={() => add()} remove={() => { const id = selectedKnot || knots.at(-1)?.id; editing.patch(props.id, { ...data, path: { ...data.path!, knots: knots.filter((knot) => knot.id !== id) } }); setSelectedKnot(null); }} />
|
||||
{knots.map((knot, index) => <g key={knot.id} onDoubleClick={(event) => event.stopPropagation()}>
|
||||
{control(knot.position, knot.id, "position", `曲线点 ${index + 1}`)}
|
||||
{selectedKnot === knot.id && <><path d={`M${plus(knot.position, knot.in).x},${plus(knot.position, knot.in).y}L${knot.position.x},${knot.position.y}L${plus(knot.position, knot.out).x},${plus(knot.position, knot.out).y}`} fill="none" stroke="#a47764" strokeWidth={1 / zoom} pointerEvents="none" />{control(plus(knot.position, knot.in), knot.id, "in", "进入控制柄")}{control(plus(knot.position, knot.out), knot.id, "out", "离开控制柄")}</>}
|
||||
</g>)}
|
||||
{control(segments[0][1], "start", "startControl", "起点控制柄")}
|
||||
{control(segments.at(-1)![2], "end", "endControl", "终点控制柄")}
|
||||
</>}
|
||||
</g>;
|
||||
}
|
||||
|
||||
export function CanvasConnectionLine(props: ConnectionLineComponentProps<CanvasNode>) {
|
||||
const from = connector(props.fromNode, props.fromHandle.id, "right");
|
||||
const to = props.toNode && props.toHandle
|
||||
? connector(props.toNode, props.toHandle.id, "left")
|
||||
: { point: { x: props.toX, y: props.toY }, normal: { x: -from.normal.x, y: -from.normal.y } };
|
||||
const to = props.toNode && props.toHandle ? connector(props.toNode, props.toHandle.id, "left") : { point: { x: props.toX, y: props.toY }, normal: { x: -from.normal.x, y: -from.normal.y } };
|
||||
return <path fill="none" className="react-flow__connection-path" d={connectorBezier(from, to)} style={{ stroke: "#aa968b", strokeWidth: 1.5, ...props.connectionLineStyle, strokeLinecap: "round" }} />;
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ export function CanvasTextEditor({ id, data, selected, plain = false }: { id: st
|
||||
element?.focus({ preventScroll: true });
|
||||
element?.setSelectionRange(element.value.length, element.value.length);
|
||||
}, [editing]);
|
||||
function begin() { actions.begin(); setEditing(true); }
|
||||
function begin() { actions.begin(id); setEditing(true); }
|
||||
const active = editing && selected;
|
||||
const label = plain ? "纯文本" : data.title;
|
||||
return <div className={`${styles.textEditor} ${active ? "nodrag" : ""}`}>
|
||||
|
||||
@@ -135,3 +135,55 @@
|
||||
.plainImage { width: 100%; height: 100%; display: block; object-fit: contain; pointer-events: none; user-select: none; }
|
||||
.nodeTitle { min-width: 0; overflow: hidden; text-overflow: ellipsis; }
|
||||
.nodeTitleInput { width: 180px; min-width: 0; height: 22px; padding: 0 4px; border: 1px solid #cbb8a8; border-radius: 4px; background: #fffefb; font: inherit; color: #78523f; }
|
||||
|
||||
.frameLabelSegments { position: relative; display: flex; padding: 3px; border-radius: 6px; background: #f1eee8; isolation: isolate; }
|
||||
.frameLabelSegments button { position: relative; z-index: 1; width: 28px; height: 28px; flex: 0 0 28px; background: transparent !important; color: #92877c; }
|
||||
.frameLabelSegments button[aria-checked="true"] { color: #78523f; }
|
||||
.frameLabelSlider { position: absolute; left: 3px; top: 3px; width: 28px; height: 28px; border-radius: 4px; background: #fffefb; box-shadow: 0 1px 4px #32251a16; transition: transform 160ms ease; }
|
||||
.resizeHandle { position: absolute; width: 10px; height: 10px; transform: translate(-50%, -50%); border: 1px solid #a47764; border-radius: 2px; padding: 0; background: #fffefb; z-index: 12; touch-action: none; pointer-events: auto; }
|
||||
.handMode .resizeHandle { pointer-events: none; }
|
||||
.edgeToolbar { display: flex; align-items: end; flex-wrap: wrap; gap: 7px; max-width: min(680px, calc(100vw - 24px)); padding: 10px; border: 1px solid #ded7ce; border-radius: 9px; background: #fffefb; color: #49372e; box-shadow: 0 5px 20px #32251a14; }
|
||||
.edgeToolbar label { display: flex; align-items: center; gap: 4px; font-size: 10px; }
|
||||
.edgeToolbar input[type="number"] { width: 50px; }
|
||||
.edgeToolbar input[type="color"] { width: 24px; height: 24px; padding: 0; border: 0; background: transparent; }
|
||||
.edgeToolbar input[type="range"] { width: 55px; }
|
||||
.edgeToolbar select { max-width: 92px; height: 26px; padding: 0 4px; background: #f4f0eb; border-radius: 4px; font-size: 11px; }
|
||||
.edgeToolbar button { padding: 6px; border-radius: 4px; }
|
||||
.edgeToolbar button:hover { background: #f1e6df; }
|
||||
.edgeToolbar button:disabled { opacity: .4; }
|
||||
.edgeToolbar small { width: 100%; color: #92877c; font-size: 10px; }
|
||||
.curveControl { cursor: move; touch-action: none; }
|
||||
.edgeDashAnimation { animation: edgeDash 1.6s linear infinite; }
|
||||
.edgePulseAnimation { animation: edgePulse 1.6s ease-in-out infinite; }
|
||||
@keyframes edgeDash { to { stroke-dashoffset: -40; } }
|
||||
@keyframes edgePulse { 0%, 100% { opacity: .1; } 50% { opacity: 1; } }
|
||||
@media (prefers-reduced-motion: reduce) { .frameLabelSlider { transition: none; } .edgeDashAnimation, .edgePulseAnimation { animation: none; } }
|
||||
.canvasBusy { position: absolute; inset: 0; display: flex; align-items: start; justify-content: center; padding-top: 80px; z-index: 200; background: #fffefb35; cursor: progress; color: #78523f; font-size: 12px; }
|
||||
.subgraphScale { display: flex; align-items: center; gap: 2px; font-size: 11px; }
|
||||
.subgraphScale input { width: 45px; }
|
||||
.collapsedSubgraph { position: absolute; inset: 15px; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 7px; color: #92877c; pointer-events: auto; font-size: 12px; }
|
||||
.collapsedSubgraph small { font-size: 10px; }
|
||||
.aiPanel { width: min(440px, calc(100vw - 28px)); border: 1px solid #ded7ce; border-radius: 13px; padding: 14px; background: #fffefb; box-shadow: 0 8px 30px #32251a16; color: #49372e; }
|
||||
.aiPanel header { display: flex; align-items: center; gap: 6px; font-size: 12px; margin-bottom: 9px; }
|
||||
.aiPanel header span { margin-left: auto; color: #92877c; max-width: 160px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 10px; }
|
||||
.aiPanel textarea { width: 100%; height: 92px; resize: vertical; padding: 4px; border: 0; outline: none; background: transparent; font-size: 13px; }
|
||||
.aiPanel footer { display: flex; align-items: center; gap: 8px; border-top: 1px solid #eee7de; padding-top: 9px; }
|
||||
.aiPanel footer select { max-width: 200px; min-width: 0; flex: 1; height: 28px; background: transparent; font-size: 11px; }
|
||||
.aiPanel footer label { display: flex; align-items: center; gap: 4px; font-size: 10px; white-space: nowrap; }
|
||||
.aiPanel footer button { width: 32px; height: 32px; flex: 0 0 32px; display: grid; place-items: center; border-radius: 8px; background: #78523f; color: white; }
|
||||
.aiPanel footer button:disabled { opacity: .35; }
|
||||
.aiPanel p { margin: 7px 0 0; color: #92877c; font-size: 10px; }
|
||||
.aiPanel p[role="alert"] { color: #a25439; }
|
||||
.aiConflict { max-height: 180px; overflow: auto; padding: 8px; border-radius: 7px; background: #f4eee7; font-size: 11px; }
|
||||
.aiConflict pre { white-space: pre-wrap; }
|
||||
.aiConflict img { max-height: 100px; }
|
||||
.aiConflict button { padding: 6px; color: #78523f; }
|
||||
.transformFrame :global(.react-flow__handle).centerHandle { width: 10px; height: 10px; border: 0; background: #a47764; opacity: 0; pointer-events: none; }
|
||||
.selectedFrame :global(.react-flow__handle).centerHandle { opacity: 1; }
|
||||
.transformFrame :global(.react-flow__handle).centerHandle:global(.connectionindicator) { pointer-events: auto; }
|
||||
.framePort { width: 8px; height: 8px; border: 1px solid #fffefb; background: #a47764; pointer-events: auto; }
|
||||
.framePort { opacity: 0; pointer-events: none; }
|
||||
.selectedFrame .framePort { opacity: 1; }
|
||||
.framePort:global(.connectionindicator) { pointer-events: auto; }
|
||||
.rotatingFrame :global(.react-flow__handle).centerHandle { pointer-events: none; }
|
||||
.canvasBusy button { margin: 8px 5px; padding: 6px 9px; border: 1px solid #ded7ce; border-radius: 5px; background: #fffefb; cursor: pointer; }
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
"use client";
|
||||
import { Handle, Position } from "@xyflow/react";
|
||||
import styles from "./canvas.module.css";
|
||||
export function ContentHandles({ selected }: { selected: boolean }) {
|
||||
return <>{([["top", Position.Top], ["right", Position.Right], ["bottom", Position.Bottom], ["left", Position.Left]] as const).map(([id, position]) => <Handle key={id} id={id} type="source" position={position} className={styles.centerHandle} isConnectableStart={selected} aria-hidden={!selected} aria-label={`从 ${id} 连接元素`} />)}</>;
|
||||
}
|
||||
@@ -2,12 +2,12 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import styles from "./canvas.module.css";
|
||||
export function DraftRecovery({ restore, discard }: { restore: () => void; discard: () => void }) {
|
||||
export function DraftRecovery({ restore, discard, canRestore = true, exportDraft }: { restore: () => void; discard: () => void; canRestore?: boolean; exportDraft?: () => void }) {
|
||||
const dialog = useRef<HTMLDialogElement>(null);
|
||||
useEffect(() => { const element = dialog.current; element?.showModal(); return () => element?.close(); }, []);
|
||||
if (typeof document === "undefined") return null;
|
||||
return createPortal(<dialog ref={dialog} className={styles.recoveryDialog} aria-labelledby="draft-recovery-title" onCancel={(event) => event.preventDefault()}>
|
||||
<h2 id="draft-recovery-title">发现未同步的本地草稿</h2><p>服务端画布已读取。恢复草稿会以本地内容替换当前服务端版本,请选择要继续编辑的版本。</p>
|
||||
<div><button type="button" onClick={restore}>恢复本地草稿</button><button type="button" onClick={discard}>使用服务端版本</button></div>
|
||||
<h2 id="draft-recovery-title">发现未同步的本地草稿</h2><p>{canRestore ? "服务端画布已读取。恢复草稿会以本地内容替换当前服务端版本,请选择要继续编辑的版本。" : "画布的子图关系已更新,这份旧草稿不能直接覆盖。可以先导出草稿,再使用服务端版本继续编辑。"}</p>
|
||||
<div>{canRestore && <button type="button" onClick={restore}>恢复本地草稿</button>}{exportDraft && <button type="button" onClick={exportDraft}>导出本地草稿</button>}<button type="button" onClick={discard}>使用服务端版本</button></div>
|
||||
</dialog>, document.body);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
"use client";
|
||||
import { createContext } from "react";
|
||||
import type { EdgeData } from "@/lib/canvas-v2";
|
||||
export const EdgeEditing = createContext({ begin: (_id?: string) => {}, patch: (_id: string, _data: EdgeData, _transient = false) => {}, active: new Set<string>() });
|
||||
@@ -0,0 +1,32 @@
|
||||
"use client";
|
||||
import { EdgeToolbar } from "@xyflow/react";
|
||||
import { Plus, Trash2 } from "lucide-react";
|
||||
import type { EdgeData, EdgeMarker, MarkerShape } from "@/lib/canvas-v2";
|
||||
import styles from "./canvas.module.css";
|
||||
|
||||
export function EdgeToolbarControls({ id, x, y, data, change, add, remove, count, knotMode, changeKnotMode }: { id: string; x: number; y: number; data: EdgeData; change: (data: EdgeData) => void; add: () => void; remove: () => void; count: number; knotMode?: string; changeKnotMode: (mode: "corner" | "smooth" | "symmetric") => void }) {
|
||||
const a = data.appearance || {}, animation = data.animation || {};
|
||||
const appearance = (patch: NonNullable<EdgeData["appearance"]>) => change({ ...data, appearance: { ...a, ...patch } });
|
||||
const animate = (patch: NonNullable<EdgeData["animation"]>) => change({ ...data, animation: { ...animation, ...patch } });
|
||||
const markers: [MarkerShape, string][] = [["none", "无"], ["open-arrow", "开放箭头"], ["triangle", "三角箭头"], ["circle", "圆形"], ["diamond", "菱形"], ["square", "方形"]];
|
||||
return <EdgeToolbar edgeId={id} x={x} y={y} isVisible style={{ zIndex: 115, pointerEvents: "auto" }}><div role="toolbar" aria-label="连线编辑" className={`${styles.edgeToolbar} nodrag nopan nowheel`}
|
||||
onPointerDown={(event) => event.stopPropagation()} onClick={(event) => event.stopPropagation()} onDoubleClick={(event) => event.stopPropagation()} onKeyDown={(event) => event.stopPropagation()}>
|
||||
<label>颜色<input aria-label="连线颜色" type="color" value={a.color || "#aa968b"} onChange={(event) => appearance({ color: event.target.value })} /></label>
|
||||
<label>粗细<input aria-label="连线粗细" type="number" min={.5} max={20} step={.5} value={a.width ?? 1.5} onChange={(event) => { const value = +event.target.value; if (value >= .5 && value <= 20) appearance({ width: value }); }} /></label>
|
||||
<label>线条<select aria-label="线条类型" value={a.line || "solid"} onChange={(event) => appearance({ line: event.target.value as typeof a.line })}><option value="solid">实线</option><option value="dashed">虚线</option><option value="dotted">点线</option></select></label>
|
||||
{(["head", "tail"] as const).map((end) => <label key={end}>{end === "head" ? "箭头" : "箭尾"}<select aria-label={end === "head" ? "箭头类型" : "箭尾类型"} value={a[end]?.type || "none"} onChange={(event) => appearance({ [end]: { ...a[end], type: event.target.value as EdgeMarker["type"] } })}>{markers.map(([id, label]) => <option key={id} value={id}>{label}</option>)}</select>
|
||||
<input aria-label={end === "head" ? "箭头大小" : "箭尾大小"} type="number" min={4} max={32} value={a[end]?.size || 12} onChange={(event) => { const size = +event.target.value; if (size >= 4 && size <= 32) appearance({ [end]: { ...a[end], type: a[end]?.type || "none", size } }); }} />
|
||||
<input aria-label={end === "head" ? "填充箭头" : "填充箭尾"} type="checkbox" checked={a[end]?.filled ?? true} onChange={(event) => appearance({ [end]: { ...a[end], type: a[end]?.type || "none", filled: event.target.checked } })} /></label>)}
|
||||
<label>透明度<input aria-label="连线透明度" type="range" min={.05} max={1} step={.05} value={a.opacity ?? 1} onChange={(event) => appearance({ opacity: +event.target.value })} /></label>
|
||||
<label>动画<select aria-label="动画播放时机" value={animation.mode || "off"} onChange={(event) => animate({ mode: event.target.value as typeof animation.mode })}><option value="off">关闭</option><option value="always">始终播放</option><option value="executing">仅执行时</option></select></label>
|
||||
<label>效果<select aria-label="连线动画效果" value={animation.effect || "flow"} onChange={(event) => animate({ effect: event.target.value as typeof animation.effect })}><option value="flow">流光</option><option value="dash">流动虚线</option><option value="pulse">脉冲</option></select></label>
|
||||
<label>方向<select aria-label="动画方向" value={animation.direction || "forward"} onChange={(event) => animate({ direction: event.target.value as typeof animation.direction })}><option value="forward">正向</option><option value="reverse">反向</option></select></label>
|
||||
<label>时长<input aria-label="动画时长" title="毫秒" type="number" min={250} max={30000} step={250} value={animation.durationMs || 1600} onChange={(event) => { const durationMs = +event.target.value; if (durationMs >= 250 && durationMs <= 30000) animate({ durationMs }); }} /></label>
|
||||
<label>光色<input aria-label="动画颜色" type="color" value={animation.color || "#ffffff"} onChange={(event) => animate({ color: event.target.value })} /></label>
|
||||
<label><input aria-label="参与下游传播" type="checkbox" checked={data.execution?.enabled !== false} onChange={(event) => change({ ...data, execution: { enabled: event.target.checked } })} />传递</label>
|
||||
<label>曲线点<select aria-label="曲线点控制模式" disabled={!count} value={knotMode || "smooth"} onChange={(event) => changeKnotMode(event.target.value as "corner" | "smooth" | "symmetric")}><option value="corner">独立控制柄</option><option value="smooth">平滑</option><option value="symmetric">镜像</option></select></label>
|
||||
<button type="button" aria-label="添加曲线点" title="也可双击连线添加曲线点" disabled={count >= 64} onClick={add}><Plus size={14} /></button>
|
||||
<button type="button" aria-label="删除选中曲线点" disabled={!count} onClick={remove}><Trash2 size={14} /></button>
|
||||
<small>拖动锚点或控制柄 · 双击添加 · Shift 镜像控制柄</small>
|
||||
</div></EdgeToolbar>;
|
||||
}
|
||||
@@ -1,77 +1,23 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useId, useLayoutEffect, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { Check, ChevronDown } from "lucide-react";
|
||||
import { useRef } from "react";
|
||||
import { EyeOff, Scaling, Type } from "lucide-react";
|
||||
import type { FrameLabelMode } from "@/lib/canvas-elements";
|
||||
import styles from "./canvas.module.css";
|
||||
|
||||
const options = [
|
||||
{ value: "hidden", label: "隐藏名称", hint: "在画布上隐藏分区名称" },
|
||||
{ value: "fixed", label: "固定字号", hint: "缩放画布时保持名称大小" },
|
||||
{ value: "scaled", label: "随画布缩放", hint: "名称随画布一起放大缩小" },
|
||||
{ value: "hidden", label: "隐藏名称", hint: "在画布上隐藏分区名称", Icon: EyeOff },
|
||||
{ value: "fixed", label: "固定字号", hint: "缩放画布时保持名称大小", Icon: Type },
|
||||
{ value: "scaled", label: "随画布缩放", hint: "名称随画布一起放大缩小", Icon: Scaling },
|
||||
] as const;
|
||||
|
||||
export function FrameLabelMenu({ value, change }: { value: FrameLabelMode; change: (mode: FrameLabelMode) => void }) {
|
||||
const id = useId();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [position, setPosition] = useState({ left: 0, top: 0 });
|
||||
const trigger = useRef<HTMLButtonElement>(null);
|
||||
const menu = useRef<HTMLDivElement>(null);
|
||||
const items = useRef<(HTMLButtonElement | null)[]>([]);
|
||||
const initialIndex = useRef(0);
|
||||
const current = options.findIndex((option) => option.value === value);
|
||||
function show(index = current) { initialIndex.current = index; setOpen(true); }
|
||||
function close(focus = false) { setOpen(false); if (focus) trigger.current?.focus(); }
|
||||
useLayoutEffect(() => {
|
||||
if (!open) return;
|
||||
const bounds = trigger.current!.getBoundingClientRect();
|
||||
const height = menu.current!.getBoundingClientRect().height;
|
||||
setPosition({ left: Math.max(8, Math.min(bounds.left, window.innerWidth - 228)), top: Math.max(8, bounds.bottom + height + 8 <= window.innerHeight ? bounds.bottom + 6 : bounds.top - height - 6) });
|
||||
items.current[initialIndex.current]?.focus({ preventScroll: true });
|
||||
}, [open]);
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const outside = (event: PointerEvent) => {
|
||||
if (!trigger.current?.contains(event.target as Node) && !menu.current?.contains(event.target as Node)) close();
|
||||
};
|
||||
const dismiss = (event: Event) => {
|
||||
if (event.target instanceof Node && menu.current?.contains(event.target)) return;
|
||||
close();
|
||||
};
|
||||
document.addEventListener("pointerdown", outside, true);
|
||||
window.addEventListener("resize", dismiss);
|
||||
window.addEventListener("scroll", dismiss, true);
|
||||
window.addEventListener("wheel", dismiss, true);
|
||||
return () => {
|
||||
document.removeEventListener("pointerdown", outside, true);
|
||||
window.removeEventListener("resize", dismiss);
|
||||
window.removeEventListener("scroll", dismiss, true);
|
||||
window.removeEventListener("wheel", dismiss, true);
|
||||
};
|
||||
}, [open]);
|
||||
return <>
|
||||
<button ref={trigger} type="button" className={styles.frameModeTrigger} aria-label="分区名称显示模式" aria-haspopup="menu" aria-expanded={open} aria-controls={open ? id : undefined}
|
||||
onClick={() => open ? close() : show()} onKeyDown={(event) => {
|
||||
if (event.key === "ArrowDown" || event.key === "ArrowUp") { event.preventDefault(); show(event.key === "ArrowUp" ? options.length - 1 : current); }
|
||||
}}>{options[current].label}<ChevronDown size={13} /></button>
|
||||
{open && createPortal(<div ref={menu} id={id} className={`${styles.frameModeMenu} nodrag nopan nowheel`} style={position} role="menu" aria-label="分区名称显示模式选项"
|
||||
onPointerDown={(event) => event.stopPropagation()} onClick={(event) => event.stopPropagation()} onDoubleClick={(event) => event.stopPropagation()}
|
||||
onBlur={(event) => { if (!event.currentTarget.contains(event.relatedTarget as Node)) close(); }}
|
||||
onKeyDown={(event) => {
|
||||
event.stopPropagation();
|
||||
const index = items.current.indexOf(document.activeElement as HTMLButtonElement);
|
||||
if (event.key === "Escape") { event.preventDefault(); close(true); }
|
||||
else if (["ArrowDown", "ArrowUp", "Home", "End"].includes(event.key)) {
|
||||
event.preventDefault();
|
||||
const next = event.key === "Home" ? 0 : event.key === "End" ? options.length - 1 : (index + (event.key === "ArrowDown" ? 1 : -1) + options.length) % options.length;
|
||||
items.current[next]?.focus({ preventScroll: true });
|
||||
}
|
||||
}}>
|
||||
{options.map((option, index) => <button key={option.value} ref={(element) => { items.current[index] = element; }} type="button" role="menuitemradio" aria-checked={value === option.value} tabIndex={-1}
|
||||
onClick={() => { change(option.value); close(true); }}>
|
||||
<span className={styles.frameModeCheck}>{value === option.value && <Check size={14} />}</span><span><strong>{option.label}</strong><small>{option.hint}</small></span>
|
||||
</button>)}
|
||||
</div>, document.body)}
|
||||
</>;
|
||||
const current = Math.max(0, options.findIndex((option) => option.value === value));
|
||||
return <div className={styles.frameLabelSegments} role="radiogroup" aria-label="分区名称显示模式" onKeyDown={(event) => {
|
||||
const delta = { ArrowRight: 1, ArrowDown: 1, ArrowLeft: -1, ArrowUp: -1 }[event.key];
|
||||
if (delta !== undefined || event.key === "Home" || event.key === "End") {
|
||||
event.preventDefault(); event.stopPropagation(); const index = event.key === "Home" ? 0 : event.key === "End" ? 2 : (current + delta! + 3) % 3;
|
||||
change(options[index].value); items.current[index]?.focus();
|
||||
}
|
||||
}}><span className={styles.frameLabelSlider} style={{ transform: `translateX(${current * 28}px)` }} />
|
||||
{options.map(({ value: mode, label, hint, Icon }, index) => <button key={mode} ref={(element) => { items.current[index] = element; }} role="radio" aria-checked={value === mode} aria-label={label} title={`${label}:${hint}`} tabIndex={value === mode ? 0 : -1} onClick={() => change(mode)}><Icon size={15} /></button>)}
|
||||
</div>;
|
||||
}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { useContext, useEffect, useRef, useState } from "react";
|
||||
import { NodeToolbar, Position, useViewport, type NodeProps } from "@xyflow/react";
|
||||
import { FoldVertical, UnfoldVertical, ExternalLink } from "lucide-react";
|
||||
import { Handle, NodeToolbar, Position, useViewport, type NodeProps } from "@xyflow/react";
|
||||
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
|
||||
import type { CanvasNode } from "@/lib/canvas";
|
||||
import { NodeEditing, TransformableNode } from "./transformable-node";
|
||||
import { NodeEditing, TransformableNode, ResizeToggle } from "./transformable-node";
|
||||
import { FrameFeedback } from "./frame-feedback";
|
||||
import { FrameLabelMenu } from "./frame-label-menu";
|
||||
import styles from "./canvas.module.css";
|
||||
@@ -15,7 +16,6 @@ export function FrameNode(props: NodeProps<CanvasNode>) {
|
||||
const feedback = useContext(FrameFeedback);
|
||||
const incoming = feedback.drops.filter((drop) => drop.to === id && drop.from !== id).length;
|
||||
const outgoing = feedback.drops.filter((drop) => drop.from === id && drop.to !== id).length;
|
||||
const count = feedback.counts.get(id) || 0;
|
||||
const { zoom } = useViewport();
|
||||
const reduce = useReducedMotion();
|
||||
const [title, setTitle] = useState(data.title);
|
||||
@@ -35,13 +35,27 @@ export function FrameNode(props: NodeProps<CanvasNode>) {
|
||||
<input className={styles.frameNameInput} aria-label="分区名称" value={title} maxLength={300} onChange={(event) => setTitle(event.target.value)} onBlur={() => { if (cancelBlur.current) cancelBlur.current = false; else saveTitle(); }}
|
||||
onKeyDown={(event) => { if (event.key === "Enter" && !event.nativeEvent.isComposing) { event.currentTarget.blur(); } if (event.key === "Escape") { cancelBlur.current = true; setTitle(data.title); event.currentTarget.blur(); } }} />
|
||||
<FrameLabelMenu value={mode} change={(labelMode) => editing.frame(id, { labelMode })} />
|
||||
<span className={styles.frameMemberCount}>{count} 项内容</span>
|
||||
<ResizeToggle id={id} />
|
||||
{data.subgraph && <><button type="button" aria-label={data.presentation?.collapsed ? "展开子画布" : "收起子画布"} title={data.presentation?.collapsed ? "展开子画布" : "收起子画布"} onClick={() => {
|
||||
const collapsed = !data.presentation?.collapsed;
|
||||
const currentSize = { width: (props.width || 640) / (data.viewScale || 1), height: (props.height || 400) / (data.viewScale || 1) };
|
||||
const presentation = { ...data.presentation, collapsed, [collapsed ? "expandedSize" : "collapsedSize"]: currentSize };
|
||||
editing.frame(id, { presentation });
|
||||
}}>{data.presentation?.collapsed ? <UnfoldVertical size={16} /> : <FoldVertical size={16} />}</button>
|
||||
<label className={styles.subgraphScale} title="子画布概览比例,不修改内容尺寸"><input aria-label="子画布概览比例" type="number" min={5} max={400} step={5} value={Math.round((data.presentation?.contentScale || 1) * 100)} onChange={(event) => { const scale = +event.target.value / 100; if (scale >= .05 && scale <= 4) editing.frame(id, { presentation: { ...data.presentation, contentScale: scale } }); }} />%</label>
|
||||
<a href={`/dashboard/${encodeURIComponent(editing.workspace)}/${data.subgraph.boardId}`} aria-label="打开子画布" title="打开子画布"><ExternalLink size={15} /></a></>}
|
||||
|
||||
</motion.div>}</AnimatePresence>
|
||||
</NodeToolbar>
|
||||
<TransformableNode {...props} hideStyleToolbar className={`${styles.frameNode} ${incoming ? styles.frameDropTarget : ""} ${outgoing ? styles.frameDropExit : ""}`}>
|
||||
{mode !== "hidden" && <div className={styles.frameLabel} style={{ scale: mode === "fixed" ? 1 / zoom : 1 }} title={data.title}>{data.title}<span className={styles.frameMemberCount}> · {count} 项</span></div>}
|
||||
{(incoming > 0 || outgoing > 0) && <div className={styles.frameDropHint} style={{ scale: 1 / zoom }}>{incoming > 0 ? `松开加入 · ${incoming} 项` : `松开移出 · ${outgoing} 项`}</div>}
|
||||
{data.subgraph && data.presentation?.collapsed && <a className={styles.collapsedSubgraph} href={`/dashboard/${encodeURIComponent(editing.workspace)}/${data.subgraph.boardId}`} onPointerDown={(event) => event.stopPropagation()}><FoldVertical size={22} /><span>{data.title}</span><small>打开子画布</small></a>}
|
||||
{mode !== "hidden" && <div className={styles.frameLabel} style={{ scale: mode === "fixed" ? 1 / (zoom * (data.viewScale || 1)) : 1 }} title={data.title}>{data.title}</div>}
|
||||
{(incoming > 0 || outgoing > 0) && <div className={styles.frameDropHint} style={{ scale: 1 / (zoom * (data.viewScale || 1)) }}>{incoming > 0 ? `松开加入 · ${incoming} 项` : `松开移出 · ${outgoing} 项`}</div>}
|
||||
{(["top", "right", "bottom", "left"] as const).map((side) => <div key={side} className={`${styles.frameBorderHit} ${styles["frameBorder_" + side]}`} title="拖动分区,已有成员一起移动" />)}
|
||||
{data.ports?.map((port) => {
|
||||
const peers = data.ports!.filter((item) => item.direction === port.direction);
|
||||
return <Handle key={port.id} id={`port:${port.id}`} type="source" position={port.direction === "input" ? Position.Left : Position.Right} className={styles.framePort} style={{ top: `${(peers.indexOf(port) + 1) / (peers.length + 1) * 100}%` }} isConnectableStart={selected && port.direction === "output"} isConnectableEnd={port.direction === "input"} title={port.direction === "input" ? "子画布输入" : "子画布输出"} />;
|
||||
})}
|
||||
</TransformableNode>
|
||||
</>;
|
||||
}
|
||||
|
||||
@@ -1,24 +1,29 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useReducer, useRef, useState, type ChangeEvent } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type ChangeEvent } from "react";
|
||||
import {
|
||||
Background, BackgroundVariant, ConnectionLineType, ConnectionMode, ReactFlow, ReactFlowProvider, SelectionMode,
|
||||
addEdge, useReactFlow, useViewport, type Connection, type OnConnectEnd, type XYPosition,
|
||||
} from "@xyflow/react";
|
||||
import { Download, FileText, Hand, HelpCircle, ImagePlus, Maximize, Minus, MousePointer2, Plus, Redo2, Sparkles, StickyNote, Shapes, Frame, Image as ImageIcon, Type, Undo2, Upload } from "lucide-react";
|
||||
import { Download, FileText, Hand, HelpCircle, ImagePlus, Maximize, Minus, MousePointer2, Plus, Redo2, Sparkles, StickyNote, Shapes, Frame, Image as ImageIcon, Bot, Type, Undo2, Upload } from "lucide-react";
|
||||
import { useReducedMotion } from "motion/react";
|
||||
import dynamic from "next/dynamic";
|
||||
import toolbar from "@/components/ui/toolbar.module.css";
|
||||
import { IconButton } from "@/components/ui/icon-button";
|
||||
import { branchCanvasNode, canvasReducer, canvasStorageKey, decodeCanvas, emptyCanvas, makeCanvasNode, type CanvasHistory, type CanvasNode, type NodeDirection } from "@/lib/canvas";
|
||||
import { errorText } from "@/lib/api";
|
||||
import { readLocalRecovery, type CanvasSnapshot } from "@/lib/canvas-sync";
|
||||
import { useCanvasPersistence } from "./use-canvas-persistence";
|
||||
import { branchCanvasNode, canvasStorageKey, decodeCanvas, emptyCanvas, makeCanvasNode, type CanvasNode, type NodeDirection } from "@/lib/canvas";
|
||||
import { api, errorText } from "@/lib/api";
|
||||
import { readLocalRecovery, canRestoreCanvasDraft, type CanvasSnapshot } from "@/lib/canvas-sync";
|
||||
import { useCanvasTree } from "./use-canvas-tree";
|
||||
import type { CanvasAction } from "@/lib/canvas";
|
||||
import type { Board } from "@/lib/workspace";
|
||||
import { useCanvasAI } from "./use-canvas-ai";
|
||||
import { AIPanel } from "./ai-panel";
|
||||
import { EdgeEditing } from "./edge-editing";
|
||||
import { CanvasEdge, CanvasConnectionLine } from "./canvas-edge";
|
||||
import { BranchMenu, type BranchRequest } from "./branch-menu";
|
||||
import { IconNode } from "./icon-node";
|
||||
import { FrameFeedback } from "./frame-feedback";
|
||||
import { subgraphMovePath, subgraphMoveParticipants, subgraphParents, exposedConnection, connectionTransactionPlan } from "@/lib/canvas-subgraphs";
|
||||
import { frameDropFeedback } from "@/lib/canvas-frames";
|
||||
import { DraftRecovery } from "./draft-recovery";
|
||||
import { FrameNode } from "./frame-node";
|
||||
@@ -44,14 +49,16 @@ function CanvasEditor({ board, snapshot }: { board: Board; snapshot?: CanvasSnap
|
||||
return { document: snapshot?.document || local || emptyCanvas(), recovery: snapshot ? local : null, error: "" };
|
||||
} catch { return { document: snapshot?.document || emptyCanvas(), recovery: null, error: "无法读取此浏览器的图谱草稿,可通过导入恢复备份。" }; }
|
||||
});
|
||||
const [state, dispatch] = useReducer(canvasReducer, { past: [], present: initial.document, future: [] } satisfies CanvasHistory);
|
||||
|
||||
const [tool, setTool] = useState<"select" | "hand">("select");
|
||||
const [error, setError] = useState(initial.error);
|
||||
const [branchRequest, setBranchRequest] = useState<BranchRequest | null>(null);
|
||||
const [iconPicker, setIconPicker] = useState(false);
|
||||
const [resizing, setResizing] = useState<string | null>(null);
|
||||
const [rotating, setRotating] = useState<string | null>(null);
|
||||
const [draggedIds, setDraggedIds] = useState<string[]>([]);
|
||||
const [membershipNotice, setMembershipNotice] = useState("");
|
||||
const dragSession = useRef<"idle" | "active" | "finishing">("idle");
|
||||
const dragOrigins = useRef(new Map<string, XYPosition>());
|
||||
const draggingFrame = useRef(false);
|
||||
const connectionStart = useRef<{ id: string; direction: NodeDirection; point: XYPosition } | null>(null);
|
||||
@@ -61,7 +68,17 @@ function CanvasEditor({ board, snapshot }: { board: Board; snapshot?: CanvasSnap
|
||||
const imageInput = useRef<HTMLInputElement>(null);
|
||||
const projectInput = useRef<HTMLInputElement>(null);
|
||||
const [recovery, setRecovery] = useState(initial.recovery);
|
||||
const sync = useCanvasPersistence(state.present, board.workspace_id, board.id, storageKey, snapshot, Boolean(recovery));
|
||||
const tree = useCanvasTree(board.id, board.workspace_id, initial.document, snapshot, Boolean(recovery), board.parent_board_id);
|
||||
const childRecovery = [...tree.recoveries][0];
|
||||
const downloadDraft = (document: import("@/lib/canvas").CanvasDocument) => { const url = URL.createObjectURL(new Blob([JSON.stringify(document, null, 2)], { type: "application/json" })); const link = window.document.createElement("a"); link.href = url; link.download = "画布本地草稿.json"; link.click(); setTimeout(() => URL.revokeObjectURL(url), 1000); };
|
||||
const sync = tree;
|
||||
const ai = useCanvasAI(board.workspace_id, board.id, !!snapshot, tree);
|
||||
const state = { present: { ...tree.rootDocument, nodes: tree.views.nodes, edges: tree.views.edges } };
|
||||
const treeRef = useRef(tree); treeRef.current = tree;
|
||||
function dispatch(action: CanvasAction) {
|
||||
if (action.type === "undo" || action.type === "redo") { void treeRef.current[action.type]().catch((error) => setError(error.message)); return; }
|
||||
treeRef.current.dispatch(action);
|
||||
}
|
||||
useEffect(() => {
|
||||
const input = imageInput.current;
|
||||
const cancel = () => { pendingImage.current = null; };
|
||||
@@ -85,22 +102,33 @@ function CanvasEditor({ board, snapshot }: { board: Board; snapshot?: CanvasSnap
|
||||
const latest = useRef(state.present);
|
||||
latest.current = state.present;
|
||||
|
||||
const select = useCallback((id: string) => dispatch({ type: "nodes", changes: latest.current.nodes.map((node) => ({ id: node.id, type: "select" as const, selected: node.id === id })) }), []);
|
||||
const beginEdit = useCallback(() => dispatch({ type: "checkpoint" }), []);
|
||||
const edit = useCallback((id: string, text: string) => dispatch({ type: "edit", id, text }), []);
|
||||
const branch = useCallback((id: string, direction: NodeDirection, point: XYPosition) => {
|
||||
setBranchRequest({ id, direction, point });
|
||||
setRotating(null);
|
||||
const select = useCallback((id: string) => treeRef.current.select(id), []);
|
||||
const beginEdit = useCallback((id?: string) => { const current = treeRef.current; const ref = id ? current.views.nodeRefs.get(id) || current.views.edgeRefs.get(id) : undefined; current.dispatch({ type: "checkpoint" }, ref?.boardId || board.id); }, [board.id]);
|
||||
const edit = useCallback((id: string, text: string) => treeRef.current.nodeAction(id, (id) => ({ type: "edit", id, text })), []);
|
||||
const branch = useCallback((id: string, direction: NodeDirection, point: XYPosition) => { setBranchRequest({ id, direction, point }); setRotating(null); setResizing(null); }, []);
|
||||
const transform = useCallback((id: string, transform: NodeTransform) => {
|
||||
const ref = treeRef.current.views.nodeRefs.get(id); if (!ref) return;
|
||||
treeRef.current.nodeAction(id, (id) => ({ type: "transform", id, transform: { ...transform,
|
||||
...(transform.position ? { position: { x: (transform.position.x - ref.origin.x) / ref.scale, y: (transform.position.y - ref.origin.y) / ref.scale } } : {}),
|
||||
...(transform.width !== undefined ? { width: transform.width / ref.scale, height: transform.height! / ref.scale } : {}) } }));
|
||||
}, []);
|
||||
const transform = useCallback((id: string, transform: NodeTransform) => dispatch({ type: "transform", id, transform }), []);
|
||||
const style = useCallback((id: string, style: TextStyle, fontSize?: number) => dispatch({ type: "style", id, style, fontSize }), []);
|
||||
const rename = useCallback((id: string, title: string) => dispatch({ type: "rename-node", id, title }), []);
|
||||
const frame = useCallback((id: string, patch: { title?: string; labelMode?: FrameLabelMode }) => dispatch({ type: "frame", id, ...patch }), []);
|
||||
const editing = useMemo(() => ({ begin: beginEdit, select, edit, branch, transform, style, rename, frame, rotating, rotate: setRotating }), [beginEdit, select, edit, branch, transform, style, rename, frame, rotating]);
|
||||
const style = useCallback((id: string, style: TextStyle, fontSize?: number) => treeRef.current.nodeAction(id, (id) => ({ type: "style", id, style, fontSize })), []);
|
||||
const rename = useCallback((id: string, title: string) => treeRef.current.nodeAction(id, (id) => ({ type: "rename-node", id, title })), []);
|
||||
const frame = useCallback((id: string, patch: { title?: string; labelMode?: FrameLabelMode; presentation?: import("@/lib/canvas-v2").FramePresentation }) => {
|
||||
const current = treeRef.current, ref = current.views.nodeRefs.get(id); if (!ref) return;
|
||||
const document = current.documents.get(ref.boardId)!;
|
||||
current.dispatch({ type: "replace", document: { ...document, schema_version: 2, nodes: document.nodes.map((node) => {
|
||||
if (node.id !== ref.id) return node;
|
||||
const toggle = patch.presentation && !!patch.presentation.collapsed !== !!node.data.presentation?.collapsed;
|
||||
const size = toggle ? patch.presentation!.collapsed ? node.data.presentation?.collapsedSize || { width: 240, height: 140 } : node.data.presentation?.expandedSize || { width: 640, height: 400 } : undefined;
|
||||
return { ...node, ...(size ? { ...size, style: { ...node.style, ...size } } : {}), data: { ...node.data, ...patch } };
|
||||
}) } }, ref.boardId);
|
||||
}, []);
|
||||
const editing = useMemo(() => ({ begin: beginEdit, select, edit, branch, transform, style, rename, frame, workspace: board.workspace_id, schema: 2 as const, rotating, resizing, resize: (id: string | null) => { setResizing(id); setRotating(null); }, rotate: (id: string | null) => { setRotating(id); setResizing(null); } }), [beginEdit, select, edit, branch, transform, style, rename, frame, rotating, resizing, board.workspace_id, state.present.schema_version]);
|
||||
function createBranch(request: BranchRequest, kind: "text" | "image" | "script", src?: string) {
|
||||
try {
|
||||
const document = branchCanvasNode(state.present, request.id, request.direction, kind, src, request.at);
|
||||
dispatch({ type: "replace", document });
|
||||
const document = branchCanvasNode(tree.documents.get(tree.views.nodeRefs.get(request.id)!.boardId)!, tree.views.nodeRefs.get(request.id)!.id, request.direction, kind, src, request.at ? { x: (request.at.x - tree.views.nodeRefs.get(request.id)!.origin.x) / tree.views.nodeRefs.get(request.id)!.scale, y: (request.at.y - tree.views.nodeRefs.get(request.id)!.origin.y) / tree.views.nodeRefs.get(request.id)!.scale } : undefined);
|
||||
tree.dispatch({ type: "replace", document }, tree.views.nodeRefs.get(request.id)!.boardId);
|
||||
const child = document.nodes.at(-1)!;
|
||||
void flow.setCenter(child.position.x + 130, child.position.y + 100, { zoom: flow.getZoom(), duration });
|
||||
setBranchRequest(null);
|
||||
@@ -129,15 +157,19 @@ function CanvasEditor({ board, snapshot }: { board: Board; snapshot?: CanvasSnap
|
||||
return { x: point.x - 130 + offset, y: point.y - 80 + offset };
|
||||
};
|
||||
function addNode(kind: CanvasNode["data"]["kind"], src?: string, size?: { width: number; height: number }, title?: string) {
|
||||
if (nodes.length >= 500) { setError("当前图谱已达到 500 个节点,请新建图谱继续创作。"); return; }
|
||||
if (tree.rootDocument.nodes.length >= 500) { setError("当前图谱已达到 500 个节点,请新建图谱继续创作。"); return; }
|
||||
const node = makeCanvasNode(kind, center(), src);
|
||||
if (kind === "frame" && snapshot) {
|
||||
void tree.command(board.id, "subgraphs", { frame: { id: node.id, title: node.data.title, position: node.position, width: node.width, height: node.height, labelMode: "fixed" } }).catch((error) => setError(error.message));
|
||||
setTool("select"); return;
|
||||
}
|
||||
if (size) { node.width = size.width; node.height = size.height; node.style = { ...node.style, ...size }; }
|
||||
if (title) node.data.title = title.slice(0, 300);
|
||||
dispatch({ type: "nodes", changes: [{ type: "add", item: node }] });
|
||||
setTool("select");
|
||||
}
|
||||
function addTemplate() {
|
||||
if (nodes.length > 497) { setError("当前图谱没有足够空间插入模板。"); return; }
|
||||
if (tree.rootDocument.nodes.length > 497) { setError("当前图谱没有足够空间插入模板。"); return; }
|
||||
const position = center();
|
||||
const brief = makeCanvasNode("text", { x: position.x - 300, y: position.y });
|
||||
brief.data = { ...brief.data, title: "创意简报", text: "你想表达什么?\n\n记录主题、受众和一个关键想法。" };
|
||||
@@ -145,38 +177,96 @@ function CanvasEditor({ board, snapshot }: { board: Board; snapshot?: CanvasSnap
|
||||
idea.data = { ...idea.data, title: "视觉参考", text: "收集颜色、光线和画面氛围。" };
|
||||
const script = makeCanvasNode("script", { x: position.x + 340, y: position.y });
|
||||
script.data.text = "从第一个镜头开始,写下你的故事。";
|
||||
dispatch({ type: "replace", document: { ...state.present, nodes: [...nodes, brief, idea, script], edges: [...edges,
|
||||
dispatch({ type: "replace", document: { ...tree.rootDocument, nodes: [...tree.rootDocument.nodes, brief, idea, script], edges: [...tree.rootDocument.edges,
|
||||
{ id: crypto.randomUUID(), source: brief.id, target: idea.id, type: "default" },
|
||||
{ id: crypto.randomUUID(), source: idea.id, target: script.id },
|
||||
] } });
|
||||
setTool("select");
|
||||
}
|
||||
function beginDrag(node: CanvasNode, dragged: CanvasNode[]) {
|
||||
beginEdit(); closeBranch(); setRotating(null);
|
||||
if (dragSession.current !== "idle") return; dragSession.current = "active";
|
||||
beginEdit(node.id); closeBranch(); setRotating(null); setResizing(null);
|
||||
draggingFrame.current = node.data.kind === "frame" || dragged.some((item) => item.data.kind === "frame");
|
||||
const before = new Map(latest.current.nodes.map((item) => [item.id, item.position]));
|
||||
dragOrigins.current = new Map((dragged.length ? dragged : [node]).map((item) => [item.id, before.get(item.id) || item.position]));
|
||||
setDraggedIds(draggingFrame.current ? [] : [...dragOrigins.current.keys()]);
|
||||
setMembershipNotice("");
|
||||
}
|
||||
function finishDrag(node: CanvasNode, dragged: CanvasNode[]) {
|
||||
if (!draggingFrame.current) {
|
||||
async function finishDrag(node: CanvasNode, dragged: CanvasNode[]) {
|
||||
if (dragSession.current !== "active") return; dragSession.current = "finishing";
|
||||
if (!draggingFrame.current || snapshot && tree.rootDocument.schema_version === 2) {
|
||||
const items = dragged.length ? dragged : [node];
|
||||
const ids = items.filter((item) => { const start = dragOrigins.current.get(item.id); return item.data.kind !== "frame" && start && (start.x !== item.position.x || start.y !== item.position.y); }).map((item) => item.id);
|
||||
const ids = items.filter((item) => { const start = dragOrigins.current.get(item.id); return (item.data.kind !== "frame" || snapshot && tree.rootDocument.schema_version === 2) && start && (start.x !== item.position.x || start.y !== item.position.y); }).map((item) => item.id);
|
||||
if (ids.length) {
|
||||
const positions = new Map(items.map((item) => [item.id, item.position]));
|
||||
const dropped = latest.current.nodes.map((item) => positions.has(item.id) ? { ...item, position: positions.get(item.id)! } : item);
|
||||
const changed = frameDropFeedback(dropped, ids).filter((item) => item.from !== item.to);
|
||||
const messages = changed.map((item) => item.to ? `已加入「${frameFeedback.names.get(item.to) || "分区"}」` : `已移出「${frameFeedback.names.get(item.from!) || "分区"}」`);
|
||||
if (messages.length) setMembershipNotice(`${changed.length > 1 ? `${changed.length} 项内容:` : ""}${[...new Set(messages)].join(";")}`);
|
||||
dispatch({ type: "drop", ids });
|
||||
const notice = messages.length ? `${changed.length > 1 ? `${changed.length} 项内容:` : ""}${[...new Set(messages)].join(";")}` : "";
|
||||
if (snapshot && tree.rootDocument.schema_version === 2) {
|
||||
try {
|
||||
const byOwner = new Map<string, { ids: string[]; target: string }>();
|
||||
const parents = subgraphParents(tree.current.current);
|
||||
for (const item of items) {
|
||||
const ref = tree.views.nodeRefs.get(item.id); if (!ref) continue;
|
||||
const center = { x: item.position.x + (item.width || 260) / 2, y: item.position.y + (item.height || 200) / 2 };
|
||||
const inMovingSubtree = (child: string) => { const seen = new Set<string>(); let cursor: string | undefined = child; while (cursor && !seen.has(cursor)) { if (cursor === item.data.subgraph?.boardId) return true; seen.add(cursor); cursor = parents.get(cursor)?.boardId; } return false; };
|
||||
const candidates = dropped.filter((candidate) => candidate.id !== item.id && candidate.data.subgraph && !inMovingSubtree(candidate.data.subgraph.boardId) && center.x >= candidate.position.x && center.y >= candidate.position.y && center.x <= candidate.position.x + (candidate.width || 640) && center.y <= candidate.position.y + (candidate.height || 400)).sort((a, b) => (a.width || 640) * (a.height || 400) - (b.width || 640) * (b.height || 400));
|
||||
const target = candidates[0]?.data.subgraph?.boardId || (ref.boardId === board.id ? board.id : parents.get(ref.boardId)?.boardId) || board.id;
|
||||
if (target !== ref.boardId) {
|
||||
const key = ref.boardId + "/" + target, group = byOwner.get(key) || { ids: [], target }; group.ids.push(ref.id); byOwner.set(key, group);
|
||||
}
|
||||
}
|
||||
for (const [key, group] of byOwner) {
|
||||
let source = key.split("/")[0];
|
||||
for (const hop of subgraphMovePath(tree.current.current, source, group.target)) {
|
||||
for (const id of group.ids) { const node = tree.current.current.get(hop.source)?.nodes.find((node) => node.id === id); if (node?.data.subgraph) await tree.ensure(node.data.subgraph.boardId, true); }
|
||||
const participants = subgraphMoveParticipants(tree.current.current, hop.parent, hop.source, hop.target, group.ids);
|
||||
await tree.command(hop.parent, `subgraphs/${hop.frame}/members`, { source_board_id: hop.source, target_board_id: hop.target, node_ids: group.ids }, participants);
|
||||
source = hop.target;
|
||||
}
|
||||
}
|
||||
if (byOwner.size) setMembershipNotice(notice || "已更新子画布归属");
|
||||
} catch (error) { setError(error instanceof Error ? error.message : "移动内容未完成"); setMembershipNotice(""); }
|
||||
} else { dispatch({ type: "drop", ids }); if (notice) setMembershipNotice(notice); }
|
||||
}
|
||||
}
|
||||
setDraggedIds([]);
|
||||
dragOrigins.current.clear();
|
||||
dragOrigins.current.clear(); dragSession.current = "idle";
|
||||
}
|
||||
function connect(connection: Connection) {
|
||||
dispatch({ type: "replace", document: { ...state.present, edges: addEdge({ ...connection, id: crypto.randomUUID() }, edges) } });
|
||||
async function connect(connection: Connection) {
|
||||
const source = tree.views.nodeRefs.get(connection.source), target = tree.views.nodeRefs.get(connection.target);
|
||||
if (!source || !target) return;
|
||||
try {
|
||||
const resolve = async (ref: typeof source, handle: string | null | undefined): Promise<{ boardId: string; id: string; handle: string }> => {
|
||||
let endpoint = { boardId: ref!.boardId, id: ref!.id, handle: handle || "right" };
|
||||
for (let depth = 0; depth <= 8; depth++) {
|
||||
const node = tree.current.current.get(endpoint.boardId)?.nodes.find((node) => node.id === endpoint.id);
|
||||
if (!node?.data.subgraph) return endpoint;
|
||||
const port = node.data.ports?.find((port) => `port:${port.id}` === endpoint.handle); if (!port) throw new Error("ownership_conflict");
|
||||
await tree.ensure(node.data.subgraph.boardId);
|
||||
endpoint = { boardId: node.data.subgraph.boardId, id: port.nodeId, handle: port.handle };
|
||||
}
|
||||
throw new Error("subgraph_cycle");
|
||||
};
|
||||
const realSource = await resolve(source, connection.sourceHandle), realTarget = await resolve(target, connection.targetHandle || "left");
|
||||
if (realSource.boardId === realTarget.boardId) {
|
||||
const document = tree.current.current.get(realSource.boardId)!;
|
||||
tree.dispatch({ type: "replace", document: { ...document, schema_version: 2, edges: addEdge({ id: crypto.randomUUID(), source: realSource.id, target: realTarget.id, sourceHandle: realSource.handle, targetHandle: realTarget.handle }, document.edges) } }, realSource.boardId);
|
||||
return;
|
||||
}
|
||||
if (realSource.boardId !== realTarget.boardId) {
|
||||
const plan = connectionTransactionPlan(tree.current.current, realSource, realTarget);
|
||||
const capability = await api<{ subgraphs?: { connections?: boolean } }>(`workspaces/${board.workspace_id}/materials/${plan.boardId}/canvas/capabilities`, { headers: { Accept: "application/vnd.foundry.canvas+json;version=2" } });
|
||||
if (capability.subgraphs?.connections) {
|
||||
await tree.command(plan.boardId, "connections", { source: plan.source, target: plan.target, edge: { id: crypto.randomUUID() } }, plan.participants);
|
||||
return;
|
||||
}
|
||||
}
|
||||
const exposed = exposedConnection(tree.current.current, realSource, realTarget);
|
||||
const document = tree.current.current.get(exposed.boardId)!;
|
||||
tree.dispatch({ type: "replace", document: { ...document, schema_version: 2, edges: addEdge({ ...connection, source: exposed.source.id, target: exposed.target.id, sourceHandle: exposed.source.handle, targetHandle: exposed.target.handle, id: crypto.randomUUID() }, document.edges) } }, exposed.boardId);
|
||||
} catch (error) { setError(error instanceof Error && error.message === "boundary_port_required" ? "跨画布连接暂不可用,请稍后重试。" : errorText(error)); }
|
||||
}
|
||||
async function readImage(event: ChangeEvent<HTMLInputElement>) {
|
||||
const file = event.target.files?.[0];
|
||||
@@ -212,14 +302,18 @@ function CanvasEditor({ board, snapshot }: { board: Board; snapshot?: CanvasSnap
|
||||
if (!file) return;
|
||||
try {
|
||||
if (file.size > 5_000_000) throw new Error("请选择小于 5 MB 的图谱文件。");
|
||||
if (tree.rootDocument.nodes.some((node) => node.data.subgraph)) throw new Error("当前画布包含真实子图,请先导出备份并通过子图事务恢复,避免覆盖子图关系。");
|
||||
const document = decodeCanvas(await file.text());
|
||||
if (document.nodes.some((node) => node.data.subgraph)) throw new Error("子图备份需要按原始画布关系恢复,不能直接导入到其他画布。");
|
||||
dispatch({ type: "replace", document });
|
||||
void flow.setViewport(document.viewport);
|
||||
setError("");
|
||||
} catch (error) { setError(error instanceof Error ? error.message : "导入失败。"); }
|
||||
}
|
||||
function exportProject() {
|
||||
const content = JSON.stringify({ ...state.present, viewport: flow.getViewport() }, null, 2);
|
||||
async function exportProject() {
|
||||
let documents: Map<string, import("@/lib/canvas").CanvasDocument>;
|
||||
try { documents = await tree.exportDocuments(); } catch (error) { setError(error instanceof Error ? error.message : "导出未完成"); return; }
|
||||
const content = JSON.stringify(documents.size > 1 ? { format: "endoleap-canvas-bundle", root: board.id, documents: Object.fromEntries(documents) } : { ...tree.rootDocument, viewport: flow.getViewport() }, null, 2);
|
||||
const url = URL.createObjectURL(new Blob([content], { type: "application/json" }));
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
@@ -250,36 +344,81 @@ function CanvasEditor({ board, snapshot }: { board: Board; snapshot?: CanvasSnap
|
||||
<div className={`${styles.editor} ${tool === "hand" ? styles.handMode : ""}`}>
|
||||
<NodeEditing.Provider value={editing}>
|
||||
<FrameFeedback.Provider value={frameFeedback}>
|
||||
<EdgeEditing.Provider value={{ begin: beginEdit, patch: tree.patchEdge, active: new Set([...tree.views.edgeRefs].filter(([id, ref]) => ai.activeEdges.has(ref.boardId + "/" + ref.id)).map(([id]) => id)) }}>
|
||||
<ReactFlow<CanvasNode> nodes={nodes} edges={edges} nodeTypes={nodeTypes} edgeTypes={edgeTypes}
|
||||
onNodesChange={(changes) => dispatch({ type: "nodes", changes })}
|
||||
onEdgesChange={(changes) => dispatch({ type: "edges", changes })}
|
||||
onBeforeDelete={async ({ nodes: removing }) => {
|
||||
if (tree.busy) return false;
|
||||
const linked = removing.filter((node) => node.data.subgraph);
|
||||
const parents = subgraphParents(tree.current.current);
|
||||
const referenced = removing.filter((node) => {
|
||||
const ref = tree.views.nodeRefs.get(node.id); if (!ref) return false;
|
||||
const parent = parents.get(ref.boardId);
|
||||
return parent && tree.current.current.get(parent.boardId)?.nodes.find((frame) => frame.id === parent.frameId)?.data.ports?.some((port) => port.nodeId === ref.id);
|
||||
});
|
||||
if (!linked.length && !referenced.length) return true;
|
||||
try {
|
||||
for (const node of linked) {
|
||||
const ref = tree.views.nodeRefs.get(node.id)!;
|
||||
await tree.ensure(node.data.subgraph!.boardId);
|
||||
const child = tree.current.current.get(node.data.subgraph!.boardId)!;
|
||||
const participants = new Set([ref.boardId, node.data.subgraph!.boardId, ...child.nodes.flatMap((item) => item.data.subgraph ? [item.data.subgraph.boardId] : [])]);
|
||||
const ancestor = subgraphParents(tree.current.current).get(ref.boardId)?.boardId;
|
||||
if (ancestor && subgraphMoveParticipants(tree.current.current, ref.boardId, node.data.subgraph!.boardId, ref.boardId, child.nodes.map((item) => item.id)).includes(ancestor)) participants.add(ancestor);
|
||||
await tree.command(ref.boardId, `subgraphs/${ref.id}/dissolve`, {}, [...participants]);
|
||||
}
|
||||
for (const node of removing.filter((node) => !node.data.subgraph)) {
|
||||
const ref = tree.views.nodeRefs.get(node.id); if (!ref) continue;
|
||||
let owner = ref.boardId;
|
||||
const parent = subgraphParents(tree.current.current).get(owner);
|
||||
if (parent && tree.current.current.get(parent.boardId)?.nodes.find((frame) => frame.id === parent.frameId)?.data.ports?.some((port) => port.nodeId === ref.id)) {
|
||||
const participants = subgraphMoveParticipants(tree.current.current, parent.boardId, owner, parent.boardId, [ref.id]);
|
||||
await tree.command(parent.boardId, `subgraphs/${parent.frameId}/members`, { source_board_id: owner, target_board_id: parent.boardId, node_ids: [ref.id] }, participants); owner = parent.boardId;
|
||||
}
|
||||
if (!tree.current.current.get(owner)?.nodes.some((item) => item.id === ref.id)) {
|
||||
const match = [...tree.current.current].filter(([, document]) => document.nodes.some((item) => item.id === ref.id));
|
||||
if (match.length !== 1) continue; owner = match[0][0];
|
||||
}
|
||||
tree.dispatch({ type: "nodes", changes: [{ type: "remove", id: ref.id }] }, owner);
|
||||
}
|
||||
if (linked.length) setMembershipNotice("已解散选框,子画布内容已保留");
|
||||
} catch (error) { setError(error instanceof Error ? error.message : "删除未完成"); }
|
||||
return false;
|
||||
}}
|
||||
onNodesChange={tree.nodesChanged}
|
||||
onEdgesChange={tree.edgesChanged}
|
||||
connectionLineType={ConnectionLineType.Bezier} connectionLineComponent={CanvasConnectionLine}
|
||||
onNodeDragStart={(_, node, dragged) => beginDrag(node, dragged)}
|
||||
onNodeDragStop={(_, node, dragged) => finishDrag(node, dragged)}
|
||||
onNodeDragStop={(_, node, dragged) => { void finishDrag(node, dragged); }}
|
||||
onSelectionDragStart={(_, dragged) => { if (dragged[0]) beginDrag(dragged[0], dragged); }}
|
||||
onSelectionDragStop={(_, dragged) => { if (dragged[0]) finishDrag(dragged[0], dragged); }} onConnect={connect} connectionMode={ConnectionMode.Loose}
|
||||
onSelectionDragStop={(_, dragged) => { if (dragged[0]) void finishDrag(dragged[0], dragged); }} onConnect={(connection) => { void connect(connection); }} connectionMode={ConnectionMode.Loose}
|
||||
connectOnClick={false} connectionDragThreshold={5}
|
||||
onConnectStart={(event, params) => { closeBranch(); const point = "touches" in event ? event.touches[0] : event; if (params.nodeId && params.handleId && point) connectionStart.current = { id: params.nodeId, direction: params.handleId as NodeDirection, point: { x: point.clientX, y: point.clientY } }; }}
|
||||
onConnectEnd={connectionEnd}
|
||||
onPaneClick={() => { closeBranch(); setRotating(null); }} onNodeClick={(_, node) => { if (node.id !== rotating) setRotating(null); }}
|
||||
onPaneClick={() => { closeBranch(); setRotating(null); setResizing(null); }} onNodeClick={(_, node) => { if (node.id !== rotating) setRotating(null); if (node.id !== resizing) setResizing(null); }}
|
||||
onMoveStart={closeBranch}
|
||||
onMoveEnd={(_, viewport) => dispatch({ type: "viewport", viewport })}
|
||||
defaultViewport={initial.document.viewport} minZoom={0.1} maxZoom={4}
|
||||
panOnDrag panOnScroll zoomOnScroll={false} zoomOnPinch zoomActivationKeyCode={["Meta", "Control"]}
|
||||
nodesDraggable={tool === "select"} nodesConnectable={tool === "select"}
|
||||
nodesDraggable={tool === "select" && !tree.busy} nodesConnectable={tool === "select" && !tree.busy}
|
||||
elevateNodesOnSelect={false}
|
||||
elementsSelectable={tool === "select"} selectionMode={SelectionMode.Partial}
|
||||
panActivationKeyCode="Space" selectionKeyCode="Shift"
|
||||
deleteKeyCode={["Backspace", "Delete"]} preventScrolling
|
||||
defaultEdgeOptions={{ style: { stroke: "#aa968b", strokeWidth: 1.5 } }}>
|
||||
<Background variant={BackgroundVariant.Dots} gap={22} size={1} color="#c9bfb5" />
|
||||
{ai.enabled && nodes.filter((node) => node.selected && ["text", "script", "plain-text", "image", "plain-image"].includes(node.data.kind)).map((node) => {
|
||||
const ref = tree.views.nodeRefs.get(node.id)!;
|
||||
const owned = tree.documents.get(ref.boardId)?.nodes.find((item) => item.id === ref.id);
|
||||
return owned && <AIPanel key={node.id} id={node.id} node={owned} workspace={board.workspace_id} boardId={ref.boardId} ai={ai} save={(config) => tree.nodeAction(node.id, (id) => ({ type: "node-data", id, patch: { ai: config } }))} />;
|
||||
})}
|
||||
</ReactFlow>
|
||||
</EdgeEditing.Provider>
|
||||
</FrameFeedback.Provider>
|
||||
</NodeEditing.Provider>
|
||||
<div className={styles.topRight}>
|
||||
<div className={`${toolbar.surface} ${styles.toolbar}`} role="toolbar" aria-label="图谱视图控制">
|
||||
<IconButton icon={Undo2} label="撤销" disabled={!state.past.length} onClick={() => dispatch({ type: "undo" })} />
|
||||
<IconButton icon={Redo2} label="重做" disabled={!state.future.length} onClick={() => dispatch({ type: "redo" })} />
|
||||
<IconButton icon={Undo2} label="撤销" disabled={!tree.canUndo || tree.busy} onClick={() => dispatch({ type: "undo" })} />
|
||||
<IconButton icon={Redo2} label="重做" disabled={!tree.canRedo || tree.busy} onClick={() => dispatch({ type: "redo" })} />
|
||||
<span className={toolbar.divider} aria-hidden="true" />
|
||||
<IconButton icon={Minus} label="缩小" onClick={() => void flow.zoomOut({ duration })} />
|
||||
<button className={styles.zoomValue} onClick={() => void flow.zoomTo(1, { duration })} title="恢复 100% 缩放">{Math.round(zoom * 100)}%</button>
|
||||
@@ -300,6 +439,7 @@ function CanvasEditor({ board, snapshot }: { board: Board; snapshot?: CanvasSnap
|
||||
<IconButton icon={FileText} label="添加脚本" onClick={() => addNode("script")} />
|
||||
<IconButton icon={Frame} label="添加框选区" onClick={() => addNode("frame")} />
|
||||
<IconButton icon={Shapes} label="添加图标" aria-expanded={iconPicker} onClick={() => setIconPicker(!iconPicker)} />
|
||||
<IconButton icon={Bot} label="AI 模式" aria-pressed={ai.enabled} onClick={() => ai.setEnabled(!ai.enabled)} />
|
||||
<IconButton icon={Sparkles} label="插入创作模板" onClick={addTemplate} />
|
||||
<span className={toolbar.divider} aria-hidden="true" />
|
||||
<IconButton icon={Upload} label="导入图谱" onClick={() => projectInput.current?.click()} />
|
||||
@@ -321,18 +461,20 @@ function CanvasEditor({ board, snapshot }: { board: Board; snapshot?: CanvasSnap
|
||||
</div>
|
||||
<BranchMenu request={branchRequest} close={closeBranch} choose={chooseBranch} />
|
||||
{iconPicker && <IconPicker close={() => setIconPicker(false)} choose={(name, label) => {
|
||||
if (nodes.length >= 500) { setError("当前图谱已达到 500 个节点。"); return; }
|
||||
if (tree.rootDocument.nodes.length >= 500) { setError("当前图谱已达到 500 个节点。"); return; }
|
||||
const node = makeCanvasNode("icon", center());
|
||||
node.data = { ...node.data, icon: name, title: label };
|
||||
dispatch({ type: "replace", document: { ...state.present, nodes: [...nodes.map((item) => ({ ...item, selected: false })), { ...node, selected: true }] } });
|
||||
dispatch({ type: "replace", document: { ...tree.rootDocument, nodes: [...tree.rootDocument.nodes.map((item) => ({ ...item, selected: false })), { ...node, selected: true }] } });
|
||||
setTool("select");
|
||||
}} />}
|
||||
<div className={styles.membershipNotice} role="status" aria-live="polite">{membershipNotice}</div>
|
||||
{recovery && <DraftRecovery restore={() => { dispatch({ type: "replace", document: recovery }); setRecovery(null); }} discard={() => setRecovery(null)} />}
|
||||
{snapshot && sync.status === "error" && <div className={styles.syncError} role="alert"><span>{sync.error === "local_backup_failed" ? "本地备份失败,请导出 JSON 备份。" : sync.error === "version_conflict" ? "画布已在其他地方修改,自动保存已停止,请导出当前内容或重新读取服务端版本。" : errorText(new Error(sync.error))}</span>
|
||||
{recovery && <DraftRecovery canRestore={canRestoreCanvasDraft(recovery, tree.rootDocument)} exportDraft={() => downloadDraft(recovery)} restore={() => { dispatch({ type: "replace", document: recovery }); setRecovery(null); }} discard={() => setRecovery(null)} />}
|
||||
{!recovery && childRecovery && <DraftRecovery canRestore={canRestoreCanvasDraft(childRecovery[1], tree.documents.get(childRecovery[0])!)} exportDraft={() => downloadDraft(childRecovery[1])} restore={() => tree.resolveRecovery(childRecovery[0], true)} discard={() => tree.resolveRecovery(childRecovery[0], false)} />}
|
||||
{snapshot && sync.error && <div className={styles.syncError} role="alert"><span>{sync.error === "local_backup_failed" ? "本地备份失败,请导出 JSON 备份。" : sync.error === "version_conflict" ? "画布已在其他地方修改,自动保存已停止,请导出当前内容或重新读取服务端版本。" : errorText(new Error(sync.error))}</span>
|
||||
{sync.error !== "version_conflict" && <button onClick={() => void sync.retry()}>重试保存</button>}
|
||||
<button onClick={exportProject}>导出当前内容</button><button onClick={() => window.location.reload()}>重新读取</button>
|
||||
</div>}
|
||||
{tree.busy && <div className={styles.canvasBusy} role="status">{tree.pendingCommand ? <div>同步结果尚未确认,为保护内容暂时停止编辑。<button onClick={() => void tree.retryPending().catch((error) => setError(error.message))}>重试同步</button><button onClick={() => window.location.reload()}>重新读取画布</button></div> : "正在同步画布…"}</div>}
|
||||
{error && <button className={styles.error} role="alert" onClick={() => setError("")} title="点击关闭提示">{error}</button>}
|
||||
<input ref={imageInput} type="file" accept="image/png,image/jpeg,image/webp,image/gif" onChange={readImage} hidden />
|
||||
<input ref={projectInput} type="file" accept="application/json,.json" onChange={importProject} hidden />
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"use client";
|
||||
import { managedImageSource } from "@/lib/canvas-v2";
|
||||
|
||||
import { useContext, useRef } from "react";
|
||||
import { Handle, Position, type NodeProps } from "@xyflow/react";
|
||||
@@ -23,7 +24,7 @@ export function MaterialNode(props: NodeProps<CanvasNode>) {
|
||||
<TransformableNode {...props} className={`${styles.node} ${selected ? styles.selectedNode : ""}`}>
|
||||
<header className={styles.nodeHeading}><Icon size={13} /><NodeTitle id={id} title={data.title} selected={selected} /></header>
|
||||
<Handle type="target" position={Position.Left} className={styles.legacyHandle} isConnectable={false} />
|
||||
{data.kind === "image" ? <img className={styles.image} src={data.src} alt={data.title} draggable={false} referrerPolicy="no-referrer" /> : (
|
||||
{data.kind === "image" ? <img className={styles.image} src={managedImageSource(data.src)} alt={data.title} draggable={false} referrerPolicy="no-referrer" /> : (
|
||||
<CanvasTextEditor id={id} data={data} selected={selected} />
|
||||
)}
|
||||
<Handle type="source" position={Position.Right} className={styles.legacyHandle} isConnectable={false} />
|
||||
|
||||
@@ -6,7 +6,7 @@ import { NodeToolbar, Position, type NodeProps } from "@xyflow/react";
|
||||
import { AlignCenter, AlignLeft, AlignRight, Bold, Italic, RotateCw, Underline } from "lucide-react";
|
||||
import type { CanvasNode } from "@/lib/canvas";
|
||||
import type { TextStyle } from "@/lib/canvas-elements";
|
||||
import { NodeEditing } from "./transformable-node";
|
||||
import { NodeEditing, ResizeToggle } from "./transformable-node";
|
||||
import styles from "./canvas.module.css";
|
||||
|
||||
export function NodeStyleToolbar({ id, data, selected }: NodeProps<CanvasNode>) {
|
||||
@@ -29,6 +29,7 @@ export function NodeStyleToolbar({ id, data, selected }: NodeProps<CanvasNode>)
|
||||
</>}
|
||||
{!(["image", "plain-image"].includes(data.kind)) && <label className={styles.colorPicker} title="背景色"><span style={{ background: style.background && style.background !== "transparent" ? style.background : "#ffffff" }} /><input aria-label="背景色" type="color" value={style.background && style.background !== "transparent" ? style.background : "#ffffff"} onChange={(event) => patch({ background: event.target.value })} /></label>}
|
||||
{!(["image", "plain-image"].includes(data.kind)) && <button title="清除背景" aria-label="清除背景" onClick={() => patch({ background: "transparent" })}>∅</button>}
|
||||
{data.kind !== "icon" && <ResizeToggle id={id} />}
|
||||
<button data-rotate-toggle={id} title="旋转模式" aria-label="旋转模式" aria-pressed={editing.rotating === id} onClick={() => editing.rotate(editing.rotating === id ? null : id)}><RotateCw size={16} /></button>
|
||||
</motion.div>}</AnimatePresence>
|
||||
</NodeToolbar>;
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
"use client";
|
||||
import { managedImageSource } from "@/lib/canvas-v2";
|
||||
import type { NodeProps } from "@xyflow/react";
|
||||
import type { CanvasNode } from "@/lib/canvas";
|
||||
import { TransformableNode } from "./transformable-node";
|
||||
import styles from "./canvas.module.css";
|
||||
export function PlainImageNode(props: NodeProps<CanvasNode>) {
|
||||
return <TransformableNode {...props} className={styles.plainImageFrame}><img className={styles.plainImage} src={props.data.src} alt={props.data.title} draggable={false} referrerPolicy="no-referrer" /></TransformableNode>;
|
||||
return <TransformableNode {...props} className={styles.plainImageFrame}><img className={styles.plainImage} src={managedImageSource(props.data.src)} alt={props.data.title} draggable={false} referrerPolicy="no-referrer" /></TransformableNode>;
|
||||
}
|
||||
|
||||
@@ -1,57 +1,62 @@
|
||||
"use client";
|
||||
|
||||
import { createContext, useContext, useEffect, useRef, type PointerEvent, type ReactNode } from "react";
|
||||
import { useReactFlow, useUpdateNodeInternals, type NodeProps, type XYPosition } from "@xyflow/react";
|
||||
import { Grip, RotateCw } from "lucide-react";
|
||||
import { useReactFlow, useUpdateNodeInternals, useViewport, type NodeProps, type XYPosition } from "@xyflow/react";
|
||||
import { Grip, Maximize2, RotateCw } from "lucide-react";
|
||||
import { elementCapabilities, type TextStyle } from "@/lib/canvas-elements";
|
||||
import type { CanvasNode, NodeDirection } from "@/lib/canvas";
|
||||
import { resizeFromPointer, rotationFromPointer, type NodeTransform } from "@/lib/canvas-geometry";
|
||||
import { resizeFromPointer, rotationFromPointer, type NodeTransform, type ResizeDirection } from "@/lib/canvas-geometry";
|
||||
import { NodeStyleToolbar } from "./node-style-toolbar";
|
||||
import { MembershipBadge } from "./frame-feedback";
|
||||
import { ContentHandles } from "./content-handles";
|
||||
import styles from "./canvas.module.css";
|
||||
|
||||
export const NodeEditing = createContext({
|
||||
begin: () => {}, select: (_id: string) => {}, edit: (_id: string, _text: string) => {},
|
||||
workspace: "", schema: 1 as 1 | 2,
|
||||
begin: (_id?: string) => {}, select: (_id: string) => {}, edit: (_id: string, _text: string) => {},
|
||||
branch: (_id: string, _direction: NodeDirection, _point: XYPosition) => {},
|
||||
transform: (_id: string, _transform: NodeTransform) => {},
|
||||
style: (_id: string, _style: TextStyle, _fontSize?: number) => {},
|
||||
rename: (_id: string, _title: string) => {},
|
||||
frame: (_id: string, _patch: { title?: string; labelMode?: import("@/lib/canvas-elements").FrameLabelMode }) => {},
|
||||
frame: (_id: string, _patch: { title?: string; labelMode?: import("@/lib/canvas-elements").FrameLabelMode; presentation?: import("@/lib/canvas-v2").FramePresentation }) => {},
|
||||
rotating: null as string | null, rotate: (_id: string | null) => {},
|
||||
resizing: null as string | null, resize: (_id: string | null) => {},
|
||||
});
|
||||
type Gesture = { node: CanvasNode; start: XYPosition; direction: "bottom-right" | "rotate" };
|
||||
type Gesture = { node: CanvasNode; start: XYPosition; direction: ResizeDirection | "rotate"; target: HTMLElement; pointerId: number };
|
||||
|
||||
export function TransformableNode(props: NodeProps<CanvasNode> & { children: ReactNode; className?: string; hideStyleToolbar?: boolean }) {
|
||||
const { id, data, selected, width, height, children, className = "" } = props;
|
||||
const editing = useContext(NodeEditing);
|
||||
const flow = useReactFlow<CanvasNode>();
|
||||
const update = useUpdateNodeInternals();
|
||||
const { zoom } = useViewport();
|
||||
const frame = useRef<HTMLDivElement>(null);
|
||||
const gesture = useRef<Gesture | null>(null);
|
||||
const rotation = data.rotation || 0;
|
||||
const plain = data.kind === "plain-text" || data.kind === "icon" || data.kind === "plain-image";
|
||||
const capabilities = elementCapabilities(data.kind);
|
||||
const resizing = capabilities.resizable && editing.resizing === id && selected;
|
||||
const rotating = capabilities.rotatable && editing.rotating === id && selected;
|
||||
useEffect(() => { update(id); }, [id, rotation, width, height, selected, update]);
|
||||
useEffect(() => {
|
||||
if (!rotating) return;
|
||||
if (!rotating && !resizing) return;
|
||||
const outside = (event: globalThis.PointerEvent) => {
|
||||
if (!frame.current?.contains(event.target as Node) && !(event.target as Element).closest?.('[data-rotate-toggle="' + id + '"]')) editing.rotate(null);
|
||||
if (!frame.current?.contains(event.target as Node) && !(event.target as Element).closest?.('[data-rotate-toggle="' + id + '"], [data-resize-toggle="' + id + '"]')) { editing.rotate(null); editing.resize(null); }
|
||||
};
|
||||
const escape = (event: KeyboardEvent) => { if (event.key === "Escape") editing.rotate(null); };
|
||||
const escape = (event: KeyboardEvent) => { if (event.key === "Escape") { cancelGesture(); editing.rotate(null); editing.resize(null); } };
|
||||
window.addEventListener("pointerdown", outside, true);
|
||||
window.addEventListener("keydown", escape);
|
||||
return () => { window.removeEventListener("pointerdown", outside, true); window.removeEventListener("keydown", escape); };
|
||||
}, [rotating, id, editing]);
|
||||
}, [rotating, resizing, id, editing]);
|
||||
|
||||
function start(event: PointerEvent<HTMLElement>, direction: Gesture["direction"]) {
|
||||
if (event.button !== 0) return;
|
||||
event.preventDefault(); event.stopPropagation();
|
||||
const node = flow.getNode(id);
|
||||
if (!node) return;
|
||||
editing.begin();
|
||||
editing.begin(id);
|
||||
if (!node.selected) editing.select(id);
|
||||
gesture.current = { node, direction, start: flow.screenToFlowPosition({ x: event.clientX, y: event.clientY }) };
|
||||
gesture.current = { node, direction, target: event.currentTarget, pointerId: event.pointerId, start: flow.screenToFlowPosition({ x: event.clientX, y: event.clientY }) };
|
||||
event.currentTarget.setPointerCapture(event.pointerId);
|
||||
}
|
||||
function move(event: PointerEvent<HTMLElement>) {
|
||||
@@ -61,7 +66,13 @@ export function TransformableNode(props: NodeProps<CanvasNode> & { children: Rea
|
||||
const { node, start, direction } = gesture.current;
|
||||
editing.transform(id, direction === "rotate"
|
||||
? { rotation: rotationFromPointer(node, start, current, event.shiftKey) }
|
||||
: resizeFromPointer(node, direction, { x: current.x - start.x, y: current.y - start.y }));
|
||||
: resizeFromPointer(node, direction, { x: current.x - start.x, y: current.y - start.y }, resizing ? event.shiftKey : undefined));
|
||||
}
|
||||
function cancelGesture() {
|
||||
const current = gesture.current; if (!current) return;
|
||||
gesture.current = null;
|
||||
editing.transform(id, current.direction === "rotate" ? { rotation: current.node.data.rotation || 0 } : { position: current.node.position, width: current.node.width, height: current.node.height });
|
||||
if (current.target.hasPointerCapture(current.pointerId)) current.target.releasePointerCapture(current.pointerId);
|
||||
}
|
||||
function finish(event: PointerEvent<HTMLElement>) {
|
||||
event.stopPropagation(); gesture.current = null;
|
||||
@@ -71,17 +82,36 @@ export function TransformableNode(props: NodeProps<CanvasNode> & { children: Rea
|
||||
{!props.hideStyleToolbar && <NodeStyleToolbar {...props} />}
|
||||
<div ref={frame} className={`${styles.transformFrame} ${selected ? styles.selectedFrame : ""} ${plain ? styles.plainFrame : ""} ${rotating ? styles.rotatingFrame + " nodrag nopan" : ""} ${className}`}
|
||||
onPointerDownCapture={rotating ? (event) => start(event, "rotate") : undefined}
|
||||
onPointerMove={move} onPointerUp={finish} onPointerCancel={finish}
|
||||
onLostPointerCapture={() => { gesture.current = null; }}
|
||||
style={{ width: width || (data.kind === "frame" ? 640 : data.kind === "plain-image" ? 320 : data.kind === "icon" ? 80 : plain ? 240 : 260), height: height || (data.kind === "frame" ? 400 : data.kind === "plain-image" ? 240 : data.kind === "icon" ? 80 : plain ? 72 : 200), transform: `rotate(${rotation}deg)` }}>
|
||||
onPointerMove={move} onPointerUp={finish} onPointerCancel={cancelGesture}
|
||||
onLostPointerCapture={cancelGesture}
|
||||
style={{ zoom: data.viewScale || 1, width: (width || (data.kind === "frame" ? 640 : data.kind === "plain-image" ? 320 : data.kind === "icon" ? 80 : plain ? 240 : 260)) / (data.viewScale || 1), height: ( height || (data.kind === "frame" ? 400 : data.kind === "plain-image" ? 240 : data.kind === "icon" ? 80 : plain ? 72 : 200)) / (data.viewScale || 1), transform: `rotate(${rotation}deg)` }}>
|
||||
{children}
|
||||
{editing.schema === 2 && ["plain-text", "plain-image"].includes(data.kind) && <ContentHandles selected={selected} />}
|
||||
{data.kind !== "frame" && <MembershipBadge id={id} frameId={data.frameId} selected={selected} />}
|
||||
{capabilities.resizable && <button type="button" className={`${styles.cornerResize} nodrag nopan`} tabIndex={rotating ? -1 : 0}
|
||||
{capabilities.resizable && !resizing && <button type="button" className={`${styles.cornerResize} nodrag nopan`} tabIndex={rotating ? -1 : 0}
|
||||
aria-hidden={rotating} aria-label="拖动右下角调整大小" title="拖动调整大小"
|
||||
onPointerDown={(event) => start(event, "bottom-right")} onPointerMove={move} onPointerUp={finish} onPointerCancel={finish}>
|
||||
onPointerDown={(event) => start(event, "bottom-right")} onPointerMove={move} onPointerUp={finish} onPointerCancel={cancelGesture}>
|
||||
<Grip size={14} />
|
||||
</button>}
|
||||
{resizing && (["top-left", "top", "top-right", "right", "bottom-right", "bottom", "bottom-left", "left"] as const).map((direction) => {
|
||||
const left = direction.includes("left") ? "0%" : direction.includes("right") ? "100%" : "50%";
|
||||
const top = direction.includes("top") ? "0%" : direction.includes("bottom") ? "100%" : "50%";
|
||||
return <button type="button" key={direction} className={`${styles.resizeHandle} nodrag nopan`} data-resize-handle={direction} aria-label={`拉伸 ${direction}`} title="拖动拉伸 · Shift 固定比例" style={{ left, top, width: 10 / (zoom * (data.viewScale || 1)), height: 10 / (zoom * (data.viewScale || 1)), borderWidth: 1 / (zoom * (data.viewScale || 1)), cursor: direction === "top" || direction === "bottom" ? "ns-resize" : direction === "left" || direction === "right" ? "ew-resize" : direction === "top-left" || direction === "bottom-right" ? "nwse-resize" : "nesw-resize" }}
|
||||
onPointerDown={(event) => start(event, direction)} onPointerMove={move} onPointerUp={finish} onPointerCancel={cancelGesture}
|
||||
onKeyDown={(event) => {
|
||||
const delta = { ArrowLeft: [-1, 0], ArrowRight: [1, 0], ArrowUp: [0, -1], ArrowDown: [0, 1] }[event.key]; if (!delta) return;
|
||||
event.preventDefault(); event.stopPropagation(); const node = flow.getNode(id); if (!node) return;
|
||||
editing.begin(id); const scale = (data.viewScale || 1) * (event.ctrlKey || event.metaKey ? 10 : 1);
|
||||
editing.transform(id, resizeFromPointer(node, direction, { x: delta[0] * scale, y: delta[1] * scale }, event.shiftKey));
|
||||
}} />;
|
||||
})}
|
||||
{resizing && <span className={styles.rotationHint}>拖动八向手柄 · Shift 固定比例 · Esc 退出</span>}
|
||||
{rotating && <span className={styles.rotationHint}><RotateCw size={12} /> {Math.round(rotation)}° · 拖动旋转,点击外部退出</span>}
|
||||
</div>
|
||||
</>;
|
||||
}
|
||||
|
||||
export function ResizeToggle({ id }: { id: string }) {
|
||||
const editing = useContext(NodeEditing);
|
||||
return <button type="button" data-resize-toggle={id} aria-label="八向拉伸模式" title="八向拉伸 · Shift 固定比例" aria-pressed={editing.resizing === id} onClick={() => editing.resize(editing.resizing === id ? null : id)}><Maximize2 size={16} /></button>;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
"use client";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { api } from "@/lib/api";
|
||||
import { CANVAS_ACCEPT } from "@/lib/canvas-v2";
|
||||
import { canvasPath } from "@/lib/canvas-sync";
|
||||
import type { useCanvasTree } from "./use-canvas-tree";
|
||||
export type CanvasModel = { id: string; label?: string; display_name?: string; output_modalities: string[]; input_modalities: string[]; available: boolean; unavailable_reason?: string; parameters_schema?: unknown };
|
||||
export type AIRun = {
|
||||
run_id: string; board_id: string; status: string; next_seq: number; has_more?: boolean; error?: string;
|
||||
steps?: { step_id: string; board_id: string; node_id: string; status: string; error?: string }[];
|
||||
outputs?: { step_id: string; output: { output_id: string; kind: string; src?: string } }[];
|
||||
events?: { seq: number; type: string; board_id?: string; node_id?: string; logical_edge_id?: string; canvas_version?: number; output?: { output_id: string; text?: string; src?: string } }[];
|
||||
applied_versions?: Record<string, number>;
|
||||
};
|
||||
const active = (status: string) => status === "queued" || status === "running";
|
||||
export function useCanvasAI(workspace: string, root: string, live: boolean, tree: ReturnType<typeof useCanvasTree>) {
|
||||
const [enabled, setEnabled] = useState(false);
|
||||
const [runs, setRuns] = useState(new Map<string, AIRun>());
|
||||
const [error, setError] = useState("");
|
||||
const [conflicts, setConflicts] = useState(new Set<string>());
|
||||
const [activeEdges, setActiveEdges] = useState(new Set<string>());
|
||||
const reference = useRef(tree); reference.current = tree;
|
||||
const cache = useRef(new Map<string, AIRun>());
|
||||
const edgeStates = useRef(new Map<string, Set<string>>());
|
||||
const pendingRefresh = useRef(new Map<string, number>());
|
||||
const changed = () => { setRuns(new Map(cache.current)); setActiveEdges(new Set([...edgeStates.current.values()].flatMap((values) => [...values]))); };
|
||||
useEffect(() => {
|
||||
if (!live) return;
|
||||
let alive = true, timer: ReturnType<typeof setTimeout> | undefined;
|
||||
const controller = new AbortController();
|
||||
const request = <T,>(path: string) => api<T>(path, { signal: controller.signal, headers: { Accept: CANVAS_ACCEPT } });
|
||||
async function discover() {
|
||||
let cursor = ""; const seen = new Set<string>();
|
||||
do {
|
||||
const page = await request<{ runs: { run_id: string; board_id: string; status: string }[]; next_cursor?: string }>(canvasPath(workspace, root) + "/ai/runs?status=active&include_descendants=true&limit=50" + (cursor ? "&cursor=" + encodeURIComponent(cursor) : ""));
|
||||
for (const run of page.runs || []) if (!cache.current.has(run.run_id)) cache.current.set(run.run_id, { ...run, next_seq: 0 });
|
||||
cursor = page.next_cursor || ""; if (seen.has(cursor)) throw new Error("invalid_run_cursor"); seen.add(cursor);
|
||||
} while (cursor && alive);
|
||||
}
|
||||
let polls = 0;
|
||||
async function poll() {
|
||||
try {
|
||||
if (++polls % 4 === 0) await discover();
|
||||
for (const [id, previous] of cache.current) {
|
||||
if (!active(previous.status) && !previous.has_more) continue;
|
||||
let current = previous;
|
||||
do {
|
||||
const page = await request<Omit<AIRun, "board_id">>(canvasPath(workspace, previous.board_id) + `/ai/runs/${id}?after_seq=${current.next_seq}&limit=100`);
|
||||
if (!Number.isSafeInteger(page.next_seq) || page.next_seq < current.next_seq || page.has_more && page.next_seq === current.next_seq) throw new Error("invalid_run_cursor");
|
||||
const edges = edgeStates.current.get(id) || new Set<string>();
|
||||
for (const event of page.events || []) {
|
||||
if (event.logical_edge_id) { if (event.type === "edge.active") edges.add(event.logical_edge_id); else if (["edge.completed", "edge.failed", "edge.skipped"].includes(event.type)) edges.delete(event.logical_edge_id); }
|
||||
if (event.type === "step.applied" && event.board_id && event.canvas_version) pendingRefresh.current.set(event.board_id, Math.max(pendingRefresh.current.get(event.board_id) || 0, event.canvas_version));
|
||||
}
|
||||
if (!active(page.status)) edges.clear(); edgeStates.current.set(id, edges);
|
||||
current = { ...page, board_id: previous.board_id, events: [...(current.events || []), ...(page.events || [])].slice(-1000) };
|
||||
cache.current.set(id, current);
|
||||
} while (current.has_more && alive);
|
||||
}
|
||||
for (const [board, version] of pendingRefresh.current) {
|
||||
if (reference.current.busy) break;
|
||||
const writer = reference.current.writers.current.get(board);
|
||||
if (writer && writer.version < version) {
|
||||
try { await reference.current.refresh(board); } catch (error) {
|
||||
if (error instanceof Error && error.message === "version_conflict") { pendingRefresh.current.delete(board); setConflicts((current) => new Set(current).add(board)); }
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
pendingRefresh.current.delete(board);
|
||||
}
|
||||
if (alive) { changed(); setError(""); }
|
||||
} catch (error) { if (alive && !controller.signal.aborted) { changed(); setError(error instanceof Error ? error.message : "request_failed"); } }
|
||||
if (alive) timer = setTimeout(poll, 1200);
|
||||
}
|
||||
void discover().catch((error) => { if (alive) setError(error.message); }).finally(() => { if (alive) void poll(); });
|
||||
return () => { alive = false; controller.abort(); if (timer) clearTimeout(timer); };
|
||||
}, [workspace, root, live]);
|
||||
async function run(boardId: string, node: string, model: string, prompt: string, propagate: boolean) {
|
||||
if (!live) throw new Error("demo_ai_unavailable");
|
||||
const result = await reference.current.command(boardId, "ai/runs", { node_id: node, model_id: model, prompt, parameters: {}, propagate }, undefined, false);
|
||||
const id = result.run_id as string; cache.current.set(id, { run_id: id, board_id: boardId, status: "queued", next_seq: 0, steps: [{ step_id: "pending", board_id: boardId, node_id: node, status: "queued" }] }); changed();
|
||||
}
|
||||
async function cancel(run: AIRun) {
|
||||
await api(canvasPath(workspace, run.board_id) + `/ai/runs/${run.run_id}/cancel`, { method: "POST", headers: { Accept: CANVAS_ACCEPT, "Idempotency-Key": crypto.randomUUID() }, body: "{}" });
|
||||
const next = cache.current.get(run.run_id); if (next) { next.status = "queued"; } // Poll canonical terminal state, including already applied steps.
|
||||
}
|
||||
async function apply(run: AIRun, node: string, output: string, targetBoard: string) {
|
||||
await reference.current.prepareApply(targetBoard, node);
|
||||
await reference.current.command(run.board_id, `ai/runs/${run.run_id}/apply`, { node_id: node, output_id: output }, undefined, false, targetBoard);
|
||||
setConflicts((current) => { const next = new Set(current); next.delete(targetBoard); return next; });
|
||||
const previous = cache.current.get(run.run_id); if (previous) { previous.status = "queued"; changed(); }
|
||||
}
|
||||
return { enabled, setEnabled, runs, conflicts, activeEdges, error, run, cancel, apply, live };
|
||||
}
|
||||
@@ -0,0 +1,271 @@
|
||||
"use client";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { EdgeChange, NodeChange } from "@xyflow/react";
|
||||
import { api, ApiError } from "@/lib/api";
|
||||
import { canvasReducer, decodeCanvas, type CanvasAction, type CanvasDocument, type CanvasHistory, type CanvasNode } from "@/lib/canvas";
|
||||
import { CanvasSync, canvasFingerprint, canvasPath, decodeSnapshot, readLocalRecovery, type CanvasSnapshot } from "@/lib/canvas-sync";
|
||||
import { CANVAS_ACCEPT, type EdgeData } from "@/lib/canvas-v2";
|
||||
import { mergeCanvasDocuments, projectCanvasTree, transformEdgeData, referenceAfterMoves } from "@/lib/canvas-tree";
|
||||
|
||||
type OwnedSnapshot = CanvasSnapshot & { board_id: string };
|
||||
export type CanvasCommandResult = { transaction_id?: string; parent?: OwnedSnapshot; child?: OwnedSnapshot; snapshots?: OwnedSnapshot[]; children?: OwnedSnapshot[]; canvas_version?: number; applied_versions?: Record<string, number>; archived_board_ids?: string[]; undo_receipt?: { expires_at: string }; [key: string]: unknown };
|
||||
type LocalHistory = { boardId: string; before: CanvasDocument };
|
||||
type TransactionHistory = { boardId: string; path: string; payload: Record<string, unknown>; id: string; participants: string[] };
|
||||
type Entry = LocalHistory | TransactionHistory;
|
||||
const history = (document: CanvasDocument): CanvasHistory => ({ past: [], present: document, future: [] });
|
||||
|
||||
export function useCanvasTree(root: string, workspace: string, initial: CanvasDocument, snapshot?: CanvasSnapshot, paused = false, parentBoardId?: string) {
|
||||
const [documents, setDocuments] = useState(new Map([[root, initial]]));
|
||||
const current = useRef(documents); current.current = documents;
|
||||
const [status, setStatus] = useState("saved");
|
||||
const [error, setError] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [pendingCommand, setPendingCommand] = useState(false);
|
||||
const retryOperation = useRef<(() => Promise<CanvasCommandResult>) | null>(null);
|
||||
const locked = useRef(false), mounted = useRef(true), pause = useRef(paused); pause.current = paused;
|
||||
const writers = useRef(new Map<string, CanvasSync>());
|
||||
const loading = useRef(new Set<string>());
|
||||
const [retryLoad, setRetryLoad] = useState(0);
|
||||
const [recoveries, setRecoveries] = useState(new Map<string, CanvasDocument>());
|
||||
const pendingRecovery = useRef(recoveries); pendingRecovery.current = recoveries;
|
||||
function inspectRecovery(boardId: string, value: CanvasSnapshot) {
|
||||
const local = readLocalRecovery(localStorage, `endoleap_canvas_v1:${workspace}:${boardId}`, value);
|
||||
if (local) { const next = new Map(pendingRecovery.current).set(boardId, local); pendingRecovery.current = next; setRecoveries(next); }
|
||||
}
|
||||
function resolveRecovery(boardId: string, restore: boolean) {
|
||||
const draft = pendingRecovery.current.get(boardId);
|
||||
if (restore && draft) update(boardId, { type: "replace", document: draft });
|
||||
const next = new Map(pendingRecovery.current); next.delete(boardId); pendingRecovery.current = next; setRecoveries(next);
|
||||
localStorage.removeItem(`endoleap_canvas_v1:${workspace}:${boardId}:recovery`);
|
||||
}
|
||||
const undo = useRef<Entry[]>([]), redo = useRef<Entry[]>([]);
|
||||
const boardAliases = useRef(new Map<string, string>());
|
||||
const [, tick] = useState(0);
|
||||
const views = useMemo(() => projectCanvasTree(root, documents), [root, documents]);
|
||||
const view = useRef(views); view.current = views;
|
||||
function publish(next: Map<string, CanvasDocument>) { current.current = next; if (mounted.current) setDocuments(next); }
|
||||
function install(boardId: string, value: CanvasSnapshot) {
|
||||
const writer = writers.current.get(boardId);
|
||||
if (writer) writer.adopt(value);
|
||||
else if (snapshot) writers.current.set(boardId, new CanvasSync(api, canvasPath(workspace, boardId), value, (state) => {
|
||||
if (!mounted.current) return;
|
||||
setStatus(state.status); if (state.error) setError(state.error);
|
||||
}));
|
||||
}
|
||||
// Initialization never writes a snapshot to the server.
|
||||
if (snapshot && !writers.current.has(root)) install(root, snapshot);
|
||||
useEffect(() => { mounted.current = true; return () => { mounted.current = false; }; }, []);
|
||||
const update = (boardId: string, action: CanvasAction) => {
|
||||
if (locked.current || retryOperation.current) return;
|
||||
const before = current.current.get(boardId); if (!before) return;
|
||||
const result = canvasReducer(history(before), action);
|
||||
if (result.past.length) { undo.current.push({ boardId, before }); undo.current = undo.current.slice(-40); redo.current = []; }
|
||||
publish(new Map(current.current).set(boardId, result.present));
|
||||
};
|
||||
const dispatch = (action: CanvasAction, boardId = root) => update(boardId, action);
|
||||
const nodeAction = (id: string, action: (id: string) => CanvasAction) => { const ref = view.current.nodeRefs.get(id); if (ref) update(ref.boardId, action(ref.id)); };
|
||||
const nodesChanged = (changes: NodeChange<CanvasNode>[]) => {
|
||||
if (locked.current || retryOperation.current) return;
|
||||
const groups = new Map<string, NodeChange<CanvasNode>[]>();
|
||||
const moves = new Map(changes.flatMap((change) => change.type === "position" && change.position ? [[change.id, change.position] as const] : []));
|
||||
for (const change of changes) {
|
||||
if (change.type === "add" || change.type === "replace") continue;
|
||||
const ref = view.current.nodeRefs.get(change.id); if (!ref) continue;
|
||||
const adjusted = referenceAfterMoves(root, current.current, view.current.boards, ref.boardId, moves);
|
||||
const next = { ...change, id: ref.id };
|
||||
if (next.type === "position" && next.position) next.position = { x: (next.position.x - adjusted.origin.x) / ref.scale, y: (next.position.y - adjusted.origin.y) / ref.scale };
|
||||
if (next.type === "dimensions" && next.dimensions) next.dimensions = { width: next.dimensions.width / ref.scale, height: next.dimensions.height / ref.scale };
|
||||
groups.set(ref.boardId, [...(groups.get(ref.boardId) || []), next]);
|
||||
}
|
||||
for (const [boardId, changes] of groups) update(boardId, { type: "nodes", changes });
|
||||
};
|
||||
const edgesChanged = (changes: EdgeChange[]) => {
|
||||
const groups = new Map<string, EdgeChange[]>();
|
||||
for (const change of changes) {
|
||||
if (change.type === "add" || change.type === "replace") continue;
|
||||
const ref = view.current.edgeRefs.get(change.id); if (ref) groups.set(ref.boardId, [...(groups.get(ref.boardId) || []), { ...change, id: ref.id }]);
|
||||
}
|
||||
for (const [boardId, changes] of groups) update(boardId, { type: "edges", changes });
|
||||
};
|
||||
const patchEdge = (id: string, data: EdgeData, transient = false) => {
|
||||
const ref = view.current.edgeRefs.get(id); if (ref) update(ref.boardId, { type: "edge-data", id: ref.id, data: transformEdgeData(data, ref, true)!, transient });
|
||||
};
|
||||
const select = (id: string) => {
|
||||
for (const [boardId, document] of current.current) update(boardId, { type: "nodes", changes: document.nodes.map((node) => ({ type: "select", id: node.id, selected: view.current.nodeRefs.get(id)?.boardId === boardId && view.current.nodeRefs.get(id)?.id === node.id })) });
|
||||
};
|
||||
const read = async (boardId: string, signal?: AbortSignal) => decodeSnapshot(await api<CanvasSnapshot>(canvasPath(workspace, boardId), { headers: { Accept: CANVAS_ACCEPT }, signal }));
|
||||
async function ensure(boardId: string, recursive = false, ancestors = new Set<string>()): Promise<void> {
|
||||
if (ancestors.has(boardId) || ancestors.size > 8) throw new Error("subgraph_cycle");
|
||||
let document = current.current.get(boardId);
|
||||
if (!document) {
|
||||
const value = snapshot ? await read(boardId) : (() => { const raw = localStorage.getItem(`endoleap_canvas_v1:${workspace}:${boardId}:recovery`); return { version: 1, document: raw ? decodeCanvas(JSON.stringify(JSON.parse(raw).document)) : { schema_version: 2 as const, nodes: [], edges: [], viewport: { x: 0, y: 0, zoom: 1 } } }; })();
|
||||
install(boardId, value); if (snapshot) inspectRecovery(boardId, value); document = value.document; publish(new Map(current.current).set(boardId, document));
|
||||
}
|
||||
if (recursive) { const next = new Set(ancestors); next.add(boardId); for (const node of document.nodes) if (node.data.subgraph) await ensure(node.data.subgraph.boardId, true, next); }
|
||||
}
|
||||
async function exportDocuments() {
|
||||
return exclusive(async () => {
|
||||
await ensure(root, true);
|
||||
const reachable = new Map<string, CanvasDocument>();
|
||||
const collect = (id: string) => { const document = current.current.get(id)!; reachable.set(id, document); for (const node of document.nodes) if (node.data.subgraph && !reachable.has(node.data.subgraph.boardId)) collect(node.data.subgraph.boardId); };
|
||||
collect(root); return reachable;
|
||||
});
|
||||
}
|
||||
useEffect(() => {
|
||||
if (!snapshot || !parentBoardId) return;
|
||||
let alive = true;
|
||||
void (async () => {
|
||||
let id: string | undefined = parentBoardId; const seen = new Set<string>([root]);
|
||||
while (id && alive) {
|
||||
if (seen.has(id) || seen.size > 8) throw new Error("subgraph_cycle"); seen.add(id);
|
||||
const [metadata, value]: [{ parent_board_id?: string }, CanvasSnapshot] = await Promise.all([api<{ parent_board_id?: string }>(`workspaces/${workspace}/materials/${id}`), read(id)]);
|
||||
if (!alive) return;
|
||||
if (!current.current.has(id)) { install(id, value); publish(new Map(current.current).set(id, value.document)); }
|
||||
id = metadata.parent_board_id;
|
||||
}
|
||||
})().catch((error) => { if (alive) setError(error.message); });
|
||||
return () => { alive = false; };
|
||||
// Root identity is stable until this editor unmounts.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [parentBoardId, root, workspace]);
|
||||
useEffect(() => {
|
||||
for (const [boardId, document] of documents) for (const node of document.nodes) {
|
||||
if (!view.current.boards.has(boardId)) continue;
|
||||
const child = node.data.subgraph?.boardId;
|
||||
if (!child || node.data.presentation?.collapsed || documents.has(child) || loading.current.has(child)) continue;
|
||||
loading.current.add(child);
|
||||
read(child).then((value) => {
|
||||
if (!mounted.current || current.current.has(child) || ![...current.current.values()].some((doc) => doc.nodes.some((node) => node.data.subgraph?.boardId === child))) return;
|
||||
install(child, value); inspectRecovery(child, value); publish(new Map(current.current).set(child, value.document));
|
||||
}).catch((error) => { if (mounted.current) setError(error.message); }).finally(() => loading.current.delete(child));
|
||||
}
|
||||
// Values and writers are scoped to this mounted root/workspace.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [documents, retryLoad]);
|
||||
useEffect(() => {
|
||||
if (paused) return;
|
||||
const backup = setTimeout(() => {
|
||||
for (const [boardId, document] of current.current) try {
|
||||
if (pendingRecovery.current.has(boardId)) continue;
|
||||
const writer = writers.current.get(boardId);
|
||||
localStorage.setItem(`endoleap_canvas_v1:${workspace}:${boardId}:recovery`, JSON.stringify({ document: decodeCanvas(JSON.stringify(document)), baseVersion: writer?.version || 1, dirty: writer ? writer.saved !== canvasFingerprint(document) : false }));
|
||||
} catch { setError("local_backup_failed"); }
|
||||
}, 250);
|
||||
const save = setTimeout(() => {
|
||||
if (locked.current || retryOperation.current || pause.current) return;
|
||||
for (const [boardId, document] of current.current) if (!pendingRecovery.current.has(boardId)) void writers.current.get(boardId)?.enqueue(document);
|
||||
}, 800);
|
||||
return () => { clearTimeout(backup); clearTimeout(save); };
|
||||
}, [documents, paused, workspace]);
|
||||
useEffect(() => {
|
||||
const backup = () => {
|
||||
if (pause.current) return;
|
||||
for (const [boardId, document] of current.current) {
|
||||
if (pendingRecovery.current.has(boardId)) continue;
|
||||
try { const writer = writers.current.get(boardId); localStorage.setItem(`endoleap_canvas_v1:${workspace}:${boardId}:recovery`, JSON.stringify({ document: decodeCanvas(JSON.stringify(document)), baseVersion: writer?.version || 1, dirty: writer ? writer.saved !== canvasFingerprint(document) : false })); } catch { /* Existing draft is retained if storage is full. */ }
|
||||
}
|
||||
};
|
||||
window.addEventListener("pagehide", backup);
|
||||
return () => { window.removeEventListener("pagehide", backup); backup(); };
|
||||
}, [workspace]);
|
||||
async function flush() {
|
||||
for (const [boardId, document] of current.current) {
|
||||
if (pendingRecovery.current.has(boardId)) throw new Error("draft_recovery_required");
|
||||
const writer = writers.current.get(boardId); if (!writer) continue;
|
||||
await writer.enqueue(document); await writer.settled(); if (writer.error) throw new Error(writer.error);
|
||||
}
|
||||
}
|
||||
async function exclusive<T>(operation: () => Promise<T>) {
|
||||
if (locked.current || pause.current) throw new Error("canvas_busy");
|
||||
locked.current = true; setBusy(true);
|
||||
try { return await operation(); } catch (error) { setError(error instanceof Error ? error.message : "request_failed"); throw error; }
|
||||
finally { locked.current = false; if (mounted.current) setBusy(false); }
|
||||
}
|
||||
function keepSelection(document: CanvasDocument, local?: CanvasDocument): CanvasDocument {
|
||||
return { ...document, nodes: document.nodes.map((node) => ({ ...node, selected: local?.nodes.find((item) => item.id === node.id)?.selected })), edges: document.edges.map((edge) => ({ ...edge, selected: local?.edges.find((item) => item.id === edge.id)?.selected })) };
|
||||
}
|
||||
function accept(result: CanvasCommandResult) {
|
||||
const next = new Map(current.current);
|
||||
const values = [...(result.parent ? [result.parent] : []), ...(result.child ? [result.child] : []), ...(result.snapshots || []), ...(result.children || [])];
|
||||
for (const value of values) { const decoded = decodeSnapshot(value); install(value.board_id, decoded); next.set(value.board_id, keepSelection(decoded.document, current.current.get(value.board_id))); }
|
||||
for (const id of result.archived_board_ids || []) { next.delete(id); writers.current.delete(id); }
|
||||
publish(next);
|
||||
return [...new Set(values.map((value) => value.board_id))];
|
||||
}
|
||||
async function command(boardId: string, path: string, payload: Record<string, unknown>, participants?: string[], record = true, versionBoard = boardId) {
|
||||
if (retryOperation.current) throw new Error("canvas_busy");
|
||||
return exclusive(async () => {
|
||||
for (const id of participants || []) if (!writers.current.has(id)) {
|
||||
const value = await read(id); install(id, value); publish(new Map(current.current).set(id, value.document));
|
||||
}
|
||||
await flush();
|
||||
const body = { ...payload, ...(participants ? { expected_versions: Object.fromEntries(participants.map((id) => [id, writers.current.get(id)?.version])) } : {}) };
|
||||
const key = crypto.randomUUID(), version = writers.current.get(versionBoard)?.version || 1;
|
||||
const execute = async (): Promise<CanvasCommandResult> => {
|
||||
try {
|
||||
const result = await api<CanvasCommandResult>(canvasPath(workspace, boardId) + "/" + path, { method: "POST", headers: { Accept: CANVAS_ACCEPT, "If-Match": `"${version}"`, "Idempotency-Key": key }, body: JSON.stringify(body) });
|
||||
const changed = accept(result);
|
||||
if (!changed.length && result.canvas_version) { const target = typeof result.board_id === "string" ? result.board_id : boardId; const remote = await read(target); install(target, remote); publish(new Map(current.current).set(target, keepSelection(remote.document, current.current.get(target)))); }
|
||||
if (record && result.transaction_id) { undo.current.push({ boardId, path, payload, id: result.transaction_id, participants: changed }); redo.current = []; tick((v) => v + 1); }
|
||||
retryOperation.current = null; setPendingCommand(false); setError("");
|
||||
return result;
|
||||
} catch (error) {
|
||||
const code = error instanceof Error ? error.message : "request_failed";
|
||||
if (error instanceof TypeError || error instanceof ApiError && error.status >= 500 || ["upstream_timeout", "upstream_unavailable", "request_failed", "Failed to fetch", "internal_error", "invalid_canvas_snapshot"].includes(code)) { retryOperation.current = execute; setPendingCommand(true); }
|
||||
else { retryOperation.current = null; setPendingCommand(false); }
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
return execute();
|
||||
});
|
||||
}
|
||||
async function refresh(boardId: string) {
|
||||
return exclusive(async () => {
|
||||
const writer = writers.current.get(boardId); if (!writer) return;
|
||||
await writer.settled(); const base = writer.document;
|
||||
const remote = await read(boardId), local = current.current.get(boardId)!;
|
||||
const merged = mergeCanvasDocuments(base, local, remote.document);
|
||||
if (canvasFingerprint(merged) !== canvasFingerprint(local)) { undo.current.push({ boardId, before: local }); redo.current = []; }
|
||||
install(boardId, remote); publish(new Map(current.current).set(boardId, merged));
|
||||
});
|
||||
}
|
||||
async function prepareApply(boardId: string, nodeId: string) {
|
||||
await exclusive(async () => {
|
||||
const writer = writers.current.get(boardId); if (!writer) throw new Error("invalid_reference");
|
||||
await writer.settled(); const remote = await read(boardId), base = structuredClone(writer.document), local = current.current.get(boardId)!;
|
||||
const before = base.nodes.find((node) => node.id === nodeId), after = remote.document.nodes.find((node) => node.id === nodeId);
|
||||
if (!before || !after) throw new Error("invalid_reference");
|
||||
for (const key of ["text", "src", "assetId", "ai"] as const) { if (key in after.data) (before.data as Record<string, unknown>)[key] = after.data[key]; else delete before.data[key]; }
|
||||
const merged = mergeCanvasDocuments(base, local, remote.document);
|
||||
install(boardId, remote); publish(new Map(current.current).set(boardId, merged));
|
||||
await writer.enqueue(merged); await writer.settled(); if (writer.error) throw new Error(writer.error);
|
||||
setError("");
|
||||
});
|
||||
}
|
||||
async function undoAction(isRedo = false) {
|
||||
const stack = isRedo ? redo.current : undo.current, destination = isRedo ? undo.current : redo.current;
|
||||
const original = stack.at(-1); if (!original || locked.current || retryOperation.current) return;
|
||||
const alias = (id: string) => { const seen = new Set<string>(); while (boardAliases.current.has(id) && !seen.has(id)) { seen.add(id); id = boardAliases.current.get(id)!; } return id; };
|
||||
const remapDocument = (document: CanvasDocument) => ({ ...document, nodes: document.nodes.map((node) => node.data.subgraph ? { ...node, data: { ...node.data, subgraph: { boardId: alias(node.data.subgraph.boardId) } } } : node) });
|
||||
const entry: Entry = isRedo ? "id" in original ? { ...original, boardId: alias(original.boardId), participants: original.participants.map(alias), payload: { ...original.payload, ...(typeof original.payload.source_board_id === "string" ? { source_board_id: alias(original.payload.source_board_id) } : {}), ...(typeof original.payload.target_board_id === "string" ? { target_board_id: alias(original.payload.target_board_id) } : {}), ...Object.fromEntries(["source", "target"].flatMap((key) => { const endpoint = original.payload[key] as { board_id?: string } | undefined; return endpoint?.board_id ? [[key, { ...endpoint, board_id: alias(endpoint.board_id) }]] : []; })) } } : { ...original, boardId: alias(original.boardId), before: remapDocument(original.before) } : original;
|
||||
if ("id" in entry) {
|
||||
if (isRedo) {
|
||||
const result = await command(entry.boardId, entry.path, entry.payload, entry.path === "subgraphs" ? undefined : entry.participants, false);
|
||||
const snapshots = [...(result.parent ? [result.parent] : []), ...(result.child ? [result.child] : []), ...(result.snapshots || [])];
|
||||
if (entry.path === "subgraphs" && result.child) {
|
||||
const priorChild = entry.participants.find((id) => id !== entry.boardId); if (priorChild) boardAliases.current.set(priorChild, result.child.board_id);
|
||||
}
|
||||
stack.pop(); destination.push({ ...entry, id: result.transaction_id as string, participants: [...new Set(snapshots.map((value) => value.board_id))] });
|
||||
} else {
|
||||
await command(entry.boardId, `transactions/${entry.id}/undo`, {}, entry.participants, false);
|
||||
stack.pop(); destination.push(entry);
|
||||
}
|
||||
} else {
|
||||
const before = current.current.get(entry.boardId); if (!before) return;
|
||||
publish(new Map(current.current).set(entry.boardId, entry.before)); stack.pop(); destination.push({ ...entry, before });
|
||||
}
|
||||
tick((v) => v + 1);
|
||||
}
|
||||
const retry = async () => { setError(""); setRetryLoad((v) => v + 1); for (const writer of writers.current.values()) await writer.retry(); };
|
||||
return { documents, current, views, pendingCommand, retryPending: () => retryOperation.current ? exclusive(retryOperation.current) : Promise.resolve(null), prepareApply, recoveries, resolveRecovery, ensure, exportDocuments, dispatch, nodeAction, nodesChanged, edgesChanged, patchEdge, select, flush, command, refresh, read, busy: busy || pendingCommand, error, status, retry, undo: () => undoAction(), redo: () => undoAction(true), canUndo: !!undo.current.length, canRedo: !!redo.current.length, writers, rootDocument: documents.get(root)!, setError };
|
||||
}
|
||||
+20
-4
@@ -5,17 +5,22 @@ export type Profile = {
|
||||
email: string | null;
|
||||
avatar_url?: string | null;
|
||||
};
|
||||
export class ApiError extends Error {
|
||||
constructor(code: string, readonly status: number, readonly details?: unknown, readonly retryAfter?: string | null) { super(code); this.name = "ApiError"; }
|
||||
}
|
||||
export async function api<T>(
|
||||
path: string,
|
||||
options: RequestInit = {},
|
||||
): Promise<T> {
|
||||
const res = await fetch("/api/foundry/" + path, {
|
||||
...options,
|
||||
headers: { "Content-Type": "application/json", ...options.headers },
|
||||
headers: { ...(options.body instanceof FormData ? {} : { "Content-Type": "application/json" }), ...options.headers },
|
||||
});
|
||||
const body = await res.json().catch(() => null);
|
||||
if (!res.ok)
|
||||
throw new Error(body?.error?.code || body?.error || "request_failed");
|
||||
if (!res.ok) {
|
||||
const code = body?.error?.code || body?.error;
|
||||
throw new ApiError(typeof code === "string" ? code : "request_failed", res.status, body?.details, res.headers.get("retry-after"));
|
||||
}
|
||||
return body as T;
|
||||
}
|
||||
export const PROJECT = process.env.NEXT_PUBLIC_FOUNDRY_PROJECT || "endoleap";
|
||||
@@ -46,11 +51,22 @@ export const errorText = (error: unknown) => {
|
||||
workspace_required: "请先创建或选择一个工作空间。",
|
||||
invalid_workspace: "工作空间名称不符合要求,请修改后重试。",
|
||||
invalid_title: "名称不能为空,且不能超过 300 字节(约 100 个汉字)。",
|
||||
version_conflict: "图谱已在别处修改,请再次保存以读取最新版本。",
|
||||
version_conflict: "画布已更新,本地草稿已保留,请导出备份或重新读取后处理冲突。",
|
||||
invalid_profile: "请检查显示名称,以及用户名是否为 3–40 位小写字母、数字、下划线或连字符。",
|
||||
forbidden: "你没有在此工作空间创建或编辑图谱的权限。",
|
||||
invalid_canvas: "画布数据未通过服务端校验,请导出备份后检查节点内容。",
|
||||
invalid_canvas_snapshot: "服务端画布数据格式无效,请重新读取或联系维护者。",
|
||||
canvas_schema_upgrade_required: "这张画布已升级,请刷新以加载新版客户端。",
|
||||
undo_receipt_expired: "撤销记录已过期,当前内容已保留。",
|
||||
draft_recovery_required: "请先处理未同步的本地草稿。",
|
||||
canvas_busy: "画布正在同步,请稍后重试。",
|
||||
node_busy: "这个元素正在生成,请等待完成或先取消。",
|
||||
model_unavailable: "模型暂时不可用,请重新选择。",
|
||||
model_incompatible: "这个模型不支持当前元素的输出类型。",
|
||||
ownership_conflict: "子画布关系已变化,请重新读取画布。",
|
||||
execution_cycle: "执行路径中有循环,请先断开循环连接。",
|
||||
queue_full: "生成队列已满,请稍后重试。",
|
||||
context_too_large: "输入内容超出模型容量,请减少输入。",
|
||||
canvas_too_large: "画布超过 5 MiB,请减少图片或拆分画布后重试。",
|
||||
body_too_large: "画布内容过大,请减少图片或拆分画布。",
|
||||
invalid_material: "画布名称或内容不符合要求,请修改后重试。",
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { XYPosition } from "@xyflow/react";
|
||||
import type { Connector } from "./canvas-geometry";
|
||||
import type { EdgeData } from "./canvas-v2";
|
||||
export type Cubic = [XYPosition, XYPosition, XYPosition, XYPosition];
|
||||
export const plus = (a: XYPosition, b: XYPosition) => ({ x: a.x + b.x, y: a.y + b.y });
|
||||
export const minus = (a: XYPosition, b: XYPosition) => ({ x: a.x - b.x, y: a.y - b.y });
|
||||
const lerp = (a: XYPosition, b: XYPosition, t: number) => ({ x: a.x + (b.x - a.x) * t, y: a.y + (b.y - a.y) * t });
|
||||
export function curveSegments(from: Connector, to: Connector, path?: EdgeData["path"]): Cubic[] {
|
||||
const reach = Math.min(240, Math.max(40, Math.hypot(to.point.x - from.point.x, to.point.y - from.point.y) * .4));
|
||||
const knots = path?.knots || [];
|
||||
const anchors = [from.point, ...knots.map((knot) => knot.position), to.point];
|
||||
return anchors.slice(0, -1).map((point, index) => [point,
|
||||
plus(point, index ? knots[index - 1].out : path?.startControl || { x: from.normal.x * reach, y: from.normal.y * reach }),
|
||||
plus(anchors[index + 1], index < knots.length ? knots[index].in : path?.endControl || { x: to.normal.x * reach, y: to.normal.y * reach }), anchors[index + 1]]);
|
||||
}
|
||||
export function cubicPoint([a, b, c, d]: Cubic, t: number) { const ab = lerp(a, b, t), bc = lerp(b, c, t), cd = lerp(c, d, t); return lerp(lerp(ab, bc, t), lerp(bc, cd, t), t); }
|
||||
export function curvePath(segments: Cubic[]) { return `M ${segments[0][0].x},${segments[0][0].y} ` + segments.map(([, b, c, d]) => `C ${b.x},${b.y} ${c.x},${c.y} ${d.x},${d.y}`).join(" "); }
|
||||
/** Split the closest segment without altering its shape. */
|
||||
export function insertCurveKnot(from: Connector, to: Connector, path: EdgeData["path"], point?: XYPosition) {
|
||||
const segments = curveSegments(from, to, path);
|
||||
let selected = Math.floor(segments.length / 2), t = .5, distance = Infinity;
|
||||
if (point) segments.forEach((segment, index) => { for (let step = 1; step < 40; step++) {
|
||||
const p = cubicPoint(segment, step / 40), d = Math.hypot(p.x - point.x, p.y - point.y);
|
||||
if (d < distance) { distance = d; selected = index; t = step / 40; }
|
||||
} });
|
||||
const [a, b, c, d] = segments[selected];
|
||||
const ab = lerp(a, b, t), bc = lerp(b, c, t), cd = lerp(c, d, t), abc = lerp(ab, bc, t), bcd = lerp(bc, cd, t), anchor = lerp(abc, bcd, t);
|
||||
const next = structuredClone(path || { type: "bezier-chain" as const, knots: [] });
|
||||
const knot = { id: crypto.randomUUID(), position: anchor, in: minus(abc, anchor), out: minus(bcd, anchor), mode: "smooth" as const };
|
||||
if (selected) next.knots[selected - 1].out = minus(ab, a); else next.startControl = minus(ab, a);
|
||||
if (selected < next.knots.length) next.knots[selected].in = minus(cd, d); else next.endControl = minus(cd, d);
|
||||
// Splitting one side of an existing symmetric knot can make its handle lengths differ.
|
||||
for (const index of [selected - 1, selected]) if (next.knots[index]?.mode === "symmetric") next.knots[index].mode = "smooth";
|
||||
next.knots.splice(selected, 0, knot); return { path: next, knot };
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { Node, XYPosition } from "@xyflow/react";
|
||||
import type { AIConfig, FramePort, FramePresentation } from "./canvas-v2";
|
||||
|
||||
export type ElementKind = "text" | "image" | "script" | "plain-text" | "icon" | "frame" | "plain-image";
|
||||
export type FrameLabelMode = "hidden" | "fixed" | "scaled";
|
||||
@@ -14,6 +15,12 @@ export type CanvasNodeData = {
|
||||
icon?: string;
|
||||
frameId?: string;
|
||||
labelMode?: FrameLabelMode;
|
||||
ai?: AIConfig;
|
||||
assetId?: string;
|
||||
subgraph?: { boardId: string };
|
||||
ports?: FramePort[];
|
||||
presentation?: FramePresentation;
|
||||
viewScale?: number;
|
||||
};
|
||||
export type CanvasNode = Node<CanvasNodeData, "material" | "plainText" | "icon" | "frame" | "plainImage">;
|
||||
export type ElementCapabilities = { resizable: boolean; rotatable: boolean; connectable: boolean; branchable: boolean };
|
||||
|
||||
@@ -26,7 +26,7 @@ export function rotationFromPointer(node: CanvasNode, start: XYPosition, current
|
||||
if (snap) rotation = Math.round(rotation / 15) * 15;
|
||||
return normalizeRotation(rotation);
|
||||
}
|
||||
export function resizeFromPointer(node: CanvasNode, direction: ResizeDirection, delta: XYPosition): NodeTransform {
|
||||
export function resizeFromPointer(node: CanvasNode, direction: ResizeDirection, delta: XYPosition, lockAspect?: boolean): NodeTransform {
|
||||
const { width, height } = nodeSize(node);
|
||||
const rotation = node.data.rotation || 0;
|
||||
const local = rotateVector(delta, -rotation);
|
||||
@@ -34,13 +34,17 @@ export function resizeFromPointer(node: CanvasNode, direction: ResizeDirection,
|
||||
const vertical = direction.includes("top") ? -1 : direction.includes("bottom") ? 1 : 0;
|
||||
const plain = node.data.kind === "plain-text";
|
||||
const icon = node.data.kind === "icon" || node.data.kind === "plain-image";
|
||||
let nextWidth = Math.max(plain ? 60 : 140, Math.min(3000, width + horizontal * local.x));
|
||||
let nextHeight = Math.max(plain ? 24 : 100, Math.min(3000, height + vertical * local.y));
|
||||
if (icon) {
|
||||
const viewScale = node.data.viewScale || 1;
|
||||
const maximum = node.data.kind === "frame" ? 100000 * viewScale : 3000 * viewScale;
|
||||
const minWidth = (node.data.kind === "frame" ? 32 : icon ? 24 : plain ? 60 : 140) * viewScale;
|
||||
const minHeight = (node.data.kind === "frame" ? 32 : icon ? 24 : plain ? 24 : 100) * viewScale;
|
||||
let nextWidth = Math.max(minWidth, Math.min(maximum, width + horizontal * local.x));
|
||||
let nextHeight = Math.max(minHeight, Math.min(maximum, height + vertical * local.y));
|
||||
if (lockAspect ?? icon) {
|
||||
const sx = 1 + horizontal * local.x / width;
|
||||
const sy = 1 + vertical * local.y / height;
|
||||
let scale = !horizontal ? sy : !vertical ? sx : Math.abs(sx - 1) > Math.abs(sy - 1) ? sx : sy;
|
||||
scale = Math.max(24 / width, 24 / height, Math.min(scale, 3000 / width, 3000 / height));
|
||||
scale = Math.max(minWidth / width, minHeight / height, Math.min(scale, maximum / width, maximum / height));
|
||||
nextWidth = width * scale;
|
||||
nextHeight = height * scale;
|
||||
}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { CANVAS_ACCEPT } from "./canvas-v2.ts";
|
||||
import { decodeSnapshot, type CanvasSnapshot, type CanvasRequest } from "./canvas-sync.ts";
|
||||
import { matchesRef, type Board } from "./workspace.ts";
|
||||
|
||||
export async function loadBoardCanvas(request: CanvasRequest, workspaceRef: string, boardRef: string, signal?: AbortSignal) {
|
||||
const path = `workspaces/${encodeURIComponent(workspaceRef)}/materials/${encodeURIComponent(boardRef)}`;
|
||||
const [board, snapshot] = await Promise.all([request<Board>(path, { signal }), request<CanvasSnapshot>(path + "/canvas", { signal })]);
|
||||
const [board, snapshot] = await Promise.all([request<Board>(path, { signal }), request<CanvasSnapshot>(path + "/canvas", { signal, headers: { Accept: CANVAS_ACCEPT } })]);
|
||||
if (board.kind !== "board" || !matchesRef(board, boardRef) || typeof board.workspace_id !== "string" || !board.workspace_id) throw new Error("invalid_reference");
|
||||
return { board, snapshot: decodeSnapshot(snapshot) };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import type { CanvasDocument } from "./canvas.ts";
|
||||
export function subgraphParents(documents: Map<string, CanvasDocument>) {
|
||||
const parents = new Map<string, { boardId: string; frameId: string }>();
|
||||
for (const [boardId, document] of documents) for (const node of document.nodes) if (node.data.subgraph) parents.set(node.data.subgraph.boardId, { boardId, frameId: node.id });
|
||||
return parents;
|
||||
}
|
||||
export function subgraphMoveParticipants(documents: Map<string, CanvasDocument>, parent: string, source: string, target: string, ids: string[]) {
|
||||
const participants = new Set([parent, source, target]);
|
||||
const parents = subgraphParents(documents);
|
||||
const addDescendants = (board: string, seen = new Set<string>()) => {
|
||||
if (seen.has(board)) throw new Error("subgraph_cycle"); seen.add(board); participants.add(board);
|
||||
for (const node of documents.get(board)?.nodes || []) if (node.data.subgraph) addDescendants(node.data.subgraph.boardId, seen);
|
||||
};
|
||||
for (const node of documents.get(source)?.nodes || []) if (ids.includes(node.id) && node.data.subgraph) addDescendants(node.data.subgraph.boardId);
|
||||
const ancestor = parents.get(parent);
|
||||
if (ancestor) {
|
||||
const frame = documents.get(ancestor.boardId)?.nodes.find((node) => node.id === ancestor.frameId);
|
||||
const affected = frame?.data.ports?.some((port) => {
|
||||
if (source === parent) return ids.includes(port.nodeId);
|
||||
const inner = documents.get(parent)?.nodes.find((node) => node.id === port.nodeId);
|
||||
return inner?.data.subgraph?.boardId === source && inner.data.ports?.some((p) => `port:${p.id}` === port.handle && p.direction === port.direction && ids.includes(p.nodeId));
|
||||
});
|
||||
if (affected) participants.add(ancestor.boardId);
|
||||
}
|
||||
return [...participants];
|
||||
}
|
||||
export function subgraphMovePath(documents: Map<string, CanvasDocument>, source: string, target: string) {
|
||||
const parents = subgraphParents(documents);
|
||||
const chain = (board: string) => { const result = [board]; while (parents.has(board)) { board = parents.get(board)!.boardId; if (result.includes(board)) throw new Error("subgraph_cycle"); result.push(board); } return result; };
|
||||
const from = chain(source), to = chain(target), common = from.find((id) => to.includes(id));
|
||||
if (!common) throw new Error("ownership_conflict");
|
||||
const path: { parent: string; frame: string; source: string; target: string }[] = [];
|
||||
for (let i = 0; from[i] !== common; i++) { const relation = parents.get(from[i])!; path.push({ parent: relation.boardId, frame: relation.frameId, source: from[i], target: relation.boardId }); }
|
||||
for (let i = to.indexOf(common) - 1; i >= 0; i--) { const relation = parents.get(to[i])!; path.push({ parent: relation.boardId, frame: relation.frameId, source: relation.boardId, target: to[i] }); }
|
||||
return path;
|
||||
}
|
||||
export function exposedConnection(documents: Map<string, CanvasDocument>, source: { boardId: string; id: string; handle?: string | null }, target: { boardId: string; id: string; handle?: string | null }) {
|
||||
const parents = subgraphParents(documents);
|
||||
const ancestors = (id: string) => { const result = [id]; while (parents.has(id)) { id = parents.get(id)!.boardId; if (result.includes(id)) throw new Error("subgraph_cycle"); result.push(id); } return result; };
|
||||
const owner = ancestors(source.boardId).find((id) => ancestors(target.boardId).includes(id));
|
||||
if (!owner) throw new Error("ownership_conflict");
|
||||
const expose = (endpoint: typeof source, direction: "input" | "output") => {
|
||||
let { boardId, id, handle } = endpoint;
|
||||
while (boardId !== owner) {
|
||||
const parent = parents.get(boardId)!;
|
||||
const frame = documents.get(parent.boardId)?.nodes.find((node) => node.id === parent.frameId);
|
||||
const port = frame?.data.ports?.find((port) => port.nodeId === id && port.handle === handle && port.direction === direction);
|
||||
if (!port) throw new Error("boundary_port_required");
|
||||
id = parent.frameId; handle = `port:${port.id}`; boardId = parent.boardId;
|
||||
}
|
||||
return { id, handle };
|
||||
};
|
||||
return { boardId: owner, source: expose(source, "output"), target: expose(target, "input") };
|
||||
}
|
||||
export function connectionTransactionPlan(documents: Map<string, CanvasDocument>, source: { boardId: string; id: string; handle: string }, target: { boardId: string; id: string; handle: string }) {
|
||||
const parents = subgraphParents(documents);
|
||||
const path = (id: string) => { const result = [id]; while (parents.has(id)) { id = parents.get(id)!.boardId; if (result.includes(id)) throw new Error("subgraph_cycle"); result.push(id); } return result; };
|
||||
const from = path(source.boardId), to = path(target.boardId), owner = from.find((id) => to.includes(id));
|
||||
if (!owner) throw new Error("ownership_conflict");
|
||||
return { boardId: owner, participants: [...new Set([...from.slice(0, from.indexOf(owner) + 1), ...to.slice(0, to.indexOf(owner) + 1)])], source: { board_id: source.boardId, node_id: source.id, handle: source.handle }, target: { board_id: target.boardId, node_id: target.id, handle: target.handle } };
|
||||
}
|
||||
+16
-1
@@ -1,3 +1,4 @@
|
||||
import { CANVAS_ACCEPT } from "./canvas-v2.ts";
|
||||
import { decodeCanvas, type CanvasDocument } from "./canvas.ts";
|
||||
|
||||
export type CanvasSnapshot = { version: number; document: CanvasDocument };
|
||||
@@ -23,6 +24,7 @@ export class CanvasSync {
|
||||
version: number;
|
||||
saved: string;
|
||||
error = "";
|
||||
document: CanvasDocument;
|
||||
private pending: CanvasDocument | null = null;
|
||||
private running: Promise<void> | null = null;
|
||||
private request: CanvasRequest;
|
||||
@@ -31,6 +33,7 @@ export class CanvasSync {
|
||||
constructor(request: CanvasRequest, path: string, initial: CanvasSnapshot, notify: (state: SyncState) => void) {
|
||||
this.request = request; this.path = path; this.version = initial.version;
|
||||
this.saved = canvasFingerprint(initial.document); this.notify = notify;
|
||||
this.document = initial.document;
|
||||
}
|
||||
enqueue(document: CanvasDocument): Promise<void> {
|
||||
this.pending = document;
|
||||
@@ -39,6 +42,12 @@ export class CanvasSync {
|
||||
return this.running;
|
||||
}
|
||||
retry() { this.error = ""; return this.pending ? this.enqueue(this.pending) : Promise.resolve(); }
|
||||
async settled() { await this.running; }
|
||||
adopt(snapshot: CanvasSnapshot) {
|
||||
if (this.running) throw new Error("canvas_busy");
|
||||
this.pending = null; this.error = ""; this.version = snapshot.version; this.document = snapshot.document;
|
||||
this.saved = canvasFingerprint(snapshot.document); this.notify({ status: "saved", version: this.version });
|
||||
}
|
||||
private async drain() {
|
||||
while (this.pending) {
|
||||
const document = this.pending; this.pending = null;
|
||||
@@ -48,9 +57,10 @@ export class CanvasSync {
|
||||
if (fingerprint === this.saved) { this.notify({ status: "saved", version: this.version }); continue; }
|
||||
const body = JSON.stringify({ document: canvasPayload(document) });
|
||||
if (new TextEncoder().encode(body).length > 5 * 1024 * 1024) throw new Error("canvas_too_large");
|
||||
const result = await this.request<{ version: number }>(this.path, { method: "PATCH", headers: { "If-Match": `"${this.version}"` }, body });
|
||||
const result = await this.request<{ version: number }>(this.path, { method: "PATCH", headers: { Accept: CANVAS_ACCEPT, "If-Match": `"${this.version}"` }, body });
|
||||
if (!Number.isSafeInteger(result?.version) || result.version !== this.version + 1) throw new Error("invalid_canvas_snapshot");
|
||||
this.version = result.version; this.saved = fingerprint;
|
||||
this.document = document;
|
||||
this.notify({ status: "saved", version: this.version });
|
||||
} catch (error) {
|
||||
this.pending ||= document;
|
||||
@@ -75,3 +85,8 @@ export function readLocalRecovery(storage: Pick<Storage, "getItem">, key: string
|
||||
const doc = decodeCanvas(legacy);
|
||||
return !snapshot || canvasFingerprint(doc) !== canvasFingerprint(snapshot.document) ? doc : null;
|
||||
}
|
||||
export function canRestoreCanvasDraft(draft: CanvasDocument, remote: CanvasDocument) {
|
||||
if (draft.schema_version < remote.schema_version) return false;
|
||||
const relations = (document: CanvasDocument) => document.nodes.filter((node) => node.data.subgraph).map((node) => ({ id: node.id, subgraph: node.data.subgraph, ports: node.data.ports || [] })).sort((a, b) => a.id.localeCompare(b.id));
|
||||
return JSON.stringify(relations(draft)) === JSON.stringify(relations(remote));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import { nodeSize } from "./canvas-geometry.ts";
|
||||
import type { CanvasDocument, CanvasNode } from "./canvas.ts";
|
||||
import type { CanvasEdgeModel, EdgeData } from "./canvas-v2.ts";
|
||||
export type CanvasReference = { boardId: string; id: string; origin: { x: number; y: number }; scale: number; parentBoard?: string; parentFrame?: string };
|
||||
export type CanvasDocuments = Map<string, CanvasDocument>;
|
||||
export const displayID = (root: string, board: string, id: string) => root === board ? id : `${board}::${id}`;
|
||||
export function transformEdgeData(data: EdgeData | undefined, reference: CanvasReference, inverse = false): EdgeData | undefined {
|
||||
if (!data?.path) return data ? structuredClone(data) : undefined;
|
||||
const { origin, scale } = reference;
|
||||
const point = (point: { x: number; y: number }, vector = false) => inverse
|
||||
? { x: (point.x - (vector ? 0 : origin.x)) / scale, y: (point.y - (vector ? 0 : origin.y)) / scale }
|
||||
: { x: point.x * scale + (vector ? 0 : origin.x), y: point.y * scale + (vector ? 0 : origin.y) };
|
||||
return { ...data, path: { ...data.path, ...(data.path.startControl ? { startControl: point(data.path.startControl, true) } : {}), ...(data.path.endControl ? { endControl: point(data.path.endControl, true) } : {}), knots: data.path.knots.map((knot) => ({ ...knot, position: point(knot.position), in: point(knot.in, true), out: point(knot.out, true) })) } };
|
||||
}
|
||||
/** Projection is disposable view data. Only owner documents are ever serialized. */
|
||||
export function projectCanvasTree(root: string, documents: CanvasDocuments) {
|
||||
const nodeRefs = new Map<string, CanvasReference>(), edgeRefs = new Map<string, CanvasReference>();
|
||||
const nodes: CanvasNode[] = [], edges: CanvasEdgeModel[] = [];
|
||||
const boards = new Map<string, CanvasReference>();
|
||||
const pending: { edge: CanvasEdgeModel; ref: CanvasReference }[] = [];
|
||||
function visit(boardId: string, origin: { x: number; y: number }, scale: number, ancestors: Set<string>, parentBoard?: string, parentFrame?: string) {
|
||||
if (ancestors.has(boardId) || ancestors.size > 8) throw new Error("subgraph_cycle");
|
||||
const document = documents.get(boardId);
|
||||
if (!document) return;
|
||||
const next = new Set(ancestors); next.add(boardId);
|
||||
const reference = { boardId, id: boardId, origin, scale, parentBoard, parentFrame }; boards.set(boardId, reference);
|
||||
for (const node of document.nodes) {
|
||||
const id = displayID(root, boardId, node.id);
|
||||
nodeRefs.set(id, { ...reference, id: node.id });
|
||||
const width = nodeSize(node).width * scale, height = nodeSize(node).height * scale;
|
||||
const position = { x: origin.x + node.position.x * scale, y: origin.y + node.position.y * scale };
|
||||
nodes.push({ ...node, id, position, width, height, style: { ...node.style, width, height }, data: { ...node.data, ...(parentBoard && parentFrame ? { frameId: displayID(root, parentBoard, parentFrame) } : {}), viewScale: scale } });
|
||||
if (node.data.subgraph && !node.data.presentation?.collapsed) visit(node.data.subgraph.boardId,
|
||||
{ x: position.x + 24 * scale, y: position.y + 36 * scale }, scale * (node.data.presentation?.contentScale || 1), next, boardId, node.id);
|
||||
}
|
||||
for (const edge of document.edges) pending.push({ edge, ref: reference });
|
||||
}
|
||||
visit(root, { x: 0, y: 0 }, 1, new Set());
|
||||
const visible = new Set(nodes.map((node) => node.id));
|
||||
function endpoint(board: string, nodeId: string, handle: string | null | undefined, depth = 0): { id: string; handle?: string | null } {
|
||||
const id = displayID(root, board, nodeId);
|
||||
if (depth > 8) return { id, handle };
|
||||
const node = documents.get(board)?.nodes.find((node) => node.id === nodeId);
|
||||
const port = node?.data.ports?.find((port) => `port:${port.id}` === handle);
|
||||
if (port && node?.data.subgraph && !node.data.presentation?.collapsed) {
|
||||
const inner = endpoint(node.data.subgraph.boardId, port.nodeId, port.handle, depth + 1);
|
||||
if (visible.has(inner.id)) return inner;
|
||||
}
|
||||
return { id, handle };
|
||||
}
|
||||
for (const { edge, ref } of pending) {
|
||||
const id = displayID(root, ref.boardId, edge.id); edgeRefs.set(id, { ...ref, id: edge.id });
|
||||
const source = endpoint(ref.boardId, edge.source, edge.sourceHandle), target = endpoint(ref.boardId, edge.target, edge.targetHandle);
|
||||
edges.push({ ...edge, id, source: source.id, target: target.id, sourceHandle: source.handle, targetHandle: target.handle, data: transformEdgeData(edge.data, ref) });
|
||||
}
|
||||
return { nodes, edges, nodeRefs, edgeRefs, boards };
|
||||
}
|
||||
/** A selected child and its selected parent frame must not receive the same drag twice. */
|
||||
export function referenceAfterMoves(root: string, documents: CanvasDocuments, boards: Map<string, CanvasReference>, boardId: string, moves: Map<string, { x: number; y: number }>): CanvasReference {
|
||||
const reference = boards.get(boardId);
|
||||
if (!reference) throw new Error("invalid_reference");
|
||||
if (boardId === root || !reference.parentBoard || !reference.parentFrame) return reference;
|
||||
const parent = referenceAfterMoves(root, documents, boards, reference.parentBoard, moves);
|
||||
const frame = documents.get(reference.parentBoard)?.nodes.find((node) => node.id === reference.parentFrame);
|
||||
if (!frame) throw new Error("invalid_reference");
|
||||
const position = moves.get(displayID(root, reference.parentBoard, reference.parentFrame)) || { x: parent.origin.x + frame.position.x * parent.scale, y: parent.origin.y + frame.position.y * parent.scale };
|
||||
return { ...reference, origin: { x: position.x + 24 * parent.scale, y: position.y + 36 * parent.scale } };
|
||||
}
|
||||
|
||||
const equal = (a: unknown, b: unknown) => JSON.stringify(a, (_key, value) => value && typeof value === "object" && !Array.isArray(value) ? Object.fromEntries(Object.entries(value).sort(([a], [b]) => a.localeCompare(b))) : value)
|
||||
=== JSON.stringify(b, (_key, value) => value && typeof value === "object" && !Array.isArray(value) ? Object.fromEntries(Object.entries(value).sort(([a], [b]) => a.localeCompare(b))) : value);
|
||||
/** Three-way merge preserves local geometry while applying server AI output. */
|
||||
export function mergeCanvasDocuments(base: CanvasDocument, local: CanvasDocument, remote: CanvasDocument): CanvasDocument {
|
||||
const merge = (base: unknown, local: unknown, remote: unknown): unknown => {
|
||||
if (equal(local, base)) return remote;
|
||||
if (equal(remote, base) || equal(local, remote)) return local;
|
||||
if ([base, local, remote].every((v) => !!v && typeof v === "object" && !Array.isArray(v))) {
|
||||
const b = base as Record<string, unknown>, l = local as Record<string, unknown>, r = remote as Record<string, unknown>;
|
||||
return Object.fromEntries([...new Set([...Object.keys(b), ...Object.keys(l), ...Object.keys(r)])].map((key) => [key, merge(b[key], l[key], r[key])]).filter(([, value]) => value !== undefined));
|
||||
}
|
||||
throw new Error("version_conflict");
|
||||
};
|
||||
function collection<T extends { id: string }>(base: T[], local: T[], remote: T[]): T[] {
|
||||
const b = new Map(base.map((v) => [v.id, v])), l = new Map(local.map((v) => [v.id, v])), r = new Map(remote.map((v) => [v.id, v]));
|
||||
return [...new Set([...r.keys(), ...l.keys()])].map((id) => merge(b.get(id), l.get(id), r.get(id))).filter((v) => v !== undefined) as T[];
|
||||
}
|
||||
const strip = <T extends { selected?: boolean; dragging?: boolean; measured?: unknown; style?: unknown }>(value: T) => { const { selected, dragging, measured, style, ...clean } = value; return clean; };
|
||||
const result = {
|
||||
schema_version: Math.max(local.schema_version, remote.schema_version) as 1 | 2,
|
||||
nodes: collection(base.nodes.map(strip), local.nodes.map(strip), remote.nodes.map(strip)) as CanvasNode[],
|
||||
edges: collection(base.edges.map(strip), local.edges.map(strip), remote.edges.map(strip)) as CanvasEdgeModel[], viewport: local.viewport,
|
||||
};
|
||||
return { ...result, nodes: result.nodes.map((node) => ({ ...node, selected: local.nodes.find((item) => item.id === node.id)?.selected, style: { width: node.width, height: node.height, ...(node.data.kind === "frame" ? { pointerEvents: "none" } : {}) }, ...(node.data.kind === "frame" ? { zIndex: -1 } : {}) })) };
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import type { Edge, XYPosition } from "@xyflow/react";
|
||||
export type MarkerShape = "none" | "open-arrow" | "triangle" | "circle" | "diamond" | "square";
|
||||
export type EdgeMarker = { type: MarkerShape; size?: number; filled?: boolean };
|
||||
export type BezierKnot = { id: string; position: XYPosition; in: XYPosition; out: XYPosition; mode: "corner" | "smooth" | "symmetric" };
|
||||
export type EdgeData = {
|
||||
appearance?: { color?: string; width?: number; opacity?: number; line?: "solid" | "dashed" | "dotted"; head?: EdgeMarker; tail?: EdgeMarker };
|
||||
animation?: { mode?: "off" | "always" | "executing"; effect?: "flow" | "dash" | "pulse"; direction?: "forward" | "reverse"; durationMs?: number; color?: string };
|
||||
path?: { type: "bezier-chain"; startControl?: XYPosition; endControl?: XYPosition; knots: BezierKnot[] };
|
||||
execution?: { enabled?: boolean };
|
||||
};
|
||||
export type CanvasEdgeModel = Edge<EdgeData>;
|
||||
export type AIConfig = { enabled?: boolean; modelId?: string; prompt?: string; propagate?: boolean; parameters?: Record<string, unknown> };
|
||||
export type FramePort = { id: string; direction: "input" | "output"; nodeId: string; handle: string };
|
||||
export type FramePresentation = { collapsed?: boolean; contentScale?: number; expandedSize?: { width: number; height: number }; collapsedSize?: { width: number; height: number } };
|
||||
export const CANVAS_ACCEPT = "application/vnd.foundry.canvas+json;version=2";
|
||||
export const wireID = (value: unknown): value is string => typeof value === "string" && /^[A-Za-z0-9_-]{1,128}$/.test(value);
|
||||
export const numberIn = (value: unknown, min: number, max: number): value is number => typeof value === "number" && Number.isFinite(value) && value >= min && value <= max;
|
||||
export const pointValid = (value: unknown): value is XYPosition => !!value && typeof value === "object" && numberIn((value as XYPosition).x, -1e6, 1e6) && numberIn((value as XYPosition).y, -1e6, 1e6);
|
||||
export function fields(value: unknown, keys: string[]): value is Record<string, unknown> {
|
||||
return !!value && typeof value === "object" && !Array.isArray(value) && Object.keys(value).every((key) => keys.includes(key));
|
||||
}
|
||||
function check(value: boolean) { if (!value) throw new Error("invalid_canvas"); }
|
||||
function optional(object: Record<string, unknown>, key: string, valid: (value: unknown) => boolean) { check(!(key in object) || valid(object[key])); }
|
||||
const choice = (...values: unknown[]) => (value: unknown) => values.includes(value);
|
||||
const color = (value: unknown) => typeof value === "string" && /^#[a-f\d]{6}$/i.test(value);
|
||||
const bool = (value: unknown) => typeof value === "boolean";
|
||||
export function decodeEdgeData(value: unknown): EdgeData | undefined {
|
||||
if (value === undefined) return undefined;
|
||||
check(fields(value, ["appearance", "animation", "path", "execution"]));
|
||||
const data = value as Record<string, unknown>;
|
||||
if (data.appearance !== undefined) {
|
||||
check(fields(data.appearance, ["color", "width", "opacity", "line", "head", "tail"])); const a = data.appearance as Record<string, unknown>;
|
||||
optional(a, "color", color); optional(a, "width", (v) => numberIn(v, .5, 20)); optional(a, "opacity", (v) => numberIn(v, .05, 1)); optional(a, "line", choice("solid", "dashed", "dotted"));
|
||||
for (const key of ["head", "tail"]) if (a[key] !== undefined) {
|
||||
check(fields(a[key], ["type", "size", "filled"])); const marker = a[key] as Record<string, unknown>;
|
||||
check(choice("none", "open-arrow", "triangle", "circle", "diamond", "square")(marker.type)); optional(marker, "size", (v) => numberIn(v, 4, 32)); optional(marker, "filled", bool);
|
||||
}
|
||||
}
|
||||
if (data.animation !== undefined) {
|
||||
check(fields(data.animation, ["mode", "effect", "direction", "durationMs", "color"])); const a = data.animation as Record<string, unknown>;
|
||||
optional(a, "mode", choice("off", "always", "executing")); optional(a, "effect", choice("flow", "dash", "pulse")); optional(a, "direction", choice("forward", "reverse")); optional(a, "durationMs", (v) => numberIn(v, 250, 30000)); optional(a, "color", color);
|
||||
}
|
||||
if (data.execution !== undefined) { check(fields(data.execution, ["enabled"])); optional(data.execution as Record<string, unknown>, "enabled", bool); }
|
||||
if (data.path !== undefined) {
|
||||
check(fields(data.path, ["type", "startControl", "endControl", "knots"])); const path = data.path as Record<string, unknown>;
|
||||
check(path.type === "bezier-chain" && Array.isArray(path.knots) && path.knots.length <= 64);
|
||||
optional(path, "startControl", pointValid); optional(path, "endControl", pointValid);
|
||||
const ids = new Set<string>();
|
||||
for (const knot of path.knots as BezierKnot[]) {
|
||||
check(fields(knot, ["id", "position", "in", "out", "mode"]) && wireID(knot.id) && !ids.has(knot.id) && pointValid(knot.position) && pointValid(knot.in) && pointValid(knot.out) && choice("corner", "smooth", "symmetric")(knot.mode)); ids.add(knot.id);
|
||||
if (knot.mode !== "corner") {
|
||||
const a = Math.hypot(knot.in.x, knot.in.y), b = Math.hypot(knot.out.x, knot.out.y);
|
||||
if (a && b) check(Math.abs(knot.in.x / a * knot.out.y / b - knot.in.y / a * knot.out.x / b) <= 1e-6 && knot.in.x / a * knot.out.x / b + knot.in.y / a * knot.out.y / b <= 0);
|
||||
if (knot.mode === "symmetric") check(Math.abs(a - b) <= 1e-6 * Math.max(a, b));
|
||||
}
|
||||
}
|
||||
}
|
||||
return structuredClone(value) as EdgeData;
|
||||
}
|
||||
export function decodeNodeExtensions(data: Record<string, unknown>, schema: number) {
|
||||
const extensions: { ai?: AIConfig; assetId?: string; subgraph?: { boardId: string }; ports?: FramePort[]; presentation?: FramePresentation } = {};
|
||||
for (const key of ["ai", "assetId", "subgraph", "ports", "presentation"]) if (key in data) check(schema === 2);
|
||||
if (data.assetId !== undefined) { check(["image", "plain-image"].includes(String(data.kind)) && typeof data.assetId === "string" && /^[a-f\d]{8}-[a-f\d]{4}-[a-f\d]{4}-[a-f\d]{4}-[a-f\d]{12}$/i.test(data.assetId)); extensions.assetId = data.assetId as string; }
|
||||
if (data.ai !== undefined) {
|
||||
check(["text", "script", "plain-text", "image", "plain-image"].includes(String(data.kind)) && fields(data.ai, ["enabled", "modelId", "prompt", "propagate", "parameters"])); const ai = data.ai as Record<string, unknown>;
|
||||
optional(ai, "enabled", bool); optional(ai, "propagate", bool); optional(ai, "modelId", (v) => typeof v === "string" && v.length > 0 && v.length <= 256 && v.trim() === v);
|
||||
optional(ai, "prompt", (v) => typeof v === "string" && [...v].length <= 16000); optional(ai, "parameters", (v) => !!v && typeof v === "object" && !Array.isArray(v) && new TextEncoder().encode(JSON.stringify(v)).length <= 16384);
|
||||
extensions.ai = structuredClone(ai) as AIConfig;
|
||||
}
|
||||
if (["subgraph", "ports", "presentation"].some((key) => key in data)) check(data.kind === "frame");
|
||||
if (data.subgraph !== undefined) { check(fields(data.subgraph, ["boardId"]) && typeof data.subgraph.boardId === "string" && /^[a-f\d]{8}-[a-f\d]{4}-[a-f\d]{4}-[a-f\d]{4}-[a-f\d]{12}$/i.test(data.subgraph.boardId)); extensions.subgraph = { boardId: (data.subgraph as { boardId: string }).boardId }; }
|
||||
if (data.ports !== undefined) {
|
||||
check(Array.isArray(data.ports) && data.ports.length <= 4000); const ids = new Set<string>();
|
||||
for (const port of data.ports as FramePort[]) { check(fields(port, ["id", "direction", "nodeId", "handle"]) && wireID(port.id) && !ids.has(port.id) && wireID(port.nodeId) && choice("input", "output")(port.direction) && typeof port.handle === "string" && (/^(top|right|bottom|left)$/.test(port.handle) || /^port:[A-Za-z0-9_-]{1,128}$/.test(port.handle))); ids.add(port.id); }
|
||||
extensions.ports = structuredClone(data.ports) as FramePort[];
|
||||
}
|
||||
if (data.presentation !== undefined) {
|
||||
check(fields(data.presentation, ["collapsed", "contentScale", "expandedSize", "collapsedSize"])); const p = data.presentation as Record<string, unknown>;
|
||||
optional(p, "collapsed", bool); optional(p, "contentScale", (v) => numberIn(v, .05, 4));
|
||||
for (const key of ["expandedSize", "collapsedSize"]) optional(p, key, (v) => fields(v, ["width", "height"]) && numberIn(v.width, 32, 100000) && numberIn(v.height, 32, 100000));
|
||||
extensions.presentation = structuredClone(p) as FramePresentation;
|
||||
}
|
||||
return extensions;
|
||||
}
|
||||
export function managedImageSource(src?: string) {
|
||||
if (!src) return src;
|
||||
try { const url = new URL(src); const match = url.pathname.match(/^\/projects\/endoleap\/(workspaces\/[A-Za-z0-9_-]+\/assets\/[A-Za-z0-9_-]+)$/);
|
||||
if (url.origin === "https://api.foundry.entroia.com" && match) return "/api/foundry/" + match[1];
|
||||
} catch { /* inline and demo images are already browser-readable */ }
|
||||
return src;
|
||||
}
|
||||
+34
-15
@@ -1,3 +1,4 @@
|
||||
import { decodeEdgeData, decodeNodeExtensions, type CanvasEdgeModel } from "./canvas-v2.ts";
|
||||
import { applyEdgeChanges } from "@xyflow/react";
|
||||
import type { Edge, EdgeChange, NodeChange, Viewport, XYPosition } from "@xyflow/react";
|
||||
|
||||
@@ -7,10 +8,12 @@ import { nodeSize, rotateVector, nodeCenter, type NodeTransform } from "./canvas
|
||||
export type { CanvasNode } from "./canvas-elements.ts";
|
||||
export type NodeDirection = "top" | "right" | "bottom" | "left";
|
||||
export const nodeDirections: NodeDirection[] = ["top", "right", "bottom", "left"];
|
||||
export type CanvasDocument = { schema_version: 1; nodes: CanvasNode[]; edges: Edge[]; viewport: Viewport };
|
||||
export type CanvasDocument = { schema_version: 1 | 2; nodes: CanvasNode[]; edges: CanvasEdgeModel[]; viewport: Viewport };
|
||||
export type CanvasHistory = { past: CanvasDocument[]; present: CanvasDocument; future: CanvasDocument[] };
|
||||
export type CanvasAction =
|
||||
| { type: "checkpoint" }
|
||||
| { type: "node-data"; id: string; patch: Partial<CanvasNode["data"]> }
|
||||
| { type: "edge-data"; id: string; data: import("./canvas-v2").EdgeData; transient?: boolean }
|
||||
| { type: "drop"; ids: string[] }
|
||||
| { type: "rename-node"; id: string; title: string }
|
||||
| { type: "frame"; id: string; title?: string; labelMode?: import("./canvas-elements.ts").FrameLabelMode }
|
||||
@@ -34,6 +37,11 @@ function recorded(state: CanvasHistory, present: CanvasDocument): CanvasHistory
|
||||
}
|
||||
export function canvasReducer(state: CanvasHistory, action: CanvasAction): CanvasHistory {
|
||||
switch (action.type) {
|
||||
case "node-data": return recorded(state, { ...state.present, schema_version: 2, nodes: state.present.nodes.map((node) => node.id === action.id ? { ...node, data: { ...node.data, ...action.patch } } : node) });
|
||||
case "edge-data": {
|
||||
const present: CanvasDocument = { ...state.present, schema_version: 2, edges: state.present.edges.map((edge) => edge.id === action.id ? { ...edge, data: action.data } : edge) };
|
||||
return action.transient ? { ...state, present, future: [] } : recorded(state, present);
|
||||
}
|
||||
case "drop": return { ...state, present: { ...state.present, nodes: assignFrameMembership(state.present.nodes, action.ids) } };
|
||||
case "rename-node": return recorded(state, { ...state.present, nodes: state.present.nodes.map((node) => node.id === action.id ? { ...node, data: { ...node.data, title: action.title } } : node) });
|
||||
case "frame": return recorded(state, { ...state.present, nodes: state.present.nodes.map((node) => node.id === action.id && node.data.kind === "frame" ? { ...node, data: { ...node.data, ...(action.title !== undefined ? { title: action.title } : {}), ...(action.labelMode ? { labelMode: action.labelMode } : {}) } } : node) });
|
||||
@@ -66,7 +74,7 @@ export function canvasReducer(state: CanvasHistory, action: CanvasAction): Canva
|
||||
width: patch.width, height: patch.height, measured: { width: patch.width, height: patch.height },
|
||||
style: { ...node.style, width: patch.width, height: patch.height },
|
||||
} : {}),
|
||||
data: { ...node.data, ...(patch.rotation !== undefined ? { rotation: patch.rotation } : {}), ...(patch.fontSize !== undefined ? { fontSize: patch.fontSize } : {}) },
|
||||
data: { ...node.data, ...(node.data.kind === "frame" && node.data.subgraph && patch.width !== undefined && patch.height !== undefined ? { presentation: { ...node.data.presentation, [node.data.presentation?.collapsed ? "collapsedSize" : "expandedSize"]: { width: patch.width, height: patch.height } } } : {}), ...(patch.rotation !== undefined ? { rotation: patch.rotation } : {}), ...(patch.fontSize !== undefined ? { fontSize: patch.fontSize } : {}) },
|
||||
}) } };
|
||||
}
|
||||
case "viewport": return { ...state, present: { ...state.present, viewport: action.viewport } };
|
||||
@@ -136,21 +144,21 @@ export function decodeTextStyle(value: unknown): import("./canvas-elements.ts").
|
||||
return { ...style };
|
||||
}
|
||||
export function decodeCanvas(raw: string): CanvasDocument {
|
||||
if (raw.length > 5_000_000) throw new Error("图谱文件太大,请使用小于 5 MB 的文件。");
|
||||
if (new TextEncoder().encode(raw).length > 5 * 1024 * 1024) throw new Error("图谱文件太大,请使用小于 5 MB 的文件。");
|
||||
const value = JSON.parse(raw);
|
||||
if (!value || value.schema_version !== 1 || !Array.isArray(value.nodes) || !Array.isArray(value.edges) || value.nodes.length > 500 || value.edges.length > 2000)
|
||||
if (!value || ![1, 2].includes(value.schema_version) || !Array.isArray(value.nodes) || !Array.isArray(value.edges) || value.nodes.length > 500 || value.edges.length > 2000)
|
||||
throw new Error("不是有效的啡跃图谱文件。");
|
||||
const ids = new Set<string>();
|
||||
const nodes: CanvasNode[] = value.nodes.map((node: CanvasNode) => {
|
||||
if (!node || typeof node.id !== "string" || !/^[\w-]{1,128}$/.test(node.id) || ids.has(node.id) || !node.position ||
|
||||
![node.position.x, node.position.y].every((n) => Number.isFinite(n) && Math.abs(n) <= 1_000_000) ||
|
||||
!node.data || !["text", "image", "script", "plain-text", "icon", "frame", "plain-image"].includes(node.data.kind) || typeof node.data.title !== "string" || node.data.title.length > 300 ||
|
||||
typeof node.data.text !== "string" || node.data.text.length > 100_000 || (["image", "plain-image"].includes(node.data.kind) && !validImage(node.data.src)))
|
||||
!node.data || !["text", "image", "script", "plain-text", "icon", "frame", "plain-image"].includes(node.data.kind) || typeof node.data.title !== "string" || [...node.data.title].length > 300 ||
|
||||
typeof node.data.text !== "string" || [...node.data.text].length > 100_000 || (["image", "plain-image"].includes(node.data.kind) && !validImage(node.data.src)))
|
||||
throw new Error("图谱节点格式无效。");
|
||||
const width = node.width ?? node.style?.width;
|
||||
const height = node.height ?? node.style?.height;
|
||||
if ((width === undefined) !== (height === undefined) || (width !== undefined && (typeof width !== "number" || !Number.isFinite(width) || width < 20 || width > 3000)) ||
|
||||
(height !== undefined && (typeof height !== "number" || !Number.isFinite(height) || height < 20 || height > 3000)) ||
|
||||
if ((width === undefined) !== (height === undefined) || (width !== undefined && (typeof width !== "number" || !Number.isFinite(width) || width < (value.schema_version === 2 && node.data.kind === "frame" ? 32 : 20) || width > (value.schema_version === 2 && node.data.kind === "frame" ? 100000 : 3000))) ||
|
||||
(height !== undefined && (typeof height !== "number" || !Number.isFinite(height) || height < (value.schema_version === 2 && node.data.kind === "frame" ? 32 : 20) || height > (value.schema_version === 2 && node.data.kind === "frame" ? 100000 : 3000))) ||
|
||||
(node.data.rotation !== undefined && (!Number.isFinite(node.data.rotation) || node.data.rotation < 0 || node.data.rotation >= 360)) ||
|
||||
(node.data.fontSize !== undefined && (!Number.isFinite(node.data.fontSize) || node.data.fontSize < 8 || node.data.fontSize > 160)))
|
||||
throw new Error("节点大小或旋转角度无效。");
|
||||
@@ -158,10 +166,12 @@ export function decodeCanvas(raw: string): CanvasDocument {
|
||||
if (node.data.frameId !== undefined && (node.data.kind === "frame" || typeof node.data.frameId !== "string" || !/^[\w-]{1,128}$/.test(node.data.frameId))) throw new Error("分区归属无效。");
|
||||
if (node.data.kind === "frame" && ((node.data.labelMode !== undefined && !["hidden", "fixed", "scaled"].includes(node.data.labelMode)) || (node.data.rotation || 0) !== 0)) throw new Error("分区设置无效。");
|
||||
const textStyle = decodeTextStyle(node.data.textStyle);
|
||||
const extensions = decodeNodeExtensions(node.data, value.schema_version);
|
||||
ids.add(node.id);
|
||||
return { id: node.id, type: node.data.kind === "plain-text" ? "plainText" : node.data.kind === "icon" ? "icon" : node.data.kind === "frame" ? "frame" : node.data.kind === "plain-image" ? "plainImage" : "material",
|
||||
...(width !== undefined && height !== undefined ? { width, height, style: { width, height } } : {}), ...(node.data.kind === "frame" ? { zIndex: -1, style: { ...(width !== undefined ? { width, height } : {}), pointerEvents: "none" as const } } : {}),
|
||||
position: { x: node.position.x, y: node.position.y }, data: {
|
||||
...extensions,
|
||||
...(node.data.frameId ? { frameId: node.data.frameId } : {}), ...(node.data.kind === "frame" ? { labelMode: node.data.labelMode || "fixed" } : {}),
|
||||
...(textStyle ? { textStyle } : {}), ...(node.data.kind === "icon" ? { icon: node.data.icon } : {}),
|
||||
kind: node.data.kind, title: node.data.title, text: node.data.text,
|
||||
@@ -172,15 +182,24 @@ export function decodeCanvas(raw: string): CanvasDocument {
|
||||
if (nodes.some((node) => node.data.frameId && !frameIds.has(node.data.frameId))) throw new Error("分区归属不存在。");
|
||||
const nodeKinds = new Map(nodes.map((node) => [node.id, node.data.kind]));
|
||||
const edgeIds = new Set<string>();
|
||||
const edges: Edge[] = value.edges.map((edge: Edge) => {
|
||||
const edges: CanvasEdgeModel[] = value.edges.map((edge: CanvasEdgeModel) => {
|
||||
if (!edge || typeof edge.id !== "string" || !/^[\w-]{1,128}$/.test(edge.id) || edgeIds.has(edge.id) || !ids.has(edge.source) || !ids.has(edge.target))
|
||||
throw new Error("图谱连线格式无效。");
|
||||
if (!elementCapabilities(nodeKinds.get(edge.source)!).connectable || !elementCapabilities(nodeKinds.get(edge.target)!).connectable)
|
||||
throw new Error("纯文本和图标不支持节点连线。");
|
||||
if ([edge.sourceHandle, edge.targetHandle].some((handle) => handle != null && !nodeDirections.includes(handle as NodeDirection)))
|
||||
throw new Error("图谱连接方向无效。");
|
||||
for (const [nodeId, handle, direction] of [[edge.source, edge.sourceHandle, "output"], [edge.target, edge.targetHandle, "input"]] as const) {
|
||||
const kind = nodeKinds.get(nodeId)!;
|
||||
if (kind === "frame" && value.schema_version === 2) {
|
||||
const node = nodes.find((item) => item.id === nodeId)!;
|
||||
if (!node.data.subgraph || !node.data.ports?.some((port) => `port:${port.id}` === handle && port.direction === direction)) throw new Error("分区端口无效。");
|
||||
} else {
|
||||
const allowed = value.schema_version === 2 ? ["text", "script", "image", "plain-text", "plain-image"].includes(kind) : elementCapabilities(kind).connectable;
|
||||
if (!allowed) throw new Error("该元素不支持节点连线。");
|
||||
if (handle != null && !nodeDirections.includes(handle as NodeDirection)) throw new Error("图谱连接方向无效。");
|
||||
}
|
||||
}
|
||||
const edgeData = decodeEdgeData(edge.data);
|
||||
if (value.schema_version === 1 && edgeData) throw new Error("invalid_canvas");
|
||||
edgeIds.add(edge.id);
|
||||
return { id: edge.id, source: edge.source, target: edge.target,
|
||||
return { id: edge.id, source: edge.source, target: edge.target, ...(edgeData ? { data: edgeData } : {}),
|
||||
...(edge.sourceHandle ? { sourceHandle: edge.sourceHandle } : {}),
|
||||
...(edge.targetHandle ? { targetHandle: edge.targetHandle } : {}),
|
||||
};
|
||||
@@ -188,5 +207,5 @@ export function decodeCanvas(raw: string): CanvasDocument {
|
||||
const viewport = value.viewport;
|
||||
if (!viewport || ![viewport.x, viewport.y, viewport.zoom].every(Number.isFinite) || viewport.zoom < 0.1 || viewport.zoom > 4)
|
||||
throw new Error("图谱视图格式无效。");
|
||||
return { schema_version: 1, nodes, edges, viewport: { x: viewport.x, y: viewport.y, zoom: viewport.zoom } };
|
||||
return { schema_version: value.schema_version, nodes, edges, viewport: { x: viewport.x, y: viewport.y, zoom: viewport.zoom } };
|
||||
}
|
||||
|
||||
@@ -10,6 +10,8 @@ export type Board = {
|
||||
version: number;
|
||||
content?: { schema_version: number };
|
||||
description?: string;
|
||||
parent_board_id?: string;
|
||||
frame_id?: string;
|
||||
};
|
||||
export function matchesRef(item: { id: string; slug?: string } | null | undefined, ref: string | undefined) { return Boolean(item && ref && (item.id === ref || item.slug === ref)); }
|
||||
export const DEFAULT_BOARD_TITLE = "未命名图谱";
|
||||
|
||||
+27
-2
@@ -16,9 +16,19 @@ const upstream = createServer(async (req, res) => {
|
||||
res.setHeader("Content-Type", "application/json");
|
||||
if (req.headers.authorization === "Bearer test-only-session" && !req.url.endsWith("/auth/logout")) res.setHeader("X-Foundry-Session-Expires-In", mockSessionSeconds);
|
||||
const canonical = req.url.replace("/workspaces/creative-space/", "/workspaces/space/").replace("/materials/my-board", "/materials/foundry-generated-id");
|
||||
if (req.url.includes("/canvas")) assert.equal(req.headers.accept, "application/vnd.foundry.canvas+json;version=2");
|
||||
if (req.url.startsWith("/projects/endoleap/workspaces/space/assets")) {
|
||||
if (req.headers.authorization !== "Bearer test-only-session") { res.statusCode = 401; res.end(JSON.stringify({error:"invalid_session"})); return; }
|
||||
if (req.method === "GET") { res.setHeader("Content-Type", req.url.endsWith("/bad") ? "image/svg+xml" : "image/png"); res.end(Buffer.from([137,80,78,71,13,10,26,10])); return; }
|
||||
let bytes = Buffer.alloc(0); for await (const chunk of req) bytes = Buffer.concat([bytes, chunk]);
|
||||
res.end(JSON.stringify({type:req.headers["content-type"],bytes:bytes.length,key:req.headers["idempotency-key"],multipart:bytes.includes(Buffer.from('name="image"'))})); return;
|
||||
}
|
||||
if (canonical.includes("/canvas/")) {
|
||||
res.end(JSON.stringify({url:req.url,method:req.method,accept:req.headers.accept,key:req.headers["idempotency-key"],version:req.headers["if-match"]})); return;
|
||||
}
|
||||
if (canonical === "/projects/endoleap/workspaces/space/materials/foundry-generated-id/canvas") {
|
||||
if (req.headers.authorization !== "Bearer test-only-session") { res.statusCode = 401; res.end(JSON.stringify({ error: "invalid_session" })); return; }
|
||||
if (req.method === "GET") { res.setHeader("ETag", '"' + canvasSnapshot.version + '"'); res.end(JSON.stringify(canvasSnapshot)); return; }
|
||||
if (req.method === "GET") { res.setHeader("X-Canvas-Schema-Version", String(canvasSnapshot.document.schema_version)); res.setHeader("ETag", '"' + canvasSnapshot.version + '"'); res.end(JSON.stringify(canvasSnapshot)); return; }
|
||||
let raw = ""; for await (const chunk of req) raw += chunk;
|
||||
if (req.headers["if-match"] !== '"' + canvasSnapshot.version + '"') { res.statusCode = 412; res.end(JSON.stringify({ error: "version_conflict" })); return; }
|
||||
const { document } = JSON.parse(raw);
|
||||
@@ -153,6 +163,21 @@ try {
|
||||
assert(cookie.includes("SameSite=Lax"));
|
||||
assert(cookie.includes("Max-Age=2592000"));
|
||||
const session = cookie.split(";")[0];
|
||||
const v2Base = host + "/api/foundry/workspaces/space/materials/foundry-generated-id/canvas";
|
||||
const idem = "11111111-1111-4111-8111-111111111111";
|
||||
for (const path of ["connections", "subgraphs", "subgraphs/migrate", "subgraphs/frame-1/members", "subgraphs/frame-1/dissolve", "transactions/tx-1/undo", "ai/runs", "ai/runs/run-1/cancel", "ai/runs/run-1/apply"]) {
|
||||
const response = await fetch(v2Base + "/" + path, { method: "POST", headers: { Cookie: session, Origin: host, "Content-Type": "application/json", "Idempotency-Key": idem, "If-Match": '\"7\"' }, body: "{}" });
|
||||
assert.equal(response.status, 200, path); const echo = await response.json(); assert.equal(echo.key, idem); assert.equal(echo.version, '\"7\"'); assert.equal(echo.accept, "application/vnd.foundry.canvas+json;version=2");
|
||||
}
|
||||
const events = await (await fetch(v2Base + "/ai/runs/run-1?after_seq=12&limit=100&status=active&include_descendants=true&cursor=cursor&secret=drop", { headers: { Cookie: session } })).json();
|
||||
const query = new URL(events.url, "http://mock").searchParams; assert.equal(query.get("after_seq"), "12"); assert.equal(query.get("include_descendants"), "true"); assert.equal(query.get("cursor"), "cursor"); assert.equal(query.get("secret"), null);
|
||||
const asset = await fetch(host + "/api/foundry/workspaces/space/assets/image-1", { headers: { Cookie: session } });
|
||||
assert.equal(asset.status, 200); assert.equal(asset.headers.get("content-type"), "image/png"); assert.equal(asset.headers.get("x-content-type-options"), "nosniff"); assert.ok(asset.headers.get("set-cookie")?.includes("HttpOnly")); assert.equal((await asset.arrayBuffer()).byteLength, 8);
|
||||
assert.equal((await fetch(host + "/api/foundry/workspaces/space/assets/image-1")).status, 401);
|
||||
assert.equal((await fetch(host + "/api/foundry/workspaces/space/assets/bad", { headers: { Cookie: session } })).status, 502);
|
||||
const form = new FormData(); form.set("image", new Blob([new Uint8Array([137,80,78,71])], { type: "image/png" }), "test.png");
|
||||
const upload = await (await fetch(host + "/api/foundry/workspaces/space/assets", { method: "POST", headers: { Cookie: session, Origin: host, "Idempotency-Key": idem }, body: form })).json();
|
||||
assert.match(upload.type, /^multipart\/form-data; boundary=/); assert.equal(upload.key, idem); assert.ok(upload.multipart);
|
||||
const signedInHome = await fetch(host + "/", { headers: { Cookie: session }, redirect: "manual" });
|
||||
assert.equal(signedInHome.status, 307);
|
||||
assert.equal(signedInHome.headers.get("location"), "/dashboard");
|
||||
@@ -311,7 +336,7 @@ try {
|
||||
assert.equal(await logout.text(), "");
|
||||
assert.match(logout.headers.get("set-cookie"), /Max-Age=0/);
|
||||
console.log(
|
||||
"PASS: Canvas GET/PATCH and independent versions, ETag/If-Match, 1+ MiB snapshot restore, conflict and 5 MiB limit, four ID/slug combinations, authenticated home redirect, anonymous and expired-session landing, canvas-free dashboard, 88 original brand assets, 97 hashed icon shards with immutable caching, favicon metadata, avatar and profile updates, App Router pages and metadata, body validation, method and query filtering, OAuth redirects, logout, session cookie, token removal, authenticated proxy, invalid session, cross-origin rejection, path allowlist, OAuth validation",
|
||||
"PASS: V2 commands, idempotency and query forwarding, authenticated binary assets and multipart uploads, Canvas GET/PATCH and independent versions, ETag/If-Match, 1+ MiB snapshot restore, conflict and 5 MiB limit, four ID/slug combinations, authenticated home redirect, anonymous and expired-session landing, canvas-free dashboard, 88 original brand assets, 97 hashed icon shards with immutable caching, favicon metadata, avatar and profile updates, App Router pages and metadata, body validation, method and query filtering, OAuth redirects, logout, session cookie, token removal, authenticated proxy, invalid session, cross-origin rejection, path allowlist, OAuth validation",
|
||||
);
|
||||
} finally {
|
||||
const exited = once(app, "exit");
|
||||
|
||||
+39
-26
@@ -38,8 +38,10 @@ globalThis.ResizeObserver = window.ResizeObserver = class {
|
||||
observe(element) { this.callback([{ target: element, contentRect: element.getBoundingClientRect() }]); }
|
||||
unobserve() {} disconnect() {}
|
||||
};
|
||||
let apiMock = null;
|
||||
const requests = [];
|
||||
globalThis.fetch = async (url, options) => {
|
||||
if (apiMock && String(url).startsWith("/api/foundry/")) return apiMock(url, options);
|
||||
requests.push(String(url));
|
||||
assert.ok(String(url).startsWith("/canvas-icons/"), `Unexpected network request ${url}`);
|
||||
if (options?.signal?.aborted) throw new DOMException("Aborted", "AbortError");
|
||||
@@ -64,7 +66,7 @@ try {
|
||||
const event = async (target, type, init = {}) => { assert.ok(target, `Missing target for ${type}`); await act(async () => { const Type = type.startsWith("pointer") ? PointerEvent : type.startsWith("key") ? window.KeyboardEvent : window.MouseEvent; target.dispatchEvent(new Type(type, { bubbles: true, cancelable: true, view: window, ...init })); await tick(); }); };
|
||||
const node = makeCanvasNode("text", { x: 100, y: 100 }); node.id = "text-one";
|
||||
const frameNode = makeCanvasNode("frame", { x: 500, y: 100 }); frameNode.id = "frame-one"; frameNode.data.title = "分区 A";
|
||||
localStorage.setItem("endoleap_canvas_v1:w:b", JSON.stringify({ ...emptyCanvas(), nodes: [node, frameNode] }));
|
||||
localStorage.setItem("endoleap_canvas_v1:w:b", JSON.stringify({ ...emptyCanvas(), nodes: [node, frameNode, { ...makeCanvasNode("text", { x: 900, y: 100 }), id: "text-two" }], edges: [{ id: "edge-test", source: "text-one", target: "text-two", sourceHandle: "right", targetHandle: "left" }] }));
|
||||
await act(async () => { root.render(h(GraphCanvas, { board: { id: "b", workspace_id: "w", kind: "board", title: "test", version: 1 } })); await tick(); });
|
||||
await event(document.querySelector('[data-id="text-one"]'), "click");
|
||||
const toggle = () => document.querySelector('[aria-label="旋转模式"]');
|
||||
@@ -96,7 +98,7 @@ try {
|
||||
await event(window, "mouseup", { clientX: 700, clientY: 200, button: 0 });
|
||||
assert.ok(document.querySelector('[title="属于:分区 A"]'));
|
||||
assert.ok(document.querySelector('[role="status"]').textContent.includes("已加入「分区 A」"));
|
||||
assert.ok(document.querySelector('.react-flow__node[data-id="frame-one"]').textContent.includes("1 项"));
|
||||
assert.ok(!document.querySelector('.react-flow__node[data-id="frame-one"]').textContent.includes("1 项"));
|
||||
await event(textNode, "mousedown", { clientX: 700, clientY: 200, button: 0, buttons: 1 });
|
||||
await event(window, "mousemove", { clientX: 690, clientY: 200, buttons: 1 });
|
||||
await event(window, "mousemove", { clientX: 200, clientY: 200, buttons: 1 });
|
||||
@@ -108,34 +110,39 @@ try {
|
||||
|
||||
const frameElement = document.querySelector('.react-flow__node[data-id="frame-one"]');
|
||||
await event(frameElement, "click");
|
||||
const modeButton = () => document.querySelector('[aria-label="分区名称显示模式"]');
|
||||
assert.equal(modeButton().tagName, "BUTTON", "Frame options must use a custom control");
|
||||
await event(modeButton(), "click");
|
||||
assert.equal(document.querySelectorAll('[role="menuitemradio"]').length, 3);
|
||||
assert.equal(document.querySelector('[role="menuitemradio"][aria-checked="true"] strong').textContent, "固定字号");
|
||||
await event(document.querySelector('[role="menuitemradio"]'), "click");
|
||||
assert.equal(modeButton().textContent, "隐藏名称");
|
||||
const group = document.querySelector('[role="radiogroup"][aria-label="分区名称显示模式"]');
|
||||
assert.ok(group); assert.equal(group.querySelectorAll('[role="radio"]').length, 3);
|
||||
assert.equal(group.querySelector('[aria-checked="true"]').getAttribute("aria-label"), "固定字号");
|
||||
await event(group.querySelector('[aria-label="隐藏名称"]'), "click");
|
||||
assert.equal(frameElement.querySelector('[title="分区 A"]'), null);
|
||||
assert.equal(document.querySelector('[role="menu"]'), null);
|
||||
assert.equal(document.activeElement, modeButton());
|
||||
await event(modeButton(), "keydown", { key: "ArrowDown" });
|
||||
await event(document.activeElement, "keydown", { key: "End" });
|
||||
assert.equal(document.activeElement.querySelector("strong").textContent, "随画布缩放");
|
||||
await event(document.activeElement, "click");
|
||||
assert.equal(modeButton().textContent, "随画布缩放");
|
||||
await event(group.querySelector('[aria-label="隐藏名称"]'), "keydown", { key: "End" });
|
||||
assert.equal(group.querySelector('[aria-checked="true"]').getAttribute("aria-label"), "随画布缩放");
|
||||
assert.ok(frameElement.querySelector('[title="分区 A"]'));
|
||||
await event(document.querySelector('[aria-label="撤销"]'), "click");
|
||||
assert.equal(modeButton().textContent, "隐藏名称");
|
||||
assert.equal(group.querySelector('[aria-checked="true"]').getAttribute("aria-label"), "隐藏名称");
|
||||
await event(document.querySelector('[aria-label="重做"]'), "click");
|
||||
assert.equal(modeButton().textContent, "随画布缩放");
|
||||
await event(modeButton(), "click");
|
||||
await event(document.activeElement, "keydown", { key: "Escape" });
|
||||
assert.equal(document.querySelector('[role="menu"]'), null);
|
||||
assert.equal(document.activeElement, modeButton());
|
||||
await event(modeButton(), "click");
|
||||
await event(document.body, "pointerdown");
|
||||
assert.equal(document.querySelector('[role="menu"]'), null);
|
||||
console.log("PASS frame menu: custom options, selection, keyboard focus, Escape, outside dismissal and undo/redo");
|
||||
assert.equal(group.querySelector('[aria-checked="true"]').getAttribute("aria-label"), "随画布缩放");
|
||||
await event(document.querySelector('[aria-label="八向拉伸模式"]'), "click");
|
||||
assert.equal(document.querySelectorAll('[data-resize-handle]').length, 8);
|
||||
await event(window, "keydown", { key: "Escape" });
|
||||
assert.equal(document.querySelectorAll('[data-resize-handle]').length, 0);
|
||||
console.log("PASS frames: icon segments, no member count, keyboard choice, undo/redo and eight resize handles");
|
||||
|
||||
await event(document.querySelector('.react-flow__edge[data-id="edge-test"]'), "click");
|
||||
assert.ok(document.querySelector('[aria-label="连线编辑"]'));
|
||||
await event(document.querySelector('[aria-label="添加曲线点"]'), "click");
|
||||
assert.ok(document.querySelector('[aria-label="曲线点 1"]'));
|
||||
const arrow = document.querySelector('[aria-label="箭头类型"]');
|
||||
await act(async () => { arrow.value = "triangle"; arrow.dispatchEvent(new window.Event("change", { bubbles: true })); await tick(); });
|
||||
assert.ok(document.querySelector('.react-flow__edge[data-id="edge-test"] .react-flow__edge-path').getAttribute("marker-end"));
|
||||
const animation = document.querySelector('[aria-label="动画播放时机"]');
|
||||
await act(async () => { animation.value = "always"; animation.dispatchEvent(new window.Event("change", { bubbles: true })); await tick(); });
|
||||
assert.ok(document.querySelector("animateMotion"));
|
||||
await act(async () => { animation.value = "executing"; animation.dispatchEvent(new window.Event("change", { bubbles: true })); await tick(); });
|
||||
assert.equal(document.querySelector("animateMotion"), null, "Execution-only animation remains stopped without a running edge");
|
||||
await event(document.querySelector('[aria-label="删除选中曲线点"]'), "click");
|
||||
assert.equal(document.querySelector('[aria-label="曲线点 1"]'), null);
|
||||
console.log("PASS edges: select, add/remove curve knot, markers and always/execution-only animation");
|
||||
|
||||
let choice;
|
||||
await act(async () => { root.render(h(IconPicker, { close() {}, choose(name) { choice = name; } })); await tick(); });
|
||||
@@ -160,6 +167,12 @@ try {
|
||||
assert.equal(grid.querySelector("button").dataset.index, "0", "Category change resets virtual window");
|
||||
console.log("PASS picker: bounded DOM, on-demand data, shared Tabler load and selection");
|
||||
}
|
||||
if (process.env.ROTATION_BASELINE !== "1") {
|
||||
let scenario;
|
||||
await act(async () => { scenario = await import("./canvas-v2-scenario.mjs"); await tick(); });
|
||||
const { canvasV2Scenario } = scenario;
|
||||
await canvasV2Scenario({ root, act, h, GraphCanvas, window, makeCanvasNode, emptyCanvas, event, tick, mock: (handler) => { apiMock = handler; } });
|
||||
}
|
||||
await act(async () => { root.unmount(); await tick(); });
|
||||
} finally {
|
||||
await rm(folder, { recursive: true, force: true }); window.close(); stop();
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
import assert from 'node:assert/strict';
|
||||
export async function canvasV2Scenario({root,act,h,GraphCanvas,window,makeCanvasNode,emptyCanvas,event,tick,mock}) {
|
||||
const parent='22222222-2222-4222-8222-222222222222', child='11111111-1111-4111-8111-111111111111';
|
||||
const frame=makeCanvasNode('frame',{x:100,y:100});frame.id='linked-frame';frame.data={...frame.data,title:'真实子图',subgraph:{boardId:child},ports:[{id:'out',nodeId:'child-text',handle:'right',direction:'output'}],presentation:{collapsed:false,contentScale:1,expandedSize:{width:640,height:400},collapsedSize:{width:240,height:140}}};
|
||||
const inside=makeCanvasNode('text',{x:10,y:20});inside.id='child-text';inside.data.title='子节点';inside.data.text='原始输入';
|
||||
const target=makeCanvasNode('text',{x:950,y:100});target.id='outside';target.data.ai={enabled:true,modelId:'test-text',prompt:'继续整理',parameters:{}};
|
||||
const image=makeCanvasNode('image',{x:1100,y:450},'https://example.com/reference.png');image.id='image-target';image.data.title='图片目标';
|
||||
const records=new Map([[parent,{version:10,document:{...emptyCanvas(),schema_version:2,nodes:[frame,target,image],edges:[{id:'boundary',source:frame.id,sourceHandle:'port:out',target:target.id,targetHandle:'left',data:{animation:{mode:'executing',effect:'flow'}}}]}}],[child,{version:3,document:{...emptyCanvas(),schema_version:2,nodes:[inside]}}]]);
|
||||
let connectionReceipt=null,connectionKey=null,connectionBody=null,connectionRequests=0;
|
||||
const receipts=new Map();let createCount=0;
|
||||
const saves=[];let job=null,poll=0,seq=0,imageRuns=0;const events=[];
|
||||
const json=(data,status=200)=>new Response(JSON.stringify(data),{status,headers:{'content-type':'application/json'}});
|
||||
const emit=(type,data)=>events.push({seq:++seq,type,...data});
|
||||
mock(async(url,options={})=>{
|
||||
assert.match(options.headers?.Accept||'',/version=2/);
|
||||
const parsed=new URL(url,'http://localhost'), parts=parsed.pathname.split('/');const board=parts[6], suffix=parts.slice(8).join('/'), method=options.method||'GET';
|
||||
const snapshot=records.get(board);assert.ok(snapshot,`Unknown canvas ${board} ${url}`);
|
||||
if(!suffix&&method==='GET')return json(structuredClone(snapshot));
|
||||
if(!suffix&&method==='PATCH'){
|
||||
if(options.headers['If-Match']!==`"${snapshot.version}"`)return json({error:'version_conflict'},412);
|
||||
const payload=JSON.parse(options.body).document;assert.ok(payload.nodes.every(node=>!node.id.includes('::')),'No projected IDs may be persisted');
|
||||
if(board===parent)assert.ok(!payload.nodes.some(node=>node.id==='child-text'),'Child content cannot be duplicated into parent');
|
||||
saves.push({board,document:payload});snapshot.document=structuredClone(payload);snapshot.version++;return json({version:snapshot.version});
|
||||
}
|
||||
if(suffix==='capabilities')return json({supported_schema_versions:[1,2],subgraphs:{connections:true}});
|
||||
if(suffix==='ai/models')return json({models:[{id:'test-text',label:'测试文本模型',available:true,output_modalities:['text'],input_modalities:['text'],parameters_schema:{type:'object',properties:{},additionalProperties:false}},{id:'test-image',label:'测试图片模型',available:true,output_modalities:['image'],input_modalities:['text','image'],parameters_schema:{type:'object',properties:{},additionalProperties:false}}]});
|
||||
if(suffix==='ai/runs'&&method==='GET')return json({runs:job&&['queued','running'].includes(job.status)?[{run_id:'test-run',board_id:child,status:job.status}]:[],next_cursor:null});
|
||||
if(suffix==='ai/runs'&&method==='POST'){
|
||||
const input=JSON.parse(options.body);const isImage=input.node_id==='image-target';assert.equal(board,isImage?parent:child);assert.equal(input.node_id,isImage?'image-target':'child-text');assert.equal(input.model_id,isImage?'test-image':'test-text');assert.equal(input.propagate,!isImage);assert.equal(options.headers['If-Match'],`"${snapshot.version}"`);assert.ok(options.headers['Idempotency-Key']);
|
||||
snapshot.document.nodes.find(node=>node.id===input.node_id).data.ai={enabled:true,modelId:input.model_id,prompt:input.prompt,parameters:{},propagate:true};snapshot.version++;
|
||||
poll=0;seq=0;events.length=0;job={status:'queued',id:isImage?'image-run-'+(++imageRuns):'test-run'};return json({run_id:job.id,status:'queued',canvas_version:snapshot.version,plan:{steps:[],edges:[]}},202);
|
||||
}
|
||||
if(suffix.startsWith('ai/runs/image-run-')&&suffix.endsWith('/cancel')&&method==='POST'){
|
||||
job.status='cancelled';emit('step.cancelled',{board_id:parent,node_id:'image-target'});return json({run_id:job.id,status:'cancelled'});
|
||||
}
|
||||
if(suffix.startsWith('ai/runs/image-run-')&&method==='GET'){
|
||||
poll++;if(job.status!=='cancelled')job.status=poll<2?'running':'succeeded';
|
||||
if(poll===2&&job.status==='succeeded'){
|
||||
const target=records.get(parent).document.nodes.find(node=>node.id==='image-target');target.data.src='https://api.foundry.entroia.com/projects/endoleap/workspaces/w/assets/generated-image';target.data.assetId='44444444-4444-4444-8444-444444444444';records.get(parent).version++;
|
||||
emit('step.output',{board_id:parent,node_id:'image-target',output:{output_id:'image-output',src:target.data.src}});emit('step.applied',{board_id:parent,node_id:'image-target',canvas_version:records.get(parent).version,patch:{src:target.data.src,assetId:target.data.assetId}});
|
||||
}
|
||||
const after=+(parsed.searchParams.get('after_seq')||0);return json({run_id:job.id,status:job.status,next_seq:seq,has_more:false,events:events.filter(e=>e.seq>after),steps:[{step_id:'image-step',board_id:parent,node_id:'image-target',status:job.status}],outputs:[],applied_versions:{}});
|
||||
}
|
||||
if(suffix==='ai/runs/test-run'&&method==='GET'){
|
||||
poll++;
|
||||
if(poll===1){job.status='running';emit('step.running',{board_id:child,node_id:'child-text'});}
|
||||
if(poll===2){records.get(child).document.nodes[0].data.text='生成后的子图内容';records.get(child).version++;emit('step.applied',{board_id:child,node_id:'child-text',canvas_version:records.get(child).version,patch:{text:'生成后的子图内容'}});emit('edge.active',{logical_edge_id:parent+'/boundary'});}
|
||||
if(poll>=3&&job.status!=='succeeded'){job.status='succeeded';records.get(parent).document.nodes.find(node=>node.id==='outside').data.text='向下传播结果';records.get(parent).version++;emit('step.applied',{board_id:parent,node_id:'outside',canvas_version:records.get(parent).version,patch:{text:'向下传播结果'}});emit('edge.completed',{logical_edge_id:parent+'/boundary'});}
|
||||
const after=+(parsed.searchParams.get('after_seq')||0);return json({run_id:'test-run',status:job.status,next_seq:seq,has_more:false,events:events.filter(e=>e.seq>after),steps:[{step_id:'one',board_id:child,node_id:'child-text',status:poll>=2?'succeeded':'running'},{step_id:'two',board_id:parent,node_id:'outside',status:poll>=3?'succeeded':poll>=2?'running':'queued'}],outputs:[],applied_versions:{}});
|
||||
}
|
||||
if(suffix==='connections'&&method==='POST'){
|
||||
connectionRequests++;
|
||||
if(connectionReceipt){assert.equal(options.headers['Idempotency-Key'],connectionKey);assert.equal(options.body,connectionBody);return json(connectionReceipt,201);}
|
||||
connectionKey=options.headers['Idempotency-Key'];connectionBody=options.body;
|
||||
const input=JSON.parse(options.body);assert.equal(board,parent);assert.equal(input.source.board_id,child);assert.equal(input.source.node_id,'child-text');assert.equal(input.source.handle,'bottom');assert.equal(input.target.board_id,parent);assert.deepEqual(Object.keys(input.expected_versions).sort(),[parent,child].sort());
|
||||
for(const id of [parent,child])assert.equal(input.expected_versions[id],records.get(id).version);
|
||||
const linked=records.get(parent).document.nodes.find(node=>node.id==='linked-frame');linked.data.ports.push({id:'generated-port',direction:'output',nodeId:'child-text',handle:'bottom'});
|
||||
records.get(parent).document.edges.push({id:input.edge.id,source:'linked-frame',sourceHandle:'port:generated-port',target:'outside',targetHandle:'left'});
|
||||
records.get(parent).version++;records.get(child).version++;
|
||||
connectionReceipt={transaction_id:'tx-connect',parent:{board_id:parent,...structuredClone(records.get(parent))},snapshots:[{board_id:parent,...structuredClone(records.get(parent))},{board_id:child,...structuredClone(records.get(child))}],undo_receipt:{expires_at:new Date(Date.now()+86400000).toISOString()}};
|
||||
return json({error:'upstream_unavailable'},502); // Simulate a lost response after the atomic commit.
|
||||
}
|
||||
if(suffix.startsWith('transactions/tx-create-')&&suffix.endsWith('/undo')&&method==='POST'){
|
||||
const id=suffix.split('/')[1],receipt=receipts.get(id),input=JSON.parse(options.body);assert.ok(receipt);assert.deepEqual(Object.keys(input.expected_versions).sort(),[parent,receipt.child].sort());assert.equal(input.expected_versions[parent],records.get(parent).version);assert.equal(input.expected_versions[receipt.child],records.get(receipt.child).version);
|
||||
const next={...receipt.before,version:records.get(parent).version+1};records.set(parent,next);records.get(receipt.child).version++;
|
||||
return json({transaction_id:'undo-'+id,parent:{board_id:parent,...structuredClone(next)},snapshots:[{board_id:parent,...structuredClone(next)}],archived_board_ids:[receipt.child],applied_versions:{[parent]:next.version,[receipt.child]:records.get(receipt.child).version}});
|
||||
}
|
||||
if(suffix==='subgraphs'&&method==='POST'){
|
||||
const input=JSON.parse(options.body).frame;const newChild=crypto.randomUUID();const tx='tx-create-'+(++createCount);const beforeCreate=structuredClone(snapshot);assert.equal(options.headers['If-Match'],`"${snapshot.version}"`);
|
||||
const node=makeCanvasNode('frame',input.position);node.id=input.id;node.data.title=input.title;node.data.subgraph={boardId:newChild};snapshot.document.nodes.push(node);snapshot.version++;
|
||||
const empty={board_id:newChild,version:1,document:{...emptyCanvas(),schema_version:2}};records.set(newChild,empty);
|
||||
receipts.set(tx,{before:beforeCreate,child:newChild});
|
||||
return json({transaction_id:tx,parent:{board_id:parent,...structuredClone(snapshot)},child:empty,child_board:{id:newChild,workspace_id:'w'},undo_receipt:{expires_at:new Date(Date.now()+86400000).toISOString()}},201);
|
||||
}
|
||||
throw new Error(`Unhandled V2 API ${method} ${url}`);
|
||||
});
|
||||
await act(async()=>{root.render(h(GraphCanvas,{key:'v2',board:{id:parent,workspace_id:'w',kind:'board',title:'V2 test',version:1},snapshot:structuredClone(records.get(parent))}));await tick();});
|
||||
await act(async()=>{await tick();});
|
||||
const node=()=>document.querySelector(`.react-flow__node[data-id="${child}::child-text"]`);
|
||||
assert.ok(node(),'Child is expanded from its own snapshot');assert.equal(document.querySelectorAll(`.react-flow__node[data-id="${child}::child-text"]`).length,1);
|
||||
await event(node(),'click');await event(document.querySelector('[aria-label="AI 模式"]'),'click');await act(async()=>{await tick();});
|
||||
assert.ok(document.querySelector('[aria-label="元素 AI 工具"]'));assert.ok(document.querySelector('[aria-label="AI 模型"]').textContent.includes('测试文本模型'));
|
||||
await event(document.querySelector('[aria-label="继续向下传播"]'),'click');
|
||||
await event(document.querySelector('[aria-label="执行 AI"]'),'click');
|
||||
assert.ok(job);assert.equal(document.querySelector('[aria-label="AI 模式"]').getAttribute('aria-pressed'),'true');
|
||||
// Move a child while the run executes. Its original owner receives the save.
|
||||
await event(node(),'mousedown',{clientX:200,clientY:200,button:0,buttons:1});await event(window,'mousemove',{clientX:210,clientY:200,buttons:1});await event(window,'mousemove',{clientX:240,clientY:215,buttons:1});await event(window,'mouseup',{clientX:240,clientY:215,button:0});
|
||||
const waitUntil=async(check,label)=>{for(let i=0;i<100;i++){if(check())return;await act(async()=>{await new Promise(r=>setTimeout(r,80));});}throw new Error('Timed out: '+label);};
|
||||
await waitUntil(()=>node()?.querySelector('textarea')?.value==='生成后的子图内容','child AI result');
|
||||
assert.ok(document.querySelector('.react-flow__edge[data-id="boundary"] animateMotion'),'Actual edge.active event starts execution-only animation');
|
||||
assert.ok(records.get(child).document.nodes[0].position.x>10,'AI writeback preserves the concurrent child drag');
|
||||
await waitUntil(()=>document.querySelector('.react-flow__node[data-id="outside"] textarea')?.value==='向下传播结果','downstream AI result');
|
||||
assert.equal(document.querySelector('.react-flow__edge[data-id="boundary"] animateMotion'),null,'Completed run stops execution-only animation');
|
||||
assert.ok(saves.some(save=>save.board===child),'Child edits save to child canvas');
|
||||
const childPosition=structuredClone(records.get(child).document.nodes[0].position);
|
||||
await event(document.querySelector('.react-flow__node[data-id="linked-frame"]'),'click');
|
||||
await event(document.querySelector('[aria-label="收起子画布"]'),'click');assert.equal(node(),null);assert.ok(document.querySelector('[aria-label="展开子画布"]'));
|
||||
await event(document.querySelector('[aria-label="展开子画布"]'),'click');assert.ok(node());assert.deepEqual(records.get(child).document.nodes[0].position,childPosition);
|
||||
const before=records.get(parent).document.nodes.length;await event(document.querySelector('[aria-label="添加框选区"]'),'click');
|
||||
assert.equal(records.get(parent).document.nodes.length,before+1);assert.equal(records.size,3);
|
||||
await event(document.querySelector('[aria-label="撤销"]'),'click');assert.equal(records.get(parent).document.nodes.length,before);
|
||||
await event(document.querySelector('[aria-label="重做"]'),'click');assert.equal(records.get(parent).document.nodes.length,before+1);
|
||||
await event(document.querySelector('[aria-label="撤销"]'),'click');assert.equal(records.get(parent).document.nodes.length,before,'Undo after recreate uses the new child participant');
|
||||
await event(document.querySelector('[aria-label="重做"]'),'click');assert.equal(records.get(parent).document.nodes.length,before+1);
|
||||
console.log('PASS transactions: create undo/redo and fresh child version participants');
|
||||
|
||||
await event(node(),'click');
|
||||
const fromHandle=node().querySelector('[data-handleid="bottom"]');
|
||||
const toHandle=document.querySelector('.react-flow__node[data-id="outside"] [data-handleid="left"]');
|
||||
const originalHitTest=document.elementFromPoint;document.elementFromPoint=()=>toHandle;
|
||||
await event(fromHandle,'mousedown',{clientX:250,clientY:250,button:0,buttons:1});
|
||||
await event(document,'mousemove',{clientX:500,clientY:250,buttons:1});
|
||||
await event(document,'mouseup',{clientX:500,clientY:250,button:0});
|
||||
document.elementFromPoint=originalHitTest;
|
||||
await waitUntil(()=>records.get(parent).document.nodes.find(node=>node.id==='linked-frame').data.ports.some(port=>port.id==='generated-port'),'cross-canvas connection transaction');
|
||||
await waitUntil(()=>[...document.querySelectorAll('button')].some(button=>button.textContent==='重试同步'),'uncertain transaction recovery');
|
||||
await event([...document.querySelectorAll('button')].find(button=>button.textContent==='重试同步'),'click');
|
||||
assert.equal(connectionRequests,2);assert.equal(document.querySelector('.react-flow__edge[data-id="'+connectionReceipt.parent.document.edges.at(-1).id+'"]')!==null,true);
|
||||
assert.equal(records.get(parent).document.edges.length,2);
|
||||
console.log('PASS connections: real handle gesture, capability gate, LCA transaction and exact participant versions');
|
||||
await event(document.querySelector('.react-flow__node[data-id="image-target"]'),'click');
|
||||
await act(async()=>{await tick();});
|
||||
assert.ok(document.querySelector('[aria-label="AI 模型"]').textContent.includes('测试图片模型'),'Sticky AI mode selects models for the newly selected image');
|
||||
assert.ok(!document.querySelector('[aria-label="AI 模型"]').textContent.includes('测试文本模型'));
|
||||
await event(document.querySelector('[aria-label="执行 AI"]'),'click');
|
||||
await waitUntil(()=>document.querySelector('.react-flow__node[data-id="image-target"] img')?.getAttribute('src')==='/api/foundry/workspaces/w/assets/generated-image','managed AI image result');
|
||||
const generated=records.get(parent).document.nodes.find(node=>node.id==='image-target');assert.equal(generated.width,image.width);assert.equal(generated.height,image.height);assert.equal(generated.data.title,'图片目标');
|
||||
await waitUntil(()=>document.querySelector('[aria-label="执行 AI"]'),'image run terminal UI');
|
||||
await event(document.querySelector('[aria-label="执行 AI"]'),'click');
|
||||
await event(document.querySelector('[aria-label="取消生成"]'),'click');
|
||||
await waitUntil(()=>document.querySelector('[aria-label="元素 AI 工具"]')?.textContent.includes('已取消生成'),'cancelled image run');
|
||||
assert.equal(records.get(parent).document.nodes.find(node=>node.id==='image-target').data.src,generated.data.src);
|
||||
console.log('PASS AI images: sticky mode, modality filtering, original-node asset output and cancellation');
|
||||
console.log('PASS V2: child ownership and saves, live model catalog, AI propagation/polling, geometry preservation, collapse/expand and transactional child creation');
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { decodeCanvas, emptyCanvas, makeCanvasNode } from '../src/lib/canvas.ts';
|
||||
import { curveSegments, cubicPoint, insertCurveKnot } from '../src/lib/canvas-curves.ts';
|
||||
import { mergeCanvasDocuments, projectCanvasTree, transformEdgeData } from '../src/lib/canvas-tree.ts';
|
||||
import { resizeFromPointer } from '../src/lib/canvas-geometry.ts';
|
||||
import { managedImageSource } from '../src/lib/canvas-v2.ts';
|
||||
const childId = '11111111-1111-4111-8111-111111111111';
|
||||
function fixture() {
|
||||
const frame = makeCanvasNode('frame', {x:100,y:100}); frame.id='frame'; frame.width=5000;
|
||||
frame.data={...frame.data,subgraph:{boardId:childId},presentation:{collapsed:false,contentScale:.2,expandedSize:{width:5000,height:400},collapsedSize:{width:240,height:140}},ports:[{id:'output',direction:'output',nodeId:'inside',handle:'right'}]};
|
||||
const text=makeCanvasNode('text',{x:900,y:100});text.id='outside';text.data.ai={enabled:true,modelId:'dynamic-model',prompt:'hello',propagate:false,parameters:{}};
|
||||
const inside=makeCanvasNode('text',{x:10,y:20});inside.id='inside';
|
||||
const edge={id:'edge',source:'frame',target:'outside',sourceHandle:'port:output',targetHandle:'left',data:{appearance:{color:'#123456',width:2,line:'dashed',head:{type:'triangle',filled:false,size:12},tail:{type:'circle',size:8}},animation:{mode:'executing',effect:'flow',direction:'forward',durationMs:1200,color:'#ffffff'},path:{type:'bezier-chain',knots:[{id:'k',position:{x:700,y:150},in:{x:-30,y:0},out:{x:30,y:0},mode:'smooth'}]},execution:{enabled:true}}};
|
||||
return {frame,text,inside,root:{...emptyCanvas(),schema_version:2,nodes:[frame,text],edges:[edge]},child:{...emptyCanvas(),schema_version:2,nodes:[inside]}};
|
||||
}
|
||||
test('V2 roundtrip preserves linked frames, all curve/animation/marker data and AI config',()=>{
|
||||
const {root}=fixture(); const decoded=decodeCanvas(JSON.stringify(root));
|
||||
assert.deepEqual(decoded.edges[0].data,root.edges[0].data); assert.deepEqual(decoded.nodes[0].data.subgraph,root.nodes[0].data.subgraph); assert.deepEqual(decoded.nodes[1].data.ai,root.nodes[1].data.ai);assert.equal(decoded.nodes[0].width,5000);
|
||||
assert.deepEqual(decodeCanvas(JSON.stringify(decoded)),decoded);
|
||||
});
|
||||
test('V2 rejects bad control data, wrong port directions and oversized ordinary elements',()=>{
|
||||
for(const mutate of [d=>d.edges[0].data.path.knots[0].out.x=-30,d=>d.edges[0].data.animation.mode='random',d=>d.edges[0].data.appearance.width=100,d=>d.nodes[0].data.ports[0].direction='input',d=>d.nodes[1].width=5000,d=>d.nodes[1].data.ai.parameters=[]]){
|
||||
const {root}=fixture();mutate(root);assert.throws(()=>decodeCanvas(JSON.stringify(root)));
|
||||
}
|
||||
});
|
||||
test('expanded child nodes are projected once and boundary edges resolve to their actual nodes',()=>{
|
||||
const {root,child}=fixture(), documents=new Map([['root',root],[childId,child]]);const view=projectCanvasTree('root',documents);
|
||||
assert.equal(view.nodes.length,3);const projected=view.nodes.find(n=>n.id===childId+'::inside');assert.deepEqual(projected.position,{x:126,y:140});assert.equal(projected.width,52);assert.equal(view.nodeRefs.get(projected.id).boardId,childId);assert.equal(view.edges[0].source,projected.id);assert.equal(root.nodes.length,2);assert.equal(child.nodes.length,1);
|
||||
root.nodes[0].data.presentation.collapsed=true;const collapsed=projectCanvasTree('root',documents);assert.equal(collapsed.nodes.length,2);assert.equal(collapsed.edges[0].source,'frame');assert.deepEqual(child.nodes[0].position,{x:10,y:20});
|
||||
});
|
||||
test('curve coordinates and vectors invert exactly between owner and projected canvases',()=>{
|
||||
const {root}=fixture();const ref={boardId:'b',id:'e',origin:{x:100,y:-40},scale:.2};assert.deepEqual(transformEdgeData(transformEdgeData(root.edges[0].data,ref),ref,true),root.edges[0].data);
|
||||
});
|
||||
test('adding a curve point preserves both halves of the original cubic',()=>{
|
||||
const from={point:{x:0,y:0},normal:{x:1,y:0}},to={point:{x:500,y:200},normal:{x:-1,y:0}};
|
||||
const old=curveSegments(from,to)[0], split=insertCurveKnot(from,to,undefined), next=curveSegments(from,to,split.path);
|
||||
assert.equal(next.length,2);for(let i=0;i<=100;i++){const t=i/100,a=cubicPoint(old,t),b=cubicPoint(next[t<=.5?0:1],t<=.5?t*2:(t-.5)*2);assert.ok(Math.hypot(a.x-b.x,a.y-b.y)<1e-8);}
|
||||
});
|
||||
test('AI remote output merges with local geometry but conflicts with locally edited text',()=>{
|
||||
const base=fixture().child,local=structuredClone(base),remote=structuredClone(base);local.nodes[0].position={x:200,y:300};remote.nodes[0].data.text='generated';
|
||||
const merged=mergeCanvasDocuments(base,local,remote);assert.deepEqual(merged.nodes[0].position,{x:200,y:300});assert.equal(merged.nodes[0].data.text,'generated');local.nodes[0].data.text='user typing';assert.throws(()=>mergeCanvasDocuments(base,local,remote),/version_conflict/);
|
||||
});
|
||||
test('eight-direction resize is free by default in resize mode and Shift locks the aspect',()=>{
|
||||
for(const kind of ['plain-text','plain-image','text','image','frame']){
|
||||
const node=makeCanvasNode(kind,{x:10,y:20});const oldRatio=node.width/node.height;
|
||||
for(const direction of ['top','right','bottom','left','top-left','top-right','bottom-left','bottom-right']){
|
||||
const patch=resizeFromPointer(node,direction,{x:80,y:40},true);assert.ok(Math.abs(patch.width/patch.height-oldRatio)<1e-9);
|
||||
}
|
||||
}
|
||||
const image=makeCanvasNode('plain-image',{x:0,y:0});assert.equal(resizeFromPointer(image,'right',{x:100,y:0},false).height,image.height);
|
||||
const frame=makeCanvasNode('frame',{x:0,y:0});assert.ok(resizeFromPointer(frame,'right',{x:10000,y:0},false).width>3000);
|
||||
});
|
||||
test('managed assets render through the authenticated same-origin proxy',()=>{
|
||||
assert.equal(managedImageSource('https://api.foundry.entroia.com/projects/endoleap/workspaces/w/assets/a'),'/api/foundry/workspaces/w/assets/a');assert.equal(managedImageSource('https://example.com/a.png'),'https://example.com/a.png');
|
||||
});
|
||||
|
||||
test('connection plan uses the lowest common ancestor and exactly both ownership paths',async()=>{
|
||||
const {connectionTransactionPlan}=await import('../src/lib/canvas-subgraphs.ts');const {root,child}=fixture();
|
||||
const nested='33333333-3333-4333-8333-333333333333';const frame=makeCanvasNode('frame',{x:0,y:0});frame.data.subgraph={boardId:nested};child.nodes.push(frame);
|
||||
const docs=new Map([['root',root],[childId,child],[nested,{...emptyCanvas(),schema_version:2,nodes:[]}]]);
|
||||
const result=connectionTransactionPlan(docs,{boardId:nested,id:'a',handle:'right'},{boardId:'root',id:'b',handle:'left'});
|
||||
assert.equal(result.boardId,'root');assert.deepEqual(new Set(result.participants),new Set(['root',childId,nested]));assert.equal(result.source.node_id,'a');
|
||||
const inner=connectionTransactionPlan(docs,{boardId:nested,id:'a',handle:'bottom'},{boardId:childId,id:'b',handle:'top'});assert.equal(inner.boardId,childId);assert.deepEqual(new Set(inner.participants),new Set([childId,nested]));
|
||||
});
|
||||
test('incompatible drafts cannot overwrite migrated V2 ownership',async()=>{
|
||||
const {canRestoreCanvasDraft}=await import('../src/lib/canvas-sync.ts');const {root}=fixture();const old={...structuredClone(root),schema_version:1};assert.equal(canRestoreCanvasDraft(old,root),false);
|
||||
const changed=structuredClone(root);changed.nodes[0].data.subgraph.boardId='55555555-5555-4555-8555-555555555555';assert.equal(canRestoreCanvasDraft(changed,root),false);assert.equal(canRestoreCanvasDraft(structuredClone(root),root),true);
|
||||
});
|
||||
test('selecting both parent frame and child applies a shared drag once',async()=>{
|
||||
const {referenceAfterMoves}=await import('../src/lib/canvas-tree.ts');const {root,child}=fixture();const docs=new Map([['root',root],[childId,child]]),view=projectCanvasTree('root',docs);
|
||||
const projected=view.nodes.find(node=>node.id===childId+'::inside');const delta={x:50,y:80};const moved={x:projected.position.x+delta.x,y:projected.position.y+delta.y};
|
||||
const ref=referenceAfterMoves('root',docs,view.boards,childId,new Map([['frame',{x:root.nodes[0].position.x+delta.x,y:root.nodes[0].position.y+delta.y}],[projected.id,moved]]));
|
||||
assert.deepEqual({x:(moved.x-ref.origin.x)/ref.scale,y:(moved.y-ref.origin.y)/ref.scale},child.nodes[0].position);
|
||||
});
|
||||
Reference in New Issue
Block a user