Compare commits
174 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| bf1669125d | |||
| 14f7475296 | |||
| d3eef78431 | |||
| dcbbdad25d | |||
| 15ee91772d | |||
| b05058a955 | |||
| b34dc6089a | |||
| 24c4592060 | |||
| 1f98d8d61f | |||
| 26da326c18 | |||
| f1c3b63a32 | |||
| 9d6d0775a7 | |||
| 4756f1b869 | |||
| dbb33bfdd8 | |||
| d995cc1784 | |||
| 580a14f508 | |||
| db6594f473 | |||
| 0ced171305 | |||
| c07f8fbd82 | |||
| 96fc37c319 | |||
| 3ea1a33747 | |||
| 58f97eaf07 | |||
| 465596fb7b | |||
| e3a2335160 | |||
| 2089b63e79 | |||
| 5eabca5056 | |||
| 210bc14074 | |||
| b4c40d3087 | |||
| 57dab91a9c | |||
| 0ce73a6854 | |||
| 680aea0aa7 | |||
| 72a8e01114 | |||
| 26d1688f2f | |||
| 4946e8025b | |||
| 64ed8fe57a | |||
| 8958d835b9 | |||
| b051509dee | |||
| 2452fd6ae9 | |||
| afccb8c95c | |||
| b7fe214d97 | |||
| 0abb7c96ba | |||
| b735667e00 | |||
| 786453cce0 | |||
| 69eb29343e | |||
| a6092e61cd | |||
| c8e5f6ac49 | |||
| 697716ee89 | |||
| 104ef3942f | |||
| 6b3986f89f | |||
| aa72f9e484 | |||
| 3ae18b1670 | |||
| dc086a4774 | |||
| c087d32e84 | |||
| fb997f23a8 | |||
| 198c7f9863 | |||
| 2529a801b2 | |||
| 40f3c97bbd | |||
| 5590d55bb1 | |||
| d8bf6406c6 | |||
| da7d9d056f | |||
| 0ce3abbe04 | |||
| f11ea36b48 | |||
| f0ee0736b7 | |||
| bcc11e2e84 | |||
| ed85546674 | |||
| 732c15f7bf | |||
| bb5bc9d8e9 | |||
| b833f0a2fc | |||
| ccd61bfb90 | |||
| 70bc134436 | |||
| fea813ac4f | |||
| be6a092c40 | |||
| 7f0d8e7983 | |||
| 180f66b2af | |||
| fd6c166f4b | |||
| 9efe6e7c9f | |||
| 28c792d640 | |||
| 43c74a1485 | |||
| 6c4ab46413 | |||
| a9bc6e7d29 | |||
| 0813b8df53 | |||
| 2f385433be | |||
| e6b9999202 | |||
| 73b132f71e | |||
| eafc5ee694 | |||
| 2adc22a5d9 | |||
| 3f7a063630 | |||
| 78dfbbf4d7 | |||
| c4dfaf50a5 | |||
| 46fabafca8 | |||
| c6d77bdefd | |||
| 04a9b0c2b2 | |||
| 075bb79443 | |||
| 575b5a9256 | |||
| d7b3321df1 | |||
| 902e30a65b | |||
| a626e6c300 | |||
| a294c6ca41 | |||
| 74c219f91d | |||
| 196ca4514e | |||
| bceaf2eca6 | |||
| 3a71c9ac57 | |||
| 5b35519562 | |||
| 6ec6b12074 | |||
| 3c5e67c03d | |||
| a66933775e | |||
| 945aaea504 | |||
| 6c0a421f59 | |||
| cabf2ec667 | |||
| ef08222915 | |||
| fff6213714 | |||
| 0a705f82ee | |||
| a92ae72c8b | |||
| 0205f24904 | |||
| 8219209284 | |||
| 03c3a0dc66 | |||
| 5919eb0453 | |||
| fa7dad44e7 | |||
| 3750648dea | |||
| 3ccb8dbc5c | |||
| 4b594f85eb | |||
| bbe1e60496 | |||
| 6387939510 | |||
| b57c3404c5 | |||
| b0afe8aa8c | |||
| 9769f80cf5 | |||
| 4035956607 | |||
| b5ad3e0cc9 | |||
| ed3c3a2ab3 | |||
| a51d189b8e | |||
| 627a043d92 | |||
| 361e636ce7 | |||
| 6ff34b67e7 | |||
| e00ac017f1 | |||
| 60fad39847 | |||
| 1f89f9ebb5 | |||
| 8a2b1e4bc7 | |||
| 8a58d9785c | |||
| ff07b52a21 | |||
| bde841330a | |||
| 7ed0aa61cb | |||
| 379db2fe14 | |||
| ce02e43958 | |||
| 72d6105e7c | |||
| 160c16afb7 | |||
| be94446f12 | |||
| 5296c953b1 | |||
| 911931d265 | |||
| 95a5cd5697 | |||
| 90650873e6 | |||
| 564e878844 | |||
| 1994ff2576 | |||
| d99b6951cf | |||
| 1b29b0138b | |||
| 231f70d87d | |||
| 343bfdecff | |||
| 78194e9f95 | |||
| f741a4830f | |||
| d702240240 | |||
| f9cb1ae120 | |||
| 89b6f4a253 | |||
| a0417b4811 | |||
| 0c7fae6e32 | |||
| 77fddb9f1a | |||
| 6243602402 | |||
| d07bc2ef91 | |||
| 69b52fd819 | |||
| e2987de21a | |||
| 8a76e53a9f | |||
| 8a19909716 | |||
| 10374c3696 | |||
| 7b4d4c0ad3 | |||
| 6ff7c3064b | |||
| 39b85bcda4 |
@@ -1,5 +1,6 @@
|
||||
node_modules/
|
||||
.next/
|
||||
.headless-dist/
|
||||
.env*
|
||||
!.env.example
|
||||
*.tsbuildinfo
|
||||
|
||||
@@ -116,7 +116,9 @@ 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`,已按同步本地编辑与独立异步 runtime 重写,包含 element/component/feature、API 选择、八个工具栏位置、mode 作用域、上传恢复与 ACK 合并合同。当前没有创建或发布该库;outbox/IndexedDB 等仍是待实施方案。其他已被替代的元素/分区草案已清理。
|
||||
Headless Canvas API 的完整方案保留在本地忽略的 `docs/HEADLESS_CANVAS_API_DRAFT.md`。当前工作分支已接入纯 core/runtime、ledger/queue、IndexedDB 恢复、渐进导入/深复制、canonical Foundry 协议和私有 ESM 包预览;使用方式见 `headless/README.md`,尚未发布 npm registry,也不代表完整产品验收或后端部署完成。
|
||||
|
||||
Workspace AssetDepot 支持重新授权后的跨项目采用、持久绑定与共享字节。浏览器数据库 v4 将已确认上传的项目文件转换为共享字节指针,保留原 File MIME 和逻辑读取接口;绑定使用恢复格式13。共享回收要求所有项目的持久引用清单完整且与 Root 同步;关闭项目仍受保护。源文件转换、字节、引用清单和 Root 在同一个事务中保存,未确认上传继续保留原 Blob。
|
||||
|
||||
应用提示使用 Sonner,图标说明使用 Radix Tooltip;根布局统一安装 provider。画布通用操作错误与归属完成使用固定 ID 通知,保存冲突、失败上传和重试操作仍保留在相关内容处。消息和浮层依赖仅属于应用 UI,不作为未来 headless core 的依赖。
|
||||
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
# Headless Canvas:完整验收矩阵
|
||||
|
||||
2026-11-11。T01–T36 的实现与直接断言已逐项审计,当前版本的浏览器、存储、代理和270文件打包验收均已通过。本文随 SDK 打包,避免验收依据只存在于被忽略的研究 Draft 中。它记录已实现的第一版合同及证据范围,不表示代码已合并或生产环境已验证。
|
||||
|
||||
## 版本与复核入口
|
||||
|
||||
前端产品实现基线为 `15ee917`,此前包含 `b05058a` 的迁移数据校验修复;真实服务 consumer 固定为 `dcbbdad25de7c3b1d6bee317df6074da2c0642eb`。后续 `d3eef78` 和本次修改增加测试与交付文档,不修改产品实现。后端为 `506f778ac33d47fbd6f38412995a10218d59fe0a`。
|
||||
|
||||
后端现有 ces-control CI [#1186](https://gitea.entroia.com/Zayden-Jung/foundry-core/actions/runs/1186) 与 [#1187](https://gitea.entroia.com/Zayden-Jung/foundry-core/actions/runs/1187) 的七个阶段全部成功,包括实际认证 HTTP、PostgreSQL、资源与 RunPort consumer。两次报告全部功能标志为 true,生成执行次数均为 3。轮询次数随调度变化,不能用总 HTTP 次数代替“不重复受理”的独立断言。详见 [SERVICE_ACCEPTANCE.md](SERVICE_ACCEPTANCE.md)。
|
||||
|
||||
下面命令从 Endoleap 源码仓库根目录执行。Node 测试通过不自动替代浏览器或真实后端证据。用户允许本地前端编译与测试;后端编译、容器和数据库验收只在既有远程 CI 执行。
|
||||
|
||||
| 检查 | 命令 | 本次结果 |
|
||||
|---|---|---|
|
||||
| 全量源码单元/协议 fixture | `npm run test:headless` | 822 项通过,无失败或跳过 |
|
||||
| 类型 | `npm run typecheck` | 通过 |
|
||||
| 实际页面 webpack | `node tests/headless/renderer.integration.mjs` | 51 项通过 |
|
||||
| 实际页面 Turbopack | `node tests/headless/renderer.integration.mjs --turbopack` | 51 项通过 |
|
||||
| 公共 hooks / StrictMode | `node tests/headless/renderer.integration.mjs --hooks-only` | 10 项通过 |
|
||||
| 实际 IndexedDB / Web Locks | `npm run test:headless:browser` | 57 组通过 |
|
||||
| 实际 Next 代理 | `npm run test:headless:proxy` | 6 项通过 |
|
||||
| 原编辑器升级交接 | `node tests/headless/renderer.integration.mjs --migration-only`,另加 `--turbopack` | webpack 与 Turbopack 各 5 项通过 |
|
||||
| 独立解包、exports、类型、可选 peers、编译后浏览器/Worker | `npm run test:headless:package` | 270 文件解包、所有列出检查通过 |
|
||||
|
||||
## 逐项验收
|
||||
|
||||
负责组与 Draft 一致:A 模型/registry,B editor/history/交互,C runtime/同步,D storage/assets,E 导入/复制/loader,F bindings/renderer,G 后端/service。文件名默认位于 `tests/headless/`;后端 Go 用例位于 Foundry `internal/platform/`。每行“通过”限于列出的实现模式与断言,限制见后文。
|
||||
|
||||
| ID / 负责组 | 场景与实现边界 | 直接测试入口与已检查断言 |
|
||||
|---|---|---|
|
||||
| T01 A/B/E | 项目 ID 唯一;复制新 ID;恢复新 incarnation | `core.test.mjs`、`duplicate.test.mjs`;冲突拒绝、稳定映射、delete/undo 后旧 target 无效;实际页面旧编辑回调不能修改恢复对象。 |
|
||||
| T02 A/B/F/G | 重挂/循环/解散 canvas | `dissolve.test.mjs` 六项;直属成员提升、入口保留、孙 canvas 仅改直接 parent、世界坐标/单位/曲线转换、声明引用重写、权限/opaque/partial 拒绝;真实 consumer 丢 ACK、原 key 恢复及 undo/redo。 |
|
||||
| T03 A/G | 跨兄弟 canvas 的边归共同上级 | `core.test.mjs`、`queries.test.mjs` 与真实 owner 并发用例;边 owner 与端点 owner 分开验证,不能为存边篡改端点归属。 |
|
||||
| T04 A/F | 未知 component/未来版本保留 | `core.test.mjs`、`renderers.test.mjs` 与实际页面 opaque 恢复;编辑其它内容不丢 payload,不把未知资源字段当成可加载图片。 |
|
||||
| T05 A/F | 同模型多 renderer;重复 handler 拒绝 | `core.test.mjs`、`renderers.test.mjs`;两个宿主 catalog 投影同一 record,共享行为;同 capability 冲突和重复 component 注册明确失败。 |
|
||||
| T06 A/B | schema/引用迁移失败零发布 | `core.test.mjs`;缺 converter、引用转换抛错、多步后续失败、schema 失败、非法 JSON、getter 单次捕获;原数据/身份/版本和 Root/history/ledger/index 保留。 |
|
||||
| T07 A/B/C | 第二命令失败、async、重入 | `core.test.mjs`、`publication-guard.test.mjs`;dispatch/transaction/history/updateRuntime/reconcile 嵌套拒绝,不执行 reducer 或 observer;异步事务关闭后不能继续提交。 |
|
||||
| T08 B/D | 双栈、新编辑清 redo、容量与 pin | `history-transition.test.mjs`、`resources.test.mjs`;no-op 不造历史,容量淘汰释放 pin,删除后的 undo 资产在历史保留期内不回收。 |
|
||||
| T09 B/C | 同字段冲突与未知外部结果 | `conflicts.test.mjs`、`applied-recovery.test.mjs`、`engine.test.mjs`;冲突不提前移动栈;本地补偿可见,外部 unknown 先协调原 key,不冒称服务器确认。 |
|
||||
| T10 B/D | upload ACK 后 Escape | `core.test.mjs`、`resources.test.mjs` 与实际页面;清 session preview,保留 committed stored ref,不用手势起点的旧整对象恢复图片。 |
|
||||
| T11 B/F | pointerup 包含节流尾帧 | 实际 `renderer.integration.mjs` 的 resize/曲线原生输入;最终 300×220、曲线 440/265,一个 POST;core 对应一个 history 单元。 |
|
||||
| T12 B/F | owner/权限/删除使手势失效 | `gesture-placement.test.mjs`、`pointer-capture.test.mjs`、core 与实际页面;祖先移动取消,迟到 pointerup/lost-capture 不复活或覆盖新坐标。 |
|
||||
| T13 B/F/C | IME/Portal/detach/多 view | `input-router.test.mjs`、`session-sources.test.mjs`、bindings 与实际页面;输入只 claim 一层,旧 detach 不删除新 view,engine 任务独立于挂载生命周期。 |
|
||||
| T14 C/D | 未发 create→rename→delete 合并 | `ledger.test.mjs`、`batch-projection.test.mjs`、`elision.test.mjs`;absent/exists/unknown 基线和完整 logical coverage,position-only 不回退 title;冻结/possibly-applied 不抵消。 |
|
||||
| T15 C/G | create 已提交丢 ACK,随后 delete | `engine.test.mjs` 的 reopen 用例;submit→query 原 body/key→独立 delete,创建不再 POST,服务器无残留且两项历史保留;幂等服务合同另由真实 DB 覆盖。 |
|
||||
| T16 C/G | accepted/cancel/failed/404 | `service.test.mjs`、`runs.test.mjs` 与真实 RunPort consumer;没有 terminal identity proof 不判零写入,取消/迟到 poll 保留控制 epoch,已取消 queued run 未调用生成。 |
|
||||
| T17 C/D | 多 canvas claim 与跨标签 fencing | `scheduler.test.mjs`、实际 browser-storage/migration;全部参与者一起认领,unknown 保留 claim,过期 handle 不能写新 fence,shared legacy lease 排斥独占升级。 |
|
||||
| T18 C | heap 到期、时钟变化、饥饿 | `scheduler.test.mjs`、`ledger.test.mjs`;wall/elapsed 分开,前跳/回退/重开剩余等待/旧 timer 回调,依赖和容量 lane 不被越过,无无界忙轮询。 |
|
||||
| T19 C/G | ACK 缺失/乱序/后续 pending | `service.test.mjs`、`engine.test.mjs`、`loading-engine.test.mjs`;缺失/多余参与者与 logical coverage 拒绝;不发布半份基线,旧 ACK/tail 不清新编辑。 |
|
||||
| T20 C/D/G | 旧冻结请求/receipt 过期 | `scheduler.test.mjs`、`applied-recovery.test.mjs`;无解释器阻塞而保留原 body/key,已有 applied proof 后 receipt 过期走 fresh read,不重发旧 POST。 |
|
||||
| T21 C/D | Root/journal/文件提交失败 | `durability.test.mjs`、`engine.test.mjs` 与实际 IDB;quota 不虚报 durable、不发送,后续 put/delete 失败整事务回滚,旧 revision 的完成不能确认新 dirty 状态。 |
|
||||
| T22 C/D/G | 全部声明的恢复窗口 | `crash-windows.test.mjs` 的六个切点场景;原逻辑 ID/body/key、历史、POST/query 次数及最终 durability;实际 IDB 另验 checksum/journal gap/fence,非物理 kill 全组合测试。 |
|
||||
| T23 D/B/E | 图片复制/替换/删除/undo | `resources.test.mjs` 32 项、实际浏览器与真实服务;资源共享,replacement 阻塞仍保留旧 ref,undo 挡迟到替换,redo/reopen 不重复上传或更换元素 incarnation。 |
|
||||
| T24 D/A | 索引滞后/opaque/历史保留 pin | `resource-pin-scan.test.mjs`、`asset-depot.test.mjs`、实际 IDB;working/ACK/history owners 独立,coverage 不足/过期 revision/未知引用拒绝永久回收,repin 重启计时。 |
|
||||
| T25 E/F | 边解析/边加入/边显示及编辑 | `imports.test.mjs`、`imports-engine.test.mjs`、budget 系列与实际页面;源尾部阻塞时首记录已可编辑,后续导入不覆盖编辑;完整 SHA 未验证前不发送。 |
|
||||
| T26 E/C | 第 101 项失败/取消 | `imports.test.mjs` 的 100 valid→bad JSON→valid tail→missing edge;101 个合法元素保留、两个明确失败、未闭合边不发;取消成功前缀可恢复且不自动继续。 |
|
||||
| T27 E/A/G | 深复制 F 与内部 A 同选 | `duplicate.test.mjs`、`imports-engine.test.mjs` 与真实 consumer;深层树只复制一次,P 保留 C→F 并增 C→F′,资源复用,无新 run;字面字段不误做 ID 重写。 |
|
||||
| T28 A/E | 旧格式/未知 envelope | `entry.test.mjs`、`import-document.test.mjs`;只走明确协商 fallback/codec,403 不回退,读取不隐式迁移,未知格式/版本拒绝,不猜 owner。 |
|
||||
| T29 B/E | import 与手动编辑历史穿插 | `imports-engine.test.mjs` 新增 reopen 测试;导入前缀→手动改标题→导入尾批保持三个 undo 单元,依次撤销再重做全部三项,标题与内容均正确。 |
|
||||
| T30 C/F | 回放/重试/observer 重挂 | ledger/scheduler、`loader.test.mjs`、`session-sources.test.mjs`、公共 StrictMode hooks;queued Set 与 ledger 同源,旧回调不删新句柄,异常 observer 不阻止其它观察者,无历史/任务重复。 |
|
||||
| T31 C/D/F | logout/账号切换/升级/配额 | `model-loader.test.mjs`、host/workspace 用例、实际 IDB 与 logout 页面;新 runtime 的 account/workspace/authEpoch scope、retire fence、DB versionchange、dirty 风险可见。旧兼容编辑器备份 key 的范围见限制。 |
|
||||
| T32 E/C | loader 尾块失败/查询变化/晚导航 | `loader.test.mjs`、`model-preview.test.mjs`、`model-loader.test.mjs` 与实际页面;partial 不冒充 complete,fingerprint/epoch 分离,旧请求即使无视 abort 也不能删或完成新请求。 |
|
||||
| T33 A/F | rotate/细线/zoom/展示路径 | `hit-query.test.mjs`、geometry/connection paint 与实际页面;两级旋转和单位换算,zoom .1/1/4 的屏幕容差,Bezier 精判非直线近似,折叠端点展示与原引用分开。 |
|
||||
| T34 A/F | 只改标题/只 ACK 的局部计算 | `queries.test.mjs`、`projection-cache.test.mjs`、`scene-projection.test.mjs` 与实际 renderer 计数;无关几何/节点投影复用,listener 看到同 revision 的模型与索引。 |
|
||||
| T35 A/E/F | 停放 view/导出/引用递归 | `bindings.test.mjs`、`import-document.test.mjs` 与实际 toolbar 导出/重导入;detach 不关闭项目任务,导出从完整模型读取所有 canvas/elements/tombstones,不遍历 DOM。 |
|
||||
| T36 G/C | 幂等/版本/跨图原子 | `TestCanonicalConcurrentMigrationAndOwnershipWrites`、`TestCanonicalModelMigrationAndIncrementalLifecycle`、迁移 guards/late-cancel 用例及真实 consumer;同 key 同 bytes 只一次版本推进,改 bytes 拒绝,竞争端点重挂只有一个赢,失败与 late cancel 整体回滚。 |
|
||||
|
||||
## 恢复切点:本地事实与服务事实匹配
|
||||
|
||||
不能拿任意旧 checkpoint 配任意服务状态,再从结果声称完整恢复。`crash-windows.test.mjs` 捕获实际 engine 产生的不可变保存图像,逐个搭配对应服务状态:
|
||||
|
||||
| 窗口 | 重开时必须保持的行为 |
|
||||
|---|---|
|
||||
| 可见编辑尚未 durable | 从最后 durable Root 重开;可能丢失该编辑;关闭前 dirty 状态不能声称已保存。 |
|
||||
| pending 意图已 durable、尚未冻结 | 原 logical operation ID 保留;生成一次请求,不能重复 history。 |
|
||||
| frozen 已 durable、尚未发送 | 原 body/key 保留;恢复可保守先 query,再原样 retry,不能换请求身份。 |
|
||||
| dispatch-marked,服务尚未提交 | query 缺失不能当可靠零写入终态;按协议原 body/key retry。 |
|
||||
| 服务已提交,本地 receipt 尚未 durable | query 原请求取回成功,不再 POST;之后才推进本地基线。 |
|
||||
| receipt/基线已 durable | 重开不再执行或重复历史/副作用。 |
|
||||
|
||||
六种测试分别覆盖 Draft 的提交前、durable 意图、冻结/发送、服务提交、本地确认边界。文件字节、Root 与 journal 的实际事务回滚另由真实 IndexedDB 用例证明;AI、上传与删除依赖分别有身份/丢 ACK 用例。没有声称“每种任务 × 每个故障 × 每个浏览器”全部做了进程强杀测试。
|
||||
|
||||
## 分工与唯一事实源的交付边界
|
||||
|
||||
A/B 维护 immutable Registry、规范化文档、命令/事务、history 双栈;C/D 维护 ledger、由其派生的 ready 集合/调度队列和到期 heap,以及同一恢复 Root 和字节仓库;E 维护受该 Root 管理的导入/复制检查点、稳定映射与 loader 捕获;F 只拥有 ViewSession 和投影/输入绑定;G 负责授权、版本、幂等、跨图事务和可查询 receipt。不同组通过公共 exports、Change 与端口合同连接,不能再维护另一份业务文档或任务状态。
|
||||
|
||||
结构、包含关系、生命周期、集成入口和示例见随包 [README.md](README.md)。源码测试覆盖各组内部纯逻辑和端口故障;真实页面覆盖连线后的生命周期;真实 CI 覆盖前后端 wire/DB,三个证据层分别保留。
|
||||
|
||||
## 已知边界
|
||||
|
||||
- 第一版的 dissolve 保留原入口元素和原线,只解除其分区入口关系;公开 headless 命令已实现,没有新增产品按钮或另一种“删除入口”政策。
|
||||
- 支持记录/依赖闭合后的渐进显示;完整源 SHA 和恢复身份未可靠保存前不得发请求。任意数据捕获、原子发布、native 与 GC 不能全部抢占。
|
||||
- 性能验收是合作调度软目标。10,000-child 生产复制的首次副本呈现 784.1ms、完成帧约 27.95s、最大 long task 386ms;保留此限制,不能称为完全无卡顿或生产 SLA。环境、命令和原生中文输入记录见 [STEP4_ACCEPTANCE.md](STEP4_ACCEPTANCE.md)。本次没有产品性能代码变化,未重复该规模测量。
|
||||
- 新 canonical runtime 和迁移记录按 account/workspace/authEpoch 隔离;旧兼容编辑器原有的本地草稿备份 key 仍按 workspace/board 范围。T31 不声称已把全部旧备份迁移为账号命名空间。
|
||||
- 未知/未来版本的 payload 保留并限制编辑;不承诺自动理解所有未来 schema。无解释器的冻结请求明确阻塞,不重编码或换 key。
|
||||
- 本地资产回收基于完整 pin/coverage;跨客户端永久服务 GC、完整 CRDT、多处 notebook 阅读模型仍属原稿后续范围。
|
||||
- 远程 deterministic worker fixture 替换生成输出。实际授权、HTTP、DB、事件与文件流程经过验证,外部模型可用性、计费、CLI 认证和生产后台队列不在该证据范围,付费 real-worker 用例仍 opt-in。
|
||||
- 包验收、工作分支推送、PR 可评审、人工合并、部署是不同状态。最终提交/推送状态以交付报告和 Git 为准;Foundry 等待用户手动合并,不以全绿测试声称已合并或部署。
|
||||
|
||||
## 最终交付状态(2026-11-11)
|
||||
|
||||
T01–T36矩阵、当前前端回归、远程服务验收及独立SDK消费检查完成,原七步实现/验收交付完成。前端验收提交 `14f7475` 已推送 `codex/headless-canvas-v1`;后续只有交付状态记录更新。前后端相对最新main的merge-tree演算无冲突;后端506f778状态检查success。Foundry [PR #19](https://gitea.entroia.com/Zayden-Jung/foundry-core/pulls/19)已由Draft转为可评审,API返回draft=false、mergeable=true,等待用户手动合并。前端遵循当前不新建PR的项目约定保留工作分支。两仓库均未由bot合并main或部署。
|
||||
+1238
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,172 @@
|
||||
# Foundry service integration evidence
|
||||
|
||||
Status: implementation, service/migration and final acceptance delivery completed.
|
||||
Main integration and deployment require separate actions.
|
||||
The T01–T36 source/assertion audit and current frontend regression gates have
|
||||
passed; see [FINAL_ACCEPTANCE.md](FINAL_ACCEPTANCE.md). Main integration and
|
||||
production deployment are separate, pending steps.
|
||||
|
||||
## Verified revisions and environment
|
||||
|
||||
- Current verified Endoleap service consumer: `dcbbdad25de7c3b1d6bee317df6074da2c0642eb`
|
||||
(product implementation `15ee917`; receipt diagnostics added by `dcbbdad`).
|
||||
- Current Foundry: `506f778ac33d47fbd6f38412995a10218d59fe0a`.
|
||||
- [Foundry CI #1186](https://gitea.entroia.com/Zayden-Jung/foundry-core/actions/runs/1186):
|
||||
job 1283 completed successfully, all seven steps successful. All prior flags
|
||||
and `dissolveCanvas`, `dissolveLostAckRecovered`, `dissolveUndoRedo` are true;
|
||||
`aiExecutionCount: 3`, 84 transport calls. The new canonical-frame rotation
|
||||
test also passed. Same-commit PR CI #1187/job 1284 subsequently passed all seven
|
||||
steps, with the same flags/execution count and 83 transport calls.
|
||||
- [Foundry CI #1182](https://gitea.entroia.com/Zayden-Jung/foundry-core/actions/runs/1182):
|
||||
job 1279 completed successfully, all seven steps successful, pinned to the
|
||||
browser delivery baseline `24c4592`, Foundry `5db2bf3`. Its report has all document/resource/AI and
|
||||
migration flags true, `aiExecutionCount: 3`, and 75 transport calls.
|
||||
Same-commit PR CI #1183/job 1280 subsequently passed all seven steps too.
|
||||
- [Foundry CI #1180](https://gitea.entroia.com/Zayden-Jung/foundry-core/actions/runs/1180)
|
||||
and [PR CI #1181](https://gitea.entroia.com/Zayden-Jung/foundry-core/actions/runs/1181):
|
||||
completed successfully for consumer `26da326` and Foundry `3268c57`;
|
||||
jobs 1277/1278, all seven steps successful. The original service scope audit
|
||||
also passed CI #1178 and #1179.
|
||||
- Existing `ces-control` runner executes Go checks/build, schema migration and
|
||||
isolated PostgreSQL integration, then the pinned Node 24 consumer and HTTP smoke
|
||||
checks. No local backend build or Docker execution was used.
|
||||
- Local frontend: 822 Node tests and full TypeScript checking passed. Tests use
|
||||
the source modules; packaged SDK and application delivery remain separate gates.
|
||||
|
||||
## What the checks actually prove
|
||||
|
||||
| Requirement | Authoritative entry and observed assertion |
|
||||
| --- | --- |
|
||||
| Explicit identity migration | `TestCanonicalModelMigrationAndIncrementalLifecycle`: GET does not migrate; retry returns the same identity map; implicit partitions become owning canvases; old snapshot writers are rejected. The real Node consumer also migrates and retries. |
|
||||
| Migration admission and late rollback | `TestCanonicalMigrationPermissionVersionAndRunGuards` checks viewer denial, stale source versions, queued/running work and stable negative receipts after conditions change. `TestCanonicalMigrationLateCancellationRollsBack` observes the actual identity-map INSERT blocked after canonical/material/subgraph writes, cancels it, then checks all related rows, original legacy JSON and version before retrying the original key successfully. |
|
||||
| Incremental writes and receipt identity | `TestCanonicalModelMigrationAndIncrementalLifecycle` verifies exact retries, unknown receipt semantics, logical ID coverage, participant versions, and zero writes/logical-ID consumption after a later batch condition fails. It checks canonical DB rows, not just HTTP status. |
|
||||
| Canonical concurrent writers | `TestCanonicalConcurrentMigrationAndOwnershipWrites` races four identical migrations, then four identical edits, requiring identical receipts and one version advance. Two different requests then compete to move alternate endpoints and their common edge: exactly one wins, the loser has a queryable terminal version-conflict receipt, both canvas versions advance once, and the full ownership graph remains valid. Reusing the winning key with changed bytes is rejected without changing the model. |
|
||||
| Dissolve without deleting members | The real consumer creates nested child/grandchild canvases, dissolves the child and loses its successful response. Reopen queries the original request without a second POST. Direct members and the nested entry move to the parent, the leaf retains its grandchild owner/geometry, and the original entry/edge remain. Undo/redo/undo restores the original bindings. This uses the existing atomic edit protocol and the real HTTP/DB service. |
|
||||
| Canonical frame geometry | `TestCanonicalFramesRetainRotationAcrossOwnerChanges` preserves nonzero frame rotation through owner changes, projection and an unrelated canonical AI patch, while rejecting 360 degrees. The legacy snapshot writer still rejects rotated frames; its restriction no longer rejects canonical coordinate conversion. |
|
||||
| Real frontend coordination | `TestHeadlessClientHTTPIntegration` runs `tests/headless/foundry.integration.mjs` against authenticated HTTP and PostgreSQL. It verifies lost document ACK recovery without duplicate POST, atomic partition edits, deep copy with a parent-owned incoming edge, partial import and old stream-tail coordination. |
|
||||
| Resources and authorization | The consumer verifies lost upload ACK, shared pending-image upload, original bytes, metadata, cross-project adoption and reopen. `TestOnboardingAndSharing` additionally revokes source access after warm reads, verifies no target model/version/binding changes, preserves the rejected receipt after permission restoration, and checks independent target access. |
|
||||
| AI execution identity and controls | `TestCanonicalAIAdmissionAndLifecycle` covers admission/query/retry, zero-effect rejection, old incarnation callbacks, cancellation, managed image output, owner relocation, archived admission routes and execution after permission revocation. |
|
||||
| Actual frontend RunPort | `tests/headless/foundry-ai.integration.mjs` exercises `createFoundryRuns`, the HTTP transport and ProjectEngine: successful admission response loss/reopen/query, queued cancellation response loss, generation-time content conflict, explicit apply response loss plus undo/reopen/redo, generated image metadata/original bytes/preview, and copy without a new run or upload. |
|
||||
| Host identity migration coordination | `canvas-migration.ts` now drives the real consumer's initial migration: the server commits, the response is lost, a new coordinator reads the stored body/key and queries the original receipt without a second POST. A later intentional raw POST still checks backend idempotent retry. The coordinator's drain and lock ports are fixtures here; this does not prove the old editor's browser handoff gate is connected. |
|
||||
| Browser legacy handoff | `renderer.integration.mjs --migration-only` exercises the actual GraphPage, legacy editor, upgrade panel and Next proxy with native Chrome input, localStorage and Web Locks. Dirty old PATCH ACK precedes migration; another editable view excludes migration; committed response loss survives reload without a second migration POST; uncommitted response loss reuses the original body/key, with terminal rejection followed by explicit fresh preparation; a different cached draft must be explicitly restored before migration. A draft modal cannot block receipt recovery; it becomes actionable after terminal rejection. The upstream is a protocol fixture, not the real Foundry service. |
|
||||
|
||||
The CI #1174 consumer report contains `status: "ok"`, 72 HTTP transport calls,
|
||||
all 14 prior document/resource flags true, plus `runtimeAI`, `aiLostAdmission`,
|
||||
`aiCancelReconciled`, `aiConflictApplyUndo`, `aiManagedImage`, `aiCopyNoRun` true.
|
||||
`aiExecutionCount` is exactly 3: text, retained conflict output, image. The queued
|
||||
cancelled run never calls generation. Preview fetching is a separate authenticated
|
||||
host fetch and is not included in the 72 transport calls.
|
||||
CI #1178 repeated the same pinned consumer successfully with 71 transport calls
|
||||
and the same flags/execution count. Timing-dependent poll counts are observations,
|
||||
not an exact total-call contract; admission/action/upload duplication has separate
|
||||
explicit assertions.
|
||||
CI #1180 additionally reports `migrationCoordinator` and
|
||||
`migrationLostAckRecovered` true, with 76 transport calls and the same document,
|
||||
resource and AI results. The local coordinator suite covers 11 cases, including
|
||||
quota/no-write behavior, uncommitted-request recovery, invalid receipt retention,
|
||||
identity/abort fences and terminal rejection followed by explicit preparation.
|
||||
|
||||
## Issues exposed by the real consumer
|
||||
|
||||
1. The standalone AI client omitted the v2 Accept header. The Next proxy had
|
||||
supplied it, hiding the omission in application-only testing. `canvas-v2`
|
||||
negotiation now belongs to the AI adapter and shared transport; regression
|
||||
tests cover all three writes and receipt queries with original body/key/version.
|
||||
2. Asset display URLs assumed the application's relative preview route. The
|
||||
asset adapter now accepts a host `displayURL(id)` callback. Canonical source
|
||||
identity and authority remain separate; preview bytes are not used as original
|
||||
file digest evidence. Authentication stays in the host fetch/transport.
|
||||
3. CI #1184/#1185 rejected the nested creation before reaching dissolution:
|
||||
canonical validation had copied the legacy frame's zero-rotation rule. A
|
||||
rotated owner can require a frame to retain nonzero rotation after conversion.
|
||||
Foundry `506f778` removes that canonical-only restriction; existing finite/range
|
||||
checks and legacy normalization remain. The consumer now checks the creation
|
||||
receipt before reading descendants, so a rejection reports its actual state.
|
||||
|
||||
The test harness also needed working controlled timers, a cancelled run's final
|
||||
event drain before the next admission, and actual copy-source file persistence.
|
||||
The adoption stage still asserts zero project-local file writes. The subsequent
|
||||
copy stage permits source checkpoints but rejects bitmap file duplication and
|
||||
asserts no extra upload or generation. Those fixes do not bypass busy, cursor,
|
||||
durability or resource-sharing checks.
|
||||
|
||||
## Final-audit recovery cuts
|
||||
|
||||
`crash-windows.test.mjs` selects actual immutable cuts produced by the engine:
|
||||
before local durability, durable pending intent, frozen request, dispatch-marked
|
||||
before server commit, dispatch-marked after server commit, and durable receipt/base.
|
||||
Each independent recovery pairs that cut with its corresponding server/receipt
|
||||
state, then checks document/history/logical IDs, exact frozen request bytes,
|
||||
POST/query counts and final durability. Frozen recovery may conservatively query
|
||||
before its exact retry; a missing receipt never creates a new identity. These are
|
||||
memory StoragePort/protocol fixtures, not physical browser-process termination.
|
||||
Actual IndexedDB atomic rollback, fencing/checksum/journal-gap and reopening
|
||||
evidence remains the separate browser suite; this is not every possible cross-product.
|
||||
|
||||
`engine.test.mjs` additionally commits a create, loses its ACK, deletes locally,
|
||||
reopens, queries the original create and finally sends the dependent deletion.
|
||||
The server has no residual element; both original history units remain and the
|
||||
creation is not submitted twice. The focused engine/crash suite passes 36 tests;
|
||||
the full Node suite passes 821. `resources.test.mjs` also verifies replacement of
|
||||
an existing image while the new upload is gated: the old server reference stays
|
||||
valid, undo prevents a late replacement, and redo/reopen reuse the two uploaded
|
||||
resources without a new upload or element incarnation. Its 32 checks pass.
|
||||
The subsequent T01–T36 audit is recorded in `FINAL_ACCEPTANCE.md`; the final
|
||||
frontend run passes 822 Node tests, with both 51-check browser bundlers, 10 hooks,
|
||||
57 browser-storage checks, 6 proxy checks, both five-scenario migration entries
|
||||
and the 270-file unpacked package passing.
|
||||
|
||||
## Boundaries and remaining audit
|
||||
|
||||
The recovery store is an in-memory durable-batch fixture, and response loss is
|
||||
injected after successful HTTP responses. Real IndexedDB/Web Locks/browser
|
||||
acceptance is recorded separately in `STEP4_ACCEPTANCE.md`; this service run does
|
||||
not combine every browser crash window with every server operation.
|
||||
The migration coordinator uses a string-storage port before a canonical engine
|
||||
exists. `browser-canvas-migration.ts` supplies localStorage, identity fences and
|
||||
workspace-scoped native Web Locks. Every editable legacy view holds a shared
|
||||
lease; upgrade first freezes and drains its writer, releases that lease, then
|
||||
takes the exclusive lease and prepares again before creating a request. Pending
|
||||
records block affected legacy views across reload. Unknown results retain the
|
||||
original request; only a verified terminal rejection permits fresh preparation.
|
||||
Upload/import/member-task and draft-recovery gates preserve the old editor's
|
||||
content rather than converting in-memory work silently. A differing cached draft
|
||||
is surfaced for explicit recovery, and the current draft is backed up before
|
||||
waiting for its PATCH ACK. The account fence also covers old snapshot requests.
|
||||
|
||||
The browser fixture above proves the connected UI path, including actual native
|
||||
locking, and the separate real-service consumer proves server coordination.
|
||||
They do not combine a real browser and real PostgreSQL in one scenario. Full
|
||||
webpack and Turbopack application regressions each passed 51 checks; the migration
|
||||
entry adds five scenarios and three lock-lifetime unit tests. Unknown
|
||||
receipt/error states remain read-only with an export action. Unsupported Web
|
||||
Locks prevents upgrading, while existing legacy editing remains available when
|
||||
there is no pending migration. The stage 6 audit also inspected source/package
|
||||
exports, peer dependencies and documentation targets, then passed the actual
|
||||
267-file unpacked package's Node/types, framework-peer, compiled browser storage
|
||||
and Worker checks. Together with CI #1182 and both browser bundlers this closes
|
||||
stage 6. The final requirement matrix is recorded separately; the two new scheduler
|
||||
clock-shift checks cover backward/forward wall time, dependency/unknown claims,
|
||||
stale timer callbacks and remaining wait calculation after recovery.
|
||||
|
||||
`canvas_headless_worker_integration_test.go` supplies deterministic generation
|
||||
output over the actual worker HTTP protocol. Admission, claim, events, canonical
|
||||
writes, image storage and action receipts use the real service/DB. Private worker
|
||||
controls are registered only in the test assembly. Production's background queue
|
||||
loop, external model availability, provider billing and real CLI authentication
|
||||
are not proved by that fixture. `TestCanvasAIRealWorker` remains opt-in and skipped.
|
||||
|
||||
The CI log includes an early skip of `TestHeadlessClientHTTPIntegration` without
|
||||
its opt-in environment and a later successful execution in the explicit Node/DB
|
||||
phase. Those are different invocations. The latest successful log has 158 distinct PASS names
|
||||
including subtests; that count is not 154 independent end-to-end scenarios.
|
||||
|
||||
The service audit covers the original stage requirements: target incremental and
|
||||
idempotent protocols, frontend/runtime/resource adapters, explicit legacy
|
||||
compatibility, identity mapping, source-version/permission/run admission barriers,
|
||||
and rollback after writes have begun. Migration/delivery now has the separate
|
||||
browser/package evidence above; the final requirement matrix now has its separate per-row audit. A green
|
||||
feature-branch run does not merge or deploy
|
||||
either repository. [Foundry PR #19](https://gitea.entroia.com/Zayden-Jung/foundry-core/pulls/19)
|
||||
is open, ready for review (`draft: false`, `mergeable: true`, checked 2026-11-11),
|
||||
and waits for the user to merge manually.
|
||||
@@ -0,0 +1,68 @@
|
||||
# Step 4:渐进功能与呈现验收
|
||||
|
||||
状态:第4步原定功能、合作调度及规模输入验收已通过;整体进度 4/7。长任务与非抢占边界见下文,不宣称生产实时性能保证。此文件随实现提交;完整目标仍包含后续真实服务、迁移和集成验收。
|
||||
|
||||
## 范围与直接证据
|
||||
|
||||
| 原要求 | 代码与验证入口 | 本次结果 |
|
||||
| --- | --- | --- |
|
||||
| 导入、深复制逐步加入并允许编辑 | `imports.ts`、`canvas-runtime/duplicate.ts`;`imports.test.mjs`、`imports-engine.test.mjs`、`import-document-stream.test.mjs`、`duplicate*.test.mjs`;实际页面文件选择/工具栏 | Node 与页面检查通过。SHA 完成前可显示和编辑;完整校验完成且对应 Root 持久化后才发送。单项失败保留已加入内容并报告 partial。复制保留原图、新建身份,父层新增连线,复用合法资源且不自动启动 run。 |
|
||||
| loader 旧捕获与后续编辑/ACK 协调 | `loading.ts`、`engine.ts`;`loading-engine.test.mjs`、浏览器存储和实际页面 | 当前 loading v1–v6 路径、未读 tail、undo/redo、重开、取消及源校验检查通过。旧数据不覆盖后来的编辑/ACK;已声明的 partial 状态保留。 |
|
||||
| 公共 hooks 和局部订阅 | `canvas-bindings/react.ts`、`canvas-bindings`;`react-hooks-fixture.tsx`、`renderer.integration.mjs --hooks-only` | 同一最终源码的 StrictMode 入口 10 项通过;包括 owner 切换、engine close、资源句柄与 unloaded/absent。普通字段与 ACK 的无关投影复用由实际页面计数验证。 |
|
||||
| 输入、呈现与合作调度 | frame queue、scene/paint projection、retained projection;`frame-queue.test.mjs`、`retained-projection.test.mjs`、实际页面与生产规模入口 | 页面验证拖动最终位置、IME、Portal、detach、未知焦点与订阅取消。生产万元素早/中/尾段输入与副本捕获隔离通过;性能仍有限制,见下文。 |
|
||||
|
||||
所有 `*.test.mjs` 均位于 `tests/headless/`,源码位于 `src/lib/` 或 `src/components/canvas/`。每个测试文件中的断言决定其覆盖范围;通过总数不代表 Draft 的 T01–T36 或整个项目已经完成。
|
||||
|
||||
## 编码与发布边界
|
||||
|
||||
1. `writePrefix` 是有序单 writer,逐个保存 Root,异步编码不会跳过中间 Root。
|
||||
2. 外部对象、getter、custom `toJSON` 和浅冻结值在第一次让出前按原 codec 捕获;私有容器只证明成员固定,内部未知值仍需捕获。
|
||||
3. core 的 `detachJsonData` 为元素和复制规划 checkpoint 提供 JSON 脱离、深冻结事实,可以延后逐字段编码。需要标准 Object/Array 原型且没有继承的 `toJSON`;每次未知值捕获后重新检查环境。外部回调不能改变已经捕获的前项,也不能让后项错误沿用旧环境。
|
||||
4. 标量归一化、UTF-8 计数、history/import/pending 容器与首次元素遍历共用软预算。已完成的深不可变编码可复用,但不能省略 unsafe occurrence 的求值。
|
||||
5. 编码、调度或存储失败保留待存 Root;持久化没有确认,不解除网络发送屏障。旧 Root 与编码期间的新编辑保持各自内容。
|
||||
6. journal 比较先判断子值是否相同,再为变化项分配路径,避免为共享的整个前缀创建临时数组。patch 顺序与字节语义不变。
|
||||
|
||||
`json-data.test.mjs` 验证事实不能从外部冻结对象或结构克隆伪造;`duplicate-checkpoint.test.mjs` 验证首次 checkpoint 的真实字段编码在 yield 间发生,后续 planner.step 不改变已捕获值;`imports-engine.test.mjs` 验证真实 preparation/source envelope、字节和重开映射。ready plan 已先持久化时允许缓存复用,不要求空 yield。`recovery-encoding.test.mjs` 验证首次归一化、继承行为、外部回调、Proxy trap 次数、UTF-8、冷/热深度边界;`engine.test.mjs` 以 17000 元素验证暂停编码期间编辑、逐 Root 落盘和发送屏障。`journal-delta.test.mjs` 验证精确重放及属性顺序;浏览器检查验证真实 IndexedDB 重开。
|
||||
|
||||
## 可复现命令
|
||||
|
||||
```sh
|
||||
node --test tests/headless/*.test.mjs
|
||||
npm run typecheck -- --incremental false
|
||||
node tests/headless/renderer.integration.mjs
|
||||
node tests/headless/renderer.integration.mjs --turbopack
|
||||
node tests/headless/renderer.integration.mjs --hooks-only
|
||||
node tests/headless/browser-storage.mjs
|
||||
node tests/headless/package.integration.mjs
|
||||
HEADLESS_PERFORMANCE_COUNT=10000 HEADLESS_PERFORMANCE_EDIT_DURING_COPY=1 HEADLESS_PERFORMANCE_REPORT=/private/tmp/headless-checkpoint-production-10000.json node tests/headless/renderer.integration.mjs --performance --production --turbopack
|
||||
```
|
||||
|
||||
性能入口须在其它构建/测试结束后单独执行;功能检查可并行。这里验证真实 Chrome、IndexedDB、Web Locks 和实际前端,但 HTTP 为隔离 fixture,不能据此声称已完成真实 Foundry 集成。
|
||||
|
||||
|
||||
## 本次运行结果(2026-11-11)
|
||||
|
||||
- Node:783 项通过;全量前端 TypeScript 检查通过。
|
||||
- 实际页面:webpack 51 项、Turbopack 51 项通过;公共 hooks 10 项通过。
|
||||
- 浏览器存储:57 组通过;SDK:265 个打包文件,类型、独立消费和编译后的浏览器/Worker 检查通过。
|
||||
- Chrome 退出后的 stdio 清理保留。此次五组并行检查中,webpack 有一次原生双击未打开恢复编辑器;同源码单独复跑51项通过。随后测试增加同步完成、两次rAF和实际可命中点击点的前置条件,保留原生事件/编辑焦点断言;两种构建器各51项均通过。未据此推定产品逻辑有错或仅是CPU争用。
|
||||
- 生产规模环境:本机 arm64、macOS 15.6、Node 23.10.0、Chrome 157.0.8103.39,Next production Turbopack;无并行构建/测试、无计时代码插桩。
|
||||
- 10000-child 展开分区深复制:首副本呈现 784.1ms、本地完成帧 27949.7ms、加入并同步 28169.9ms;帧间隔 P95 33.4ms/max383.4ms;21 次 longtask,max386ms/total1663ms。
|
||||
- early 五次 input→两次 rAF:69.8/57.9/34.3/29.5/15.6ms;middle(5279 项)45.3ms、tail(9093 项)73.5ms。全部发生于复制活跃期间,DOM/focus 保留;中尾 CDP 往返约40.9/26.0ms。它们不含精确的派发前排队延迟。
|
||||
- 服务端最终 20005 元素/3 canvas;原/新父层边、原文后续输入、副本旧快照、pan 后副本挂载通过。样本呈现时挂载35节点,全过程最大137,pan 后28;屏幕外 IME/Portal 各保留1,独立应用面板不额外保活,runStartsDuringScenario=0。
|
||||
|
||||
原始报告 `/private/tmp/headless-checkpoint-production-10000.json` 和同名 PNG 为本次本机观察;命令与断言已保存在仓库,可独立复现。单次运行有不同 adaptive 批次、native/GC 与系统调度负载,不能以此与前轮36.26秒比较而宣称确定提速,更不能说所有停顿已解决,或未经归因把386ms全部称为GC。
|
||||
|
||||
## 收口审查
|
||||
|
||||
前轮唯一明确的编码缺口已经接通:`createDuplicatePlanner().checkpoint()` 与元素复用同一 JSON 脱离工厂,runtime 自建 job/source/preparation envelope 保留 shape 来源;分配列表、ready ID表和根列表不再丢失这项边界。首次 preparation 确实分段,最终 ready plan 先经过持久屏障后复用已有编码,重开后映射保持。
|
||||
|
||||
第4步验收按开工时的四类职责和6ms合作调度软目标完成,不把它改成“每次GC/native/原子发布均小于6ms”。此结论覆盖原定实现与列出的复现入口;它不代表无卡顿、低端设备SLA、任意selector皆O(1)或T01–T36全部完成。首项可见、输入保留、两侧快照正确、Root按序落盘及发送屏障均有直接断言;最终长任务观测作为已知限制保留,不假称已经消除。
|
||||
|
||||
## 调度限制与后续分工
|
||||
|
||||
6ms 是合作调度软目标。捕获成员引用、未知值的同步 JSON 语义、单个字符串/native 操作、原子模型发布和 GC 仍可能超过一个时间片。未知调用不能假冒不可变值以换取响应速度;也不能把普通循环停顿一概称为 GC。测试测量 input 到两次 rAF;CDP 往返另含传输和驱动时间,均不是精确的输入派发前排队延迟。
|
||||
|
||||
未登记的第三方 Portal/未知输入 owner 保留明确的兼容回退;任意全局 Root selector、结构变化和未知 ACK 不保证 O(1)。renderer 不保存第二份业务文档,不为裁剪丢弃完整模型。
|
||||
|
||||
下一阶段由服务/集成工作负责真实 Foundry 协议、权限、迁移、冻结请求幂等与恢复联调,并沿用现有远程 CI。分支推送不等于主线合并或部署,仍需单独报告。
|
||||
@@ -0,0 +1,68 @@
|
||||
/** A second host: no DOM, React, Next, React Flow or network service required.
|
||||
* Storage and protocol-2 transport below are in-memory examples, not a disk or
|
||||
* production server implementation. Replace them with your host's real ports.
|
||||
*/
|
||||
import assert from 'node:assert/strict';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { createDocument, createRegistry, builtinComponents, defineComponent, editable, movable, resizable, rectangleGeometry, applyChanges, validateDocument } from '@endoleap/headless-canvas/core';
|
||||
import { openProjectEngine } from '@endoleap/headless-canvas/runtime';
|
||||
import {projectCanvas,createRendererCatalog,builtinRendererDefinitions,createGeometryProjectionCache,hitProjectedCanvas} from '@endoleap/headless-canvas/bindings';
|
||||
import { createFoundryPersistence, loadFoundryModel } from '@endoleap/headless-canvas/service';
|
||||
|
||||
const scope={accountId:'10000000-0000-4000-8000-000000000001',workspaceId:'10000000-0000-4000-8000-000000000002',projectId:'10000000-0000-4000-8000-000000000003'};
|
||||
const registry=createRegistry([...builtinComponents(),defineComponent({id:'example.counter',dataVersion:1,structure:'node',validate:data=>typeof data.title==='string'&&Number.isSafeInteger(data.count)&&data.count>=0,features:[editable(),movable(),resizable()],geometry:rectangleGeometry,resourceFields:[],copy:{references:[],omit:[]}})]);
|
||||
const original=createDocument(scope.projectId,[{id:scope.projectId,parent:null}],[]);
|
||||
let image=null,leader,fence=0,server=original,version=1;
|
||||
const files=new Map(),requests=new Map();
|
||||
const check=handle=>{assert.strictEqual(handle,leader);assert.equal(handle.active,true);};
|
||||
const storage={
|
||||
async claim(requested){assert.equal(leader,undefined);assert.deepEqual(requested,scope);let active=true;const handle={scope:requested,fence:++fence,get active(){return active;},async release(){active=false;if(leader===handle)leader=undefined;}};leader=handle;return handle;},
|
||||
async recover(handle){check(handle);return structuredClone(image);},
|
||||
async commit(handle,batch){
|
||||
check(handle);const cut=structuredClone(batch.image),nextFiles=new Map(files);
|
||||
for(const file of batch.files??[]){assert.equal(nextFiles.has(file.id),false);nextFiles.set(file.id,file.bytes);}
|
||||
for(const id of batch.deleteFiles??[])nextFiles.delete(id);
|
||||
// Prepare before publishing. This is atomic in this process, not crash-durable storage.
|
||||
image=cut;files.clear();for(const entry of nextFiles)files.set(...entry);return{durableRootRevision:cut.rootRevision};
|
||||
},
|
||||
async readFile(handle,id){check(handle);return files.get(id);},close(){assert.equal(leader,undefined);},
|
||||
};
|
||||
const model=()=>({documentFormatVersion:1,projectId:scope.projectId,complete:true,canvases:[...server.canvases.values()],elements:[...server.elements.values()],tombstones:Object.fromEntries(server.tombstones),versions:{[scope.projectId]:version},removedCanvasIds:[]});
|
||||
const transport=async input=>{
|
||||
input.signal?.throwIfAborted();
|
||||
if(input.method==='GET'){
|
||||
if(input.path.endsWith('/canvas/model'))return{status:200,body:{model:model()}};
|
||||
const id=input.path.split('/').at(-1),known=requests.get(id);
|
||||
return known?{status:200,body:known.receipt}:{status:404,body:{protocol_version:2,request_id:id,status:'unknown'}};
|
||||
}
|
||||
const body=JSON.parse(input.body),known=requests.get(input.requestId);
|
||||
assert.equal(body.protocol_version,2);assert.equal(body.request_id,input.requestId);
|
||||
if(known){assert.equal(known.body,input.body);return{status:200,body:known.receipt};}
|
||||
assert.deepEqual(body.expected_versions,{[scope.projectId]:version});
|
||||
server=applyChanges(server,body.operations.flatMap(operation=>operation.changes));validateDocument(server,registry);version++;
|
||||
const receipt={protocol_version:2,request_id:input.requestId,status:'applied',operation_ids:body.operations.map(operation=>operation.id),model:{...model(),complete:false}};
|
||||
requests.set(input.requestId,{body:input.body,receipt});return{status:200,body:receipt};
|
||||
};
|
||||
const persistence=createFoundryPersistence({workspaceId:scope.workspaceId,rootId:scope.projectId,registry,transport});
|
||||
const open=()=>openProjectEngine({scope,registry,document:original,versions:new Map([[scope.projectId,1]]),storage,persistence,createId:randomUUID});
|
||||
let engine=await open();
|
||||
try{
|
||||
const target={id:randomUUID(),incarnation:1};
|
||||
const element={...target,ownerCanvasId:scope.projectId,componentId:'example.counter',dataVersion:1,structure:'node',data:{title:'Portable counter',count:1},geometry:{x:0,y:0,width:160,height:80,rotation:0}};
|
||||
assert.equal(engine.editor.dispatch({type:'element.create',element}).status,'applied');await engine.settle();
|
||||
const before=engine.getSnapshot();
|
||||
assert.equal(engine.editor.transaction(tx=>{tx.dispatch({type:'element.geometry',target,patch:{x:32}});tx.dispatch({type:'element.data',target,patch:{count:-1}});}).status,'rejected');assert.strictEqual(engine.getSnapshot(),before);
|
||||
assert.equal(engine.editor.transaction(tx=>{tx.dispatch({type:'element.geometry',target,patch:{x:32}});tx.dispatch({type:'element.data',target,patch:{count:2}});},{label:'Move and increment'}).status,'applied');await engine.settle();
|
||||
assert.equal(engine.editor.history.undo().status,'applied');await engine.settle();assert.equal(server.elements.get(target.id).data.count,1);
|
||||
assert.equal(engine.editor.history.redo().status,'applied');await engine.settle();assert.equal(server.elements.get(target.id).data.count,2);
|
||||
engine.sessions.attach('headless-view');const writes=requests.size;
|
||||
assert.equal(engine.sessions.begin('headless-view',[target]).enabled,true);engine.sessions.update('headless-view',target,{x:90});assert.equal(engine.sessions.effectiveGeometry(target.id).x,90);engine.sessions.cancel('example-cancel');await engine.settle();assert.equal(requests.size,writes);assert.equal(engine.getSnapshot().document.elements.get(target.id).geometry.x,32);
|
||||
const copyId=await engine.imports.duplicate([target],{worldOffset:{x:24,y:24}});await engine.settle();assert.equal(engine.imports.getJobs().get(copyId).status,'completed',JSON.stringify(engine.imports.getJobs().get(copyId)));assert.equal(engine.getSnapshot().document.elements.size,2);assert.equal(server.elements.size,2);
|
||||
const loaded=await loadFoundryModel(transport,scope.workspaceId,scope.projectId,registry);assert.equal(loaded.status,'ready');assert.equal(loaded.document.elements.size,2);
|
||||
const snapshot=engine.getSnapshot(),undoDepth=snapshot.history.undo.length;await engine.close();engine=await open();await engine.settle();
|
||||
assert.equal(engine.getSnapshot().document.elements.size,2);assert.equal(engine.getSnapshot().document.elements.get(target.id).data.count,2);assert.equal(engine.getSnapshot().history.undo.length,undoDepth);assert.equal(engine.sessions.getSnapshot().views.size,0);assert.ok([...engine.getSnapshot().runtime.ledger.operations.values()].every(record=>record.phase==='resolved'));
|
||||
const current=engine.getSnapshot(),queries=engine.editor.getQueries(),renderers=createRendererCatalog([...builtinRendererDefinitions,{componentId:'example.counter',dataVersion:1,structure:'node',rendererId:'counter',projection:'record-v1'}]);
|
||||
const projection=projectCanvas(current.document,scope.projectId,registry,new Set(),()=>undefined,null,renderers,{queries,geometry:createGeometryProjectionCache()});assert.equal(projection.nodes.length,2);assert.ok(projection.nodes.every(node=>node.type==='counter'&&node.data.__headlessRecord));
|
||||
const hit=hitProjectedCanvas(projection,queries,registry,'portable-view',{space:'scene',point:{x:40,y:10},zoom:1});assert.equal(hit.complete,true);assert.ok(hit.hits.some(hit=>hit.target.id===target.id));
|
||||
console.log(JSON.stringify({status:'ok',portableBindings:true,elements:2,count:2,undoDepth,reopened:true,transientViews:0,protocol:2,requests:requests.size,sourceFiles:files.size}));
|
||||
}finally{await engine.close();storage.close();}
|
||||
@@ -0,0 +1,67 @@
|
||||
{
|
||||
"name": "@endoleap/headless-canvas",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"engines": {
|
||||
"node": ">=22"
|
||||
},
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./modules/canvas-core/index.d.ts",
|
||||
"import": "./modules/canvas-core/index.js"
|
||||
},
|
||||
"./core": {
|
||||
"types": "./modules/canvas-core/index.d.ts",
|
||||
"import": "./modules/canvas-core/index.js"
|
||||
},
|
||||
"./runtime": {
|
||||
"types": "./modules/canvas-runtime/index.d.ts",
|
||||
"import": "./modules/canvas-runtime/index.js"
|
||||
},
|
||||
"./browser": {
|
||||
"types": "./modules/canvas-runtime/browser.d.ts",
|
||||
"import": "./modules/canvas-runtime/browser.js"
|
||||
},
|
||||
"./service": {
|
||||
"types": "./modules/canvas-service/index.d.ts",
|
||||
"import": "./modules/canvas-service/index.js"
|
||||
},
|
||||
"./bindings": {
|
||||
"types": "./modules/canvas-bindings/index.d.ts",
|
||||
"import": "./modules/canvas-bindings/index.js"
|
||||
},
|
||||
"./react-flow": {
|
||||
"types": "./modules/canvas-bindings/react-flow.d.ts",
|
||||
"import": "./modules/canvas-bindings/react-flow.js"
|
||||
},
|
||||
"./react": {
|
||||
"types": "./modules/canvas-bindings/react.d.ts",
|
||||
"import": "./modules/canvas-bindings/react.js"
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
"modules",
|
||||
"consumer.mjs",
|
||||
"README.md",
|
||||
"STEP4_ACCEPTANCE.md",
|
||||
"SERVICE_ACCEPTANCE.md",
|
||||
"FINAL_ACCEPTANCE.md"
|
||||
],
|
||||
"peerDependencies": {
|
||||
"react": ">=19.3.0 <20",
|
||||
"react-dom": ">=19.3.0 <20",
|
||||
"@xyflow/react": ">=12.12.1 <13"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"react": {
|
||||
"optional": true
|
||||
},
|
||||
"react-dom": {
|
||||
"optional": true
|
||||
},
|
||||
"@xyflow/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,21 @@
|
||||
import type { NextConfig } from "next";
|
||||
const config: NextConfig = {
|
||||
reactStrictMode: true, poweredByHeader: false,
|
||||
webpack(config) {
|
||||
config.module.rules.push({
|
||||
test: /[\\/]@xyflow[\\/]react[\\/]dist[\\/]esm[\\/]index\.(?:mjs|js)$/,
|
||||
use: [{ loader: require.resolve("./scripts/loaders/xyflow-retained.cjs") }],
|
||||
});
|
||||
return config;
|
||||
},
|
||||
turbopack: {
|
||||
rules: {
|
||||
"*": {
|
||||
condition: { path: /[\\/]@xyflow[\\/]react[\\/]dist[\\/]esm[\\/]index\.(?:mjs|js)$/ },
|
||||
loaders: ["./scripts/loaders/xyflow-retained.cjs"],
|
||||
},
|
||||
},
|
||||
},
|
||||
async headers() {
|
||||
return [{
|
||||
source: "/canvas-icons/:version/:asset([a-z0-9-]+-[a-f0-9]{12}\\.json)",
|
||||
|
||||
+8
-1
@@ -6,7 +6,14 @@
|
||||
"dev": "next dev --hostname localhost --port 17321",
|
||||
"build": "next build",
|
||||
"start": "next start --hostname localhost --port 17321",
|
||||
"typecheck": "tsc --noEmit"
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test:headless": "node --test tests/headless/*.test.mjs",
|
||||
"test:headless:browser": "node tests/headless/browser-storage.mjs",
|
||||
"test:headless:proxy": "node tests/headless/proxy.integration.mjs",
|
||||
"test:headless:renderer": "node tests/headless/renderer.integration.mjs",
|
||||
"test:headless:performance": "node tests/headless/renderer.integration.mjs --performance",
|
||||
"build:headless": "node scripts/build-headless.mjs",
|
||||
"test:headless:package": "node tests/headless/package.integration.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@radix-ui/react-dialog": "1.3.1",
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { cp, readdir, readFile, rm, writeFile } from 'node:fs/promises';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { join, resolve } from 'node:path';
|
||||
|
||||
const root=resolve(fileURLToPath(new URL('..',import.meta.url))),output=join(root,'.headless-dist');
|
||||
await rm(output,{recursive:true,force:true});
|
||||
execFileSync(process.execPath,[join(root,'node_modules/typescript/bin/tsc'),'-p',join(root,'tsconfig.headless.json')],{cwd:root,stdio:'inherit'});
|
||||
async function declarations(directory){
|
||||
for(const entry of await readdir(directory,{withFileTypes:true})){
|
||||
const path=join(directory,entry.name);
|
||||
if(entry.isDirectory())await declarations(path);
|
||||
else if(entry.name.endsWith('.d.ts')){
|
||||
const source=await readFile(path,'utf8');
|
||||
// Native TS rewrites runtime imports, but leaves source suffixes in declarations.
|
||||
// Only module specifiers change; data/schema string literals remain intact.
|
||||
const next=source.replace(/((?:from\s+|import\s*\()\s*['"])(\.\.?\/[^'"]+)\.ts(['"])/g,'$1$2.js$3');
|
||||
await writeFile(path,next);
|
||||
}
|
||||
}
|
||||
}
|
||||
await declarations(join(output,'modules'));
|
||||
for(const [module,worker]of [['browser-digests','digest.worker'],['browser-images','image.worker']]){
|
||||
const path=join(output,'modules/canvas-runtime',`${module}.js`),source=await readFile(path,'utf8'),before=`./${worker}.ts`;
|
||||
if(source.split(before).length!==2)throw Error(`Unexpected Worker URL in ${module}`);
|
||||
await readFile(join(output,'modules/canvas-runtime',`${worker}.js`));
|
||||
await writeFile(path,source.replace(before,`./${worker}.js`));
|
||||
}
|
||||
for(const name of ['package.json','consumer.mjs','README.md','STEP4_ACCEPTANCE.md','SERVICE_ACCEPTANCE.md','FINAL_ACCEPTANCE.md'])await cp(join(root,'headless',name),join(output,name));
|
||||
console.log('Built .headless-dist: ESM, declarations, browser adapters and Worker modules');
|
||||
@@ -0,0 +1,23 @@
|
||||
// App-only extension for the pinned @xyflow/react 12.12.1 ESM renderer. This
|
||||
// transforms compiler input; shared node_modules and the headless SDK are untouched.
|
||||
const version = require('@xyflow/react/package.json').version;
|
||||
module.exports = function retainVisibility(source) {
|
||||
if (version !== '12.12.1') throw new Error('Review xyflow retained visibility before upgrading @xyflow/react');
|
||||
const nodes = 'getNodesInside(s.nodeLookup, { x: 0, y: 0, width: s.width, height: s.height }, s.transform, true).map((node) => node.id)';
|
||||
const edges = 'isEdgeVisible({\n sourceNode,';
|
||||
const selection = 'const changeSelector = (s) => !!s.onSelectionChangeHandlers;';
|
||||
const close = 'transform: s.transform,\n })) {';
|
||||
if (source.includes('__endoleapRetain') || source.split(nodes).length !== 2 || source.split(edges).length !== 2 || source.split(close).length !== 2 || source.split(selection).length !== 2) {
|
||||
throw new Error('Unsupported xyflow visibility implementation; retained editors must not silently lose virtualization');
|
||||
}
|
||||
return source
|
||||
.replace(nodes, `Array.from(new Set([
|
||||
...${nodes},
|
||||
...s.nodes.filter((node) => node.__endoleapRetain === true && !node.hidden).map((node) => node.id),
|
||||
]))`)
|
||||
.replace(edges, '(edge.__endoleapRetain === true || isEdgeVisible({\n sourceNode,')
|
||||
.replace(close, 'transform: s.transform,\n }))) {')
|
||||
// An empty handler array is truthy; do not mount the full-graph selector
|
||||
// when neither the prop nor a useOnSelectionChange subscriber needs it.
|
||||
.replace(selection, 'const changeSelector = (s) => !!s.onSelectionChangeHandlers?.length;');
|
||||
};
|
||||
@@ -4,8 +4,8 @@ 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 canvasRoute = /^workspaces\/[a-zA-Z0-9_-]+\/materials\/[a-zA-Z0-9_-]+\/canvas(?:\/(?:model(?:\/(?:migrate|operations(?:\/[a-zA-Z0-9_-]+)?))?|operations(?:\/[a-zA-Z0-9_-]+)?|connections|capabilities|subgraphs(?:\/(?:migrate|[a-zA-Z0-9_-]+\/(?:members|dissolve)))?|transactions\/[a-zA-Z0-9_-]+\/undo|ai\/(?:models|operations\/[a-zA-Z0-9_-]+|runs(?:\/[a-zA-Z0-9_-]+(?:\/(?:cancel|apply))?)?)))?$/;
|
||||
const assetRoute = /^workspaces\/[a-zA-Z0-9_-]+\/assets(?:\/(?:(?:operations|metadata)\/[a-zA-Z0-9_-]+|[a-zA-Z0-9_-]+))?$/;
|
||||
const bodyLimit = 1024 * 1024;
|
||||
|
||||
// Route Handlers do not have the Pages Router bodyParser size limit.
|
||||
@@ -53,12 +53,16 @@ async function handler(
|
||||
}
|
||||
const path = (await params).path.join("/");
|
||||
if (!allowed.test(path) && !canvasRoute.test(path) && !assetRoute.test(path)) return json({ error: "not_found" }, 404);
|
||||
const asset = assetRoute.test(path);
|
||||
const assetReceipt = assetRoute.test(path) && /\/assets\/operations\//.test(path);
|
||||
const assetMetadata = assetRoute.test(path) && /\/assets\/metadata\//.test(path);
|
||||
const asset = assetRoute.test(path) && !assetReceipt && !assetMetadata;
|
||||
const canvas = canvasRoute.test(path);
|
||||
const canonical = canvas && /\/canvas\/model(?:\/|$)/.test(path);
|
||||
const frozenAI = canvas && /\/canvas\/ai\/runs(?:\/|$)/.test(path);
|
||||
let requestBody: unknown;
|
||||
if (req.method !== "GET") {
|
||||
try {
|
||||
requestBody = await readBody(req, asset ? 21 * 1024 * 1024 : path.endsWith("/canvas") ? 5 * 1024 * 1024 : bodyLimit, asset);
|
||||
requestBody = await readBody(req, asset ? 21 * 1024 * 1024 : canonical || path.endsWith("/canvas") || path.endsWith("/canvas/operations") ? 5 * 1024 * 1024 : bodyLimit, asset || canonical || frozenAI);
|
||||
} catch (error) {
|
||||
const tooLarge = error instanceof Error && error.message === "body_too_large";
|
||||
return json({ error: tooLarge ? "body_too_large" : "invalid_json" }, tooLarge ? 413 : 400);
|
||||
@@ -69,9 +73,11 @@ async function handler(
|
||||
const project = process.env.NEXT_PUBLIC_FOUNDRY_PROJECT || "endoleap";
|
||||
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 modelStream = canonical && path.endsWith('/canvas/model') && req.method==='GET' && req.headers.get('accept')==='application/vnd.foundry.canvas-model+ndjson;version=1';
|
||||
if(modelStream)headers.Accept='application/vnd.foundry.canvas-model+ndjson;version=1';
|
||||
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", "idempotency-key"]) {
|
||||
for (const h of ["x-foundry-code", "x-foundry-verifier", "if-match", "idempotency-key", "x-expected-account", "x-resource-id"]) {
|
||||
const value = req.headers.get(h);
|
||||
if (value !== null) headers[h] = value;
|
||||
}
|
||||
@@ -81,11 +87,12 @@ async function handler(
|
||||
if (values.length === 1 && values[0].length < 300) query.set(key, values[0]);
|
||||
}
|
||||
const encodingStarted = performance.now();
|
||||
const outboundBody = req.method === "GET" ? undefined : asset ? requestBody as BodyInit : JSON.stringify(requestBody || {});
|
||||
// Frozen document and AI requests cross the proxy byte-for-byte.
|
||||
const outboundBody = req.method === "GET" ? undefined : asset || canonical || frozenAI ? requestBody as BodyInit : JSON.stringify(requestBody || {});
|
||||
const encodeMs = performance.now() - encodingStarted;
|
||||
const started = performance.now();
|
||||
try {
|
||||
const snapshotRead = canvas && path.endsWith("/canvas") && req.method === "GET";
|
||||
const snapshotRead = canvas && (path.endsWith("/canvas") || canonical) && req.method === "GET";
|
||||
const streamedRead = snapshotRead || asset && req.method === "GET";
|
||||
const snapshotWrite = canvas && path.endsWith("/canvas") && req.method === "PATCH";
|
||||
const traceId = streamedRead || snapshotWrite ? crypto.randomUUID() : "";
|
||||
@@ -128,8 +135,8 @@ async function handler(
|
||||
const headersReceived = performance.now();
|
||||
// Snapshots contain no login exchange payload. Forward them without buffering
|
||||
// and parsing/re-encoding multi-megabyte inline image JSON in the proxy.
|
||||
if (canvas && path.endsWith("/canvas") && req.method === "GET" && upstream.ok) {
|
||||
responseHeaders.set("Content-Type", "application/json; charset=utf-8");
|
||||
if (canonical || canvas && path.endsWith("/canvas") && req.method === "GET" && upstream.ok) {
|
||||
responseHeaders.set("Content-Type", modelStream && upstream.ok && upstream.headers.get('content-type')?.startsWith('application/vnd.foundry.canvas-model+ndjson;') ? upstream.headers.get('content-type')! : "application/json; charset=utf-8");
|
||||
responseHeaders.set("Server-Timing", `upstream;dur=${(headersReceived - started).toFixed(1)}, proxy_headers;dur=${(performance.now() - headersReceived).toFixed(1)}`);
|
||||
return new NextResponse(upstream.body, { status: upstream.status, headers: responseHeaders });
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import { useActiveBoard } from "@/components/workspace/active-board";
|
||||
import toolbar from "@/components/ui/toolbar.module.css";
|
||||
import { EditableTitle } from "@/components/workspace/editable-title";
|
||||
import { UserAvatar } from "@/components/workspace/user-avatar";
|
||||
import { useCanvasRuntimeHost } from "@/components/workspace/canvas-runtime-host";
|
||||
import { AccountPanel } from "@/components/workspace/account-panel";
|
||||
import { readDemoBoards } from "@/lib/demo-workspace";
|
||||
import { loadBoardDirectory } from "@/lib/board-directory";
|
||||
@@ -20,6 +21,7 @@ import styles from "@/components/workspace/workspace.module.css";
|
||||
|
||||
export default function DashboardSidebar() {
|
||||
const router = useRouter();
|
||||
const runtimeHost = useCanvasRuntimeHost();
|
||||
const { board: activeBoard, setBoard: setActiveBoard } = useActiveBoard();
|
||||
const reduceMotion = useReducedMotion();
|
||||
const params = useParams<{ workspace?: string; board?: string }>();
|
||||
@@ -33,6 +35,7 @@ export default function DashboardSidebar() {
|
||||
const [status, setStatus] = useState<"loading" | "ready" | "error">("loading");
|
||||
const [error, setError] = useState("");
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
const [accountAnchor, setAccountAnchor] = useState<"sidebar" | "floating">("sidebar");
|
||||
const [query, setQuery] = useState("");
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [panel, setPanel] = useState<"settings" | "workspace" | null>(null);
|
||||
@@ -139,7 +142,7 @@ export default function DashboardSidebar() {
|
||||
|
||||
async function logout() {
|
||||
try {
|
||||
if (!demo) await api("auth/logout", { method: "POST" });
|
||||
if (!demo) { await runtimeHost.retireIdentity(); await api("auth/logout", { method: "POST" }); }
|
||||
router.push("/login");
|
||||
} catch (e) { setError(errorText(e)); }
|
||||
}
|
||||
@@ -172,13 +175,13 @@ export default function DashboardSidebar() {
|
||||
return (
|
||||
<>
|
||||
<AnimatePresence initial={false}>
|
||||
{collapsed && (
|
||||
<motion.div key="sidebar-controls" className={`${toolbar.surface} ${toolbar.relaxed} ${styles.floating}`} aria-label="侧边栏快捷操作"
|
||||
{(collapsed || Boolean(params.board)) && (
|
||||
<motion.div key="sidebar-controls" className={`${toolbar.surface} ${toolbar.relaxed} ${styles.floating}`} data-sidebar-collapsed={collapsed} aria-label={params.board ? "画布快捷操作" : "侧边栏快捷操作"}
|
||||
initial={{ opacity: 0, y: reduceMotion ? 0 : -6 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: reduceMotion ? 0 : -6 }}
|
||||
transition={{ duration: reduceMotion ? 0 : 0.14 }}>
|
||||
<IconButton ref={expandButton} icon={PanelLeftOpen} label="展开侧边栏" aria-expanded={false} aria-controls="workspace-sidebar" onClick={() => toggleSidebar(false)} />
|
||||
{collapsed && <IconButton ref={expandButton} icon={PanelLeftOpen} label="展开侧边栏" aria-expanded={false} aria-controls="workspace-sidebar" onClick={() => toggleSidebar(false)} />}
|
||||
<Link href="/" className={styles.logoLink} aria-label="啡跃 Endoleap 主页" title="返回主页">
|
||||
<img src="/brand/png/logo-light/endoleap-32.png"
|
||||
srcSet="/brand/png/logo-light/endoleap-32.png 1x, /brand/png/logo-light/endoleap-64.png 2x, /brand/png/logo-light/endoleap-128.png 4x"
|
||||
@@ -189,7 +192,7 @@ export default function DashboardSidebar() {
|
||||
editable={Boolean(params.board && activeBoard && matchesRef(activeBoard, params.board) && activeBoard.workspace_id === workspace && canCreate)}
|
||||
onSave={renameTitle} className={styles.floatingTitle} />
|
||||
<span className={toolbar.divider} aria-hidden="true" />
|
||||
<button type="button" className={styles.floatingAvatarButton} popoverTarget="workspace-account" aria-label="账户和工作空间">
|
||||
<button type="button" className={styles.floatingAvatarButton} popoverTarget="workspace-account" aria-label="账户和工作空间" onClick={() => setAccountAnchor("floating")}>
|
||||
<UserAvatar name={name} url={profile?.avatar_url} />
|
||||
</button>
|
||||
{error && <p className={styles.floatingError} role="alert">{error}</p>}
|
||||
@@ -220,7 +223,7 @@ export default function DashboardSidebar() {
|
||||
</nav>
|
||||
{error && <p className={styles.error} role="alert">{error}</p>}
|
||||
<div className={styles.accountArea}>
|
||||
<button type="button" className={styles.accountButton} popoverTarget="workspace-account" aria-label="账户和工作空间">
|
||||
<button type="button" className={styles.accountButton} popoverTarget="workspace-account" aria-label="账户和工作空间" onClick={() => setAccountAnchor("sidebar")}>
|
||||
<UserAvatar name={name} url={profile?.avatar_url} />
|
||||
<span className={styles.accountText}><strong>{name}</strong><small>{currentWorkspace?.name || (demo ? "本地演示" : "个人账户")}</small></span>
|
||||
<ChevronsUpDown size={14} strokeWidth={1.6} />
|
||||
@@ -228,7 +231,7 @@ export default function DashboardSidebar() {
|
||||
</div>
|
||||
</motion.aside>
|
||||
</motion.div>
|
||||
<div ref={accountPopup} id="workspace-account" popover="auto" className={styles.accountPopup} data-floating={collapsed} aria-label="账户和工作空间菜单">
|
||||
<div ref={accountPopup} id="workspace-account" popover="auto" className={styles.accountPopup} data-floating={accountAnchor === "floating"} data-sidebar-collapsed={collapsed} aria-label="账户和工作空间菜单">
|
||||
<div className={styles.accountHeading}>
|
||||
<span className={styles.workspaceAvatar} aria-hidden="true">{(currentWorkspace?.name || "啡跃").slice(0, 1)}</span>
|
||||
<div><strong>{currentWorkspace?.name || "你的工作空间"}</strong><small>{roleLabel}{demo ? " · 本地演示" : ""}</small></div>
|
||||
|
||||
+2
-1
@@ -1,3 +1,4 @@
|
||||
import { CanvasRuntimeHost } from "@/components/workspace/canvas-runtime-host";
|
||||
import { AppFeedback } from "@/components/ui/app-feedback";
|
||||
import type { Metadata } from "next";
|
||||
import type { ReactNode } from "react";
|
||||
@@ -19,7 +20,7 @@ export const metadata: Metadata = {
|
||||
export default function RootLayout({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<html lang="zh-CN" data-scroll-behavior="smooth">
|
||||
<body><AppFeedback>{children}</AppFeedback></body>
|
||||
<body><AppFeedback><CanvasRuntimeHost>{children}</CanvasRuntimeHost></AppFeedback></body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,25 +1,24 @@
|
||||
"use client";
|
||||
|
||||
import { useContext, useRef, useState, type PointerEvent } from "react";
|
||||
import { useContext, useEffect, 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 { ProjectionNode } from "@/lib/canvas-bindings/projection";
|
||||
import type { CanvasNode } from "@/lib/canvas";
|
||||
import { connectorBezier, nodeConnector, type ConnectorSide, type Connector } from "@/lib/canvas-geometry";
|
||||
import {compatConnector} from "@/lib/canvas-bindings/connectors";
|
||||
import { connectorBezier, 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 type { EditingLease } from "./editing-lease";
|
||||
import { EdgeEditing } from "./edge-editing";
|
||||
import { EdgeToolbarControls } from "./edge-toolbar";
|
||||
import {ownsPointerCapture,acquirePointerCapture,releasePointerCapture} from "@/lib/canvas-bindings/pointer-capture";
|
||||
import styles from "./canvas.module.css";
|
||||
|
||||
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;
|
||||
function connector(node:InternalNode<CanvasNode>,handle:string|null|undefined,fallback:ConnectorSide):Connector{return compatConnector({...node,position:node.internals.positionAbsolute},handle,fallback);}
|
||||
function nativeConnector(x:number,y:number,side:ConnectorSide):Connector{
|
||||
const normals={top:{x:0,y:-1},right:{x:1,y:0},bottom:{x:0,y:1},left:{x:-1,y:0}};
|
||||
return {point:{x,y},normal:normals[side]};
|
||||
}
|
||||
function Marker({ id, value, color }: { id: string; value?: EdgeMarker; color: string }) {
|
||||
const type = value?.type || "none";
|
||||
@@ -35,9 +34,12 @@ export function CanvasEdge(props: EdgeProps<CanvasEdgeModel>) {
|
||||
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);
|
||||
const gesture = useRef<{ capture: Element; controller: AbortController; pointerId: number; lease?: EditingLease<NonNullable<CanvasEdgeModel['data']>['path']>; id: string; part: "position" | "in" | "out" | "startControl" | "endControl" } | null>(null);
|
||||
const pointerHandlers = useRef<{ move(event: PointerEvent<SVGCircleElement>): void; stop(event: PointerEvent<SVGCircleElement>): void; cancel(event: PointerEvent<SVGCircleElement>): void } | null>(null);
|
||||
const interrupt=useRef(()=>{});interrupt.current=()=>cancel();
|
||||
useEffect(()=>{const blur=()=>interrupt.current();window.addEventListener('blur',blur);return()=>{window.removeEventListener('blur',blur);interrupt.current();};},[]);
|
||||
if (!source || !target) return null;
|
||||
const from = connector(source, props.sourceHandleId, "right"), to = connector(target, props.targetHandleId, "left");
|
||||
const from = (source.data as ProjectionNode['data']).__headlessRecord ? nativeConnector(props.sourceX,props.sourceY,props.sourcePosition) : connector(source, props.sourceHandleId, "right"), to = (target.data as ProjectionNode['data']).__headlessRecord ? nativeConnector(props.targetX,props.targetY,props.targetPosition) : 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);
|
||||
@@ -51,13 +53,23 @@ export function CanvasEdge(props: EdgeProps<CanvasEdgeModel>) {
|
||||
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);
|
||||
if(event.button!==0||event.defaultPrevented)return;if(gesture.current){if(gesture.current.pointerId!==event.pointerId)cancel();return;}if(event.isPrimary===false)return;
|
||||
// The SVG control can be replaced by a projection update. The view surface survives it.
|
||||
const capture = event.currentTarget.closest('[data-headless-canvas]') ?? event.currentTarget.closest('.react-flow') ?? event.currentTarget;
|
||||
const lease = editing.begin(props.id); if (lease === false) return;
|
||||
const controller = new AbortController();
|
||||
gesture.current = { id, part, capture, controller, pointerId: event.pointerId, lease: lease && typeof lease === 'object' ? lease : undefined }; setSelectedKnot(id);
|
||||
const owned = (input: Event) => (input as globalThis.PointerEvent).pointerId === event.pointerId;
|
||||
capture.addEventListener('pointermove', input => { if (owned(input)) pointerHandlers.current?.move(input as unknown as PointerEvent<SVGCircleElement>); }, { signal: controller.signal });
|
||||
capture.addEventListener('pointerup', input => { if (owned(input)) pointerHandlers.current?.stop(input as unknown as PointerEvent<SVGCircleElement>); }, { signal: controller.signal });
|
||||
for (const name of ['pointercancel', 'lostpointercapture']) capture.addEventListener(name, input => { if (owned(input) && gesture.current) pointerHandlers.current?.cancel(input as unknown as PointerEvent<SVGCircleElement>); }, { signal: controller.signal });
|
||||
if(!acquirePointerCapture(capture,event.pointerId)){cancel();return;}event.preventDefault();event.stopPropagation();
|
||||
}
|
||||
function move(event: PointerEvent<SVGCircleElement>) {
|
||||
const current = gesture.current;
|
||||
if (!current) return;
|
||||
event.stopPropagation();
|
||||
if(!current)return;event.stopPropagation();
|
||||
if (!ownsPointerCapture(current,event)) return;
|
||||
if(!current)return;
|
||||
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);
|
||||
@@ -76,14 +88,26 @@ export function CanvasEdge(props: EdgeProps<CanvasEdgeModel>) {
|
||||
}
|
||||
}
|
||||
}
|
||||
editing.patch(props.id, { ...data, path: next }, true);
|
||||
if (current.lease) current.lease.preview(next); else 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);
|
||||
if(!gesture.current)return;event.stopPropagation();
|
||||
if (!ownsPointerCapture(gesture.current,event)) return; const current = gesture.current!; move(event); gesture.current = null; current.controller.abort();
|
||||
if (current.lease) current.lease.commit(); else editing.finish?.();
|
||||
releasePointerCapture(current.capture,current.pointerId);
|
||||
}
|
||||
function cancel(event?:{pointerId:number;type?:string;target?:unknown;stopPropagation?():void}) {
|
||||
if(event&&gesture.current)event.stopPropagation?.();
|
||||
if(event&&!ownsPointerCapture(gesture.current,event))return;
|
||||
const current = gesture.current; gesture.current = null;
|
||||
if (!current) return;
|
||||
current.controller.abort();
|
||||
if (current.lease) current.lease.cancel(); else editing.cancel?.();
|
||||
releasePointerCapture(current.capture,current.pointerId);
|
||||
}
|
||||
pointerHandlers.current = { move, stop, cancel };
|
||||
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); }}
|
||||
onPointerDown={(event) => start(event, id, part)} onPointerMove={move} onPointerUp={stop} onPointerCancel={cancel} onLostPointerCapture={(event) => { if (gesture.current) cancel(event); }} 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) };
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
"use client";
|
||||
import {createContext,useContext,useCallback,useLayoutEffect,useId,useRef} from 'react';
|
||||
import {useViewport} from '@xyflow/react';
|
||||
import type {OverlayIndex} from '@/lib/canvas-bindings/overlays';
|
||||
import type {ProjectEngine} from '@/lib/canvas-runtime/workspace';
|
||||
export const CanvasOverlays=createContext<{index:OverlayIndex;engine:ProjectEngine}|null>(null);
|
||||
/** Optional for legacy/engine-less renderers; a portal keeps its explicit element target. */
|
||||
export function useCanvasOverlay(targetId:string,placement:string){
|
||||
const scope=useContext(CanvasOverlays),instance=useId(),refresh=useRef<()=>void>(()=>{}),cleanup=useRef<()=>void>(()=>{});
|
||||
useViewport();
|
||||
const incarnation=scope?.engine.getSnapshot().document.elements.get(targetId)?.incarnation;
|
||||
const ref=useCallback((element:HTMLElement|null)=>{cleanup.current();refresh.current=()=>{};cleanup.current=()=>{};if(!element||!scope||incarnation===undefined)return;
|
||||
const marker=crypto.randomUUID();element.dataset.canvasOverlayView=scope.index.viewId;element.dataset.canvasOverlayOwner=marker;element.dataset.canvasOverlayTarget=targetId;element.dataset.canvasOverlayPlacement=placement;
|
||||
let composing=false;const value=()=>{const rect=element.getBoundingClientRect();return{placement,target:{id:targetId,incarnation},bounds:{x:rect.x-8,y:rect.y-8,width:rect.width+16,height:rect.height+16},focused:element.contains(document.activeElement),composing};};
|
||||
const lease=scope.index.register(instance,value()),measure=()=>lease.update(value()),start=()=>{composing=true;measure();},end=()=>{composing=false;measure();};refresh.current=measure;
|
||||
const blur=()=>queueMicrotask(measure),observer=new ResizeObserver(measure);observer.observe(element);element.addEventListener('focusin',measure);element.addEventListener('focusout',blur);element.addEventListener('compositionstart',start);element.addEventListener('compositionend',end);let frame=requestAnimationFrame(measure);
|
||||
cleanup.current=()=>{if(element.dataset.canvasOverlayOwner===marker){delete element.dataset.canvasOverlayView;delete element.dataset.canvasOverlayOwner;delete element.dataset.canvasOverlayTarget;delete element.dataset.canvasOverlayPlacement;}cancelAnimationFrame(frame);observer.disconnect();element.removeEventListener('focusin',measure);element.removeEventListener('focusout',blur);element.removeEventListener('compositionstart',start);element.removeEventListener('compositionend',end);lease.release();};
|
||||
},[scope,instance,targetId,incarnation,placement]);
|
||||
useLayoutEffect(()=>{refresh.current();});useLayoutEffect(()=>()=>cleanup.current(),[]);return ref;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
"use client";
|
||||
import type { NodeProps } from '@xyflow/react';
|
||||
import type { CanvasNode } from '@/lib/canvas-elements';
|
||||
import { builtinRendererDefinitions } from '@/lib/canvas-bindings/renderers';
|
||||
import { createReactRenderers, type ReactRendererDefinition } from '@/lib/canvas-bindings/react-flow';
|
||||
import { MaterialNode } from './material-node';
|
||||
import { PlainTextNode } from './plain-text-node';
|
||||
import { PlainImageNode } from './plain-image-node';
|
||||
import { FrameNode } from './frame-node';
|
||||
import { IconNode } from './icon-node';
|
||||
import { CanvasEdge } from './canvas-edge';
|
||||
function HeadlessFrameNode(props:NodeProps<CanvasNode>){return <FrameNode {...props} directAnchors />;}
|
||||
const nodes={material:MaterialNode,plainText:PlainTextNode,plainImage:PlainImageNode,frame:HeadlessFrameNode,icon:IconNode};
|
||||
export const defaultCanvasRenderers=createReactRenderers(builtinRendererDefinitions.map(definition=>{const render=definition.structure==='node'?nodes[definition.rendererId as keyof typeof nodes]:CanvasEdge;return{...definition,render,preview:render} as ReactRendererDefinition;}));
|
||||
@@ -1,14 +1,14 @@
|
||||
"use client";
|
||||
|
||||
import { useContext, useEffect, useRef, useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import type { CanvasNodeData } from "@/lib/canvas-elements";
|
||||
import { NodeEditing } from "./transformable-node";
|
||||
import {useScopedNodeEditing} from "./node-editing";
|
||||
import { textAppearance } from "./text-appearance";
|
||||
import styles from "./canvas.module.css";
|
||||
|
||||
/** Single clicks select; native double-click timing controls entry into editing. */
|
||||
export function CanvasTextEditor({ id, data, selected, plain = false }: { id: string; data: CanvasNodeData; selected: boolean; plain?: boolean }) {
|
||||
const actions = useContext(NodeEditing);
|
||||
const actions = useScopedNodeEditing(id,data.__headlessTarget);
|
||||
const [editing, setEditing] = useState(false);
|
||||
const textarea = useRef<HTMLTextAreaElement>(null);
|
||||
const press = useRef({ x: 0, y: 0, moved: false });
|
||||
@@ -27,7 +27,7 @@ export function CanvasTextEditor({ id, data, selected, plain = false }: { id: st
|
||||
aria-label={label} readOnly={!active} tabIndex={active ? 0 : -1}
|
||||
placeholder={plain ? "写下文字…" : data.kind === "script" ? "写下你的脚本…" : "留住一个想法…"}
|
||||
value={data.text} maxLength={100000} onChange={(event) => actions.edit(id, event.target.value)}
|
||||
onBlur={() => setEditing(false)} onKeyDown={(event) => { if (event.key === "Escape") { event.preventDefault(); event.stopPropagation(); setEditing(false); event.currentTarget.blur(); } }} />
|
||||
onBlur={() => { setEditing(false); actions.endEditing?.(id); }} onKeyDown={(event) => { if (event.key === "Escape") { event.preventDefault(); event.stopPropagation(); setEditing(false); event.currentTarget.blur(); } }} />
|
||||
{!active && <div className={styles.textSelectLayer} role="button" tabIndex={selected ? 0 : -1} aria-label={`单击选中,双击编辑${label}`}
|
||||
onPointerDown={(event) => { press.current = { x: event.clientX, y: event.clientY, moved: false }; }}
|
||||
onPointerMove={(event) => { if (Math.hypot(event.clientX - press.current.x, event.clientY - press.current.y) > 4) press.current.moved = true; }}
|
||||
|
||||
@@ -21,6 +21,10 @@
|
||||
.helpPopup p { margin-top: 9px; }
|
||||
.error { position: absolute; top: 78px; left: 50%; transform: translateX(-50%); max-width: min(500px, calc(100vw - 32px)); padding: 10px 14px; border: 1px solid #e5c7b8; border-radius: 8px; color: #a25439; background: #fff9f3; font-size: 12px; z-index: 15; }
|
||||
.helpToolbar { position: absolute; right: 12px; bottom: 12px; z-index: 10; }
|
||||
.migrationPanel { position: absolute; right: 12px; top: 64px; z-index: 220; display: flex; flex-wrap: wrap; gap: 8px; align-items: center; max-width: 360px; padding: 8px; border: 1px solid #ded7ce; border-radius: 7px; background: #fffefb; color: #78523f; font-size: 12px; }
|
||||
.migrationPanel button { border: 1px solid #ded7ce; border-radius: 5px; padding: 6px 9px; background: white; cursor: pointer; }
|
||||
.migrationPanel button:disabled { cursor: default; opacity: .5; }
|
||||
.migrationSurface { position: absolute; inset: 0; }
|
||||
@media (max-width: 700px) {
|
||||
.topRight { top: 62px; right: 12px; }
|
||||
.bottomToolbar { left: 12px; bottom: 12px; transform: none; max-width: calc(100vw - 92px); overflow-x: auto; overscroll-behavior-x: contain; }
|
||||
@@ -152,7 +156,7 @@
|
||||
.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; }
|
||||
.curveControl { cursor: move; touch-action: none; pointer-events: all; }
|
||||
.edgeDashAnimation { animation: edgeDash 1.6s linear infinite; }
|
||||
.edgePulseAnimation { animation: edgePulse 1.6s ease-in-out infinite; }
|
||||
@keyframes edgeDash { to { stroke-dashoffset: -40; } }
|
||||
@@ -192,3 +196,12 @@
|
||||
|
||||
.memberSync { padding: 5px 9px; border: 1px solid #ded7ce; border-radius: 7px; background: #fffefb; color: #796e63; font-size: 11px; box-shadow: 0 2px 8px #372f2410; }
|
||||
.memberSync button { margin-left: 6px; border: 0; background: transparent; color: #78523f; text-decoration: underline; cursor: pointer; }
|
||||
|
||||
.importPanel { position: absolute; top: 84px; right: 12px; z-index: 12; width: max-content; max-width: min(310px, calc(100vw - 28px)); max-height: 45vh; overflow: auto; border: 1px solid #ded7ce; border-radius: 10px; padding: 6px 9px; background: #fffefb; box-shadow: 0 4px 18px #32251a10; color: #49372e; font-size: 11px; }
|
||||
.importPanel summary { cursor: pointer; padding: 5px 0; }
|
||||
.importPanel ul { padding-left: 16px; overflow-wrap: anywhere; }
|
||||
.importPanel p { margin: 5px 0; }
|
||||
.importPanel small { color: #92877c; }
|
||||
.importJob { border-top: 1px solid #eee7de; padding-top: 5px; margin-top: 5px; }
|
||||
.importActions { display: flex; align-items: center; gap: 8px; }
|
||||
.importActions > button:not(:first-child), .importJob button { color: #78523f; padding: 4px 0; }
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
"use client";
|
||||
import { createContext } from "react";
|
||||
import type { EditingLease } from "./editing-lease";
|
||||
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>() });
|
||||
const defaultEditing = { finish: undefined as undefined | (() => void), cancel: undefined as undefined | (() => void), begin: (_id?: string): boolean | void | EditingLease<NonNullable<EdgeData['path']>> => {}, patch: (_id: string, _data: EdgeData, _transient = false) => {}, active: new Set<string>() };
|
||||
export const EdgeEditing = createContext<Omit<typeof defaultEditing, 'finish' | 'cancel'> & Partial<Pick<typeof defaultEditing, 'finish' | 'cancel'>>>(defaultEditing);
|
||||
|
||||
@@ -9,7 +9,7 @@ export function EdgeToolbarControls({ id, x, y, data, change, add, remove, count
|
||||
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`}
|
||||
return <EdgeToolbar alignY="bottom" 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>
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
/** A captured pointer may only finish the exact interaction that acquired it. */
|
||||
export type EditingLease<T> = { preview(value: T): void; commit(): void; cancel(): void };
|
||||
|
||||
export function guardEditingLease<T>(isCurrent: () => boolean, actions: EditingLease<T>): EditingLease<T> {
|
||||
return Object.freeze({
|
||||
preview(value: T) { if (isCurrent()) actions.preview(value); },
|
||||
commit() { if (isCurrent()) actions.commit(); },
|
||||
cancel() { if (isCurrent()) actions.cancel(); },
|
||||
});
|
||||
}
|
||||
@@ -5,14 +5,19 @@ 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, ResizeToggle } from "./transformable-node";
|
||||
import { TransformableNode, ResizeToggle } from "./transformable-node";
|
||||
import { FrameFeedback } from "./frame-feedback";
|
||||
import { FrameLabelMenu } from "./frame-label-menu";
|
||||
import { ContentHandles } from "./content-handles";
|
||||
import {useCanvasOverlay} from "./canvas-overlays";
|
||||
import {useScopedNodeEditing} from "./node-editing";
|
||||
import styles from "./canvas.module.css";
|
||||
|
||||
export function FrameNode(props: NodeProps<CanvasNode>) {
|
||||
const { id, data, selected } = props;
|
||||
const editing = useContext(NodeEditing);
|
||||
export function FrameNode(props: NodeProps<CanvasNode> & { directAnchors?: boolean }) {
|
||||
const { directAnchors = false, ...nodeProps } = props;
|
||||
const { id, data, selected } = nodeProps;
|
||||
const overlay=useCanvasOverlay(id,"frame-settings");
|
||||
const editing = useScopedNodeEditing(id,data.__headlessTarget);
|
||||
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;
|
||||
@@ -28,14 +33,14 @@ export function FrameNode(props: NodeProps<CanvasNode>) {
|
||||
setTitle(name);
|
||||
}
|
||||
return <>
|
||||
<NodeToolbar style={{ zIndex: 100, pointerEvents: "auto" }} isVisible={true} position={Position.Top} offset={45}>
|
||||
<AnimatePresence>{selected && <motion.div className={`${styles.styleToolbar} nodrag nopan nowheel`} role="toolbar" aria-label="分区设置"
|
||||
<AnimatePresence>{selected && <NodeToolbar style={{ zIndex: 100, pointerEvents: "auto" }} isVisible={true} position={Position.Top} offset={45}>
|
||||
<motion.div ref={overlay} className={`${styles.styleToolbar} nodrag nopan nowheel`} role="toolbar" aria-label="分区设置"
|
||||
initial={{ opacity: 0, y: reduce ? 0 : 5 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: reduce ? 0 : 5 }} transition={{ duration: reduce ? 0 : 0.14 }}
|
||||
onPointerDown={(event) => event.stopPropagation()} onClick={(event) => event.stopPropagation()} onDoubleClick={(event) => event.stopPropagation()} onKeyDown={(event) => event.stopPropagation()}>
|
||||
<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 })} />
|
||||
<ResizeToggle id={id} />
|
||||
<ResizeToggle id={id} renderedTarget={data.__headlessTarget} />
|
||||
{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) };
|
||||
@@ -45,13 +50,14 @@ export function FrameNode(props: NodeProps<CanvasNode>) {
|
||||
<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 : ""}`}>
|
||||
</motion.div>
|
||||
</NodeToolbar>}</AnimatePresence>
|
||||
<TransformableNode {...nodeProps} hideStyleToolbar className={`${styles.frameNode} ${incoming ? styles.frameDropTarget : ""} ${outgoing ? styles.frameDropExit : ""}`}>
|
||||
{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="拖动分区,已有成员一起移动" />)}
|
||||
{directAnchors && <ContentHandles selected={!!selected} />}
|
||||
{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" ? "子画布输入" : "子画布输出"} />;
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
"use client";
|
||||
import { useImageUploads } from "./use-image-uploads";
|
||||
import {LegacyCanvasMigration} from "./legacy-canvas-migration";
|
||||
import type {LegacyMigrationContext} from "@/lib/browser-canvas-migration";
|
||||
import { notify, dismissNotification } from "@/lib/notifications";
|
||||
import { FeedbackContext } from "@/components/ui/app-feedback";
|
||||
import { ImagePreviews } from "./canvas-image";
|
||||
@@ -43,7 +45,7 @@ const edgeTypes = { default: CanvasEdge };
|
||||
const IconPicker = dynamic(() => import("./icon-picker").then((module) => module.IconPicker), { ssr: false, loading: () => <div role="status" className={styles.iconLoading}>正在加载图标面板…</div> });
|
||||
const nodeTypes = { material: MaterialNode, plainText: PlainTextNode, icon: IconNode, frame: FrameNode, plainImage: PlainImageNode };
|
||||
|
||||
function CanvasEditor({ board, snapshot }: { board: Board; snapshot?: CanvasSnapshot }) {
|
||||
function CanvasEditor({ board, snapshot, migration }: { board: Board; snapshot?: CanvasSnapshot; migration?:LegacyMigrationContext }) {
|
||||
const flow = useReactFlow<CanvasNode>();
|
||||
const { zoom } = useViewport();
|
||||
const reduceMotion = useReducedMotion();
|
||||
@@ -80,7 +82,7 @@ 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 tree = useCanvasTree(board.id, board.workspace_id, initial.document, snapshot, Boolean(recovery), board.parent_board_id);
|
||||
const tree = useCanvasTree(board.id, board.workspace_id, initial.document, snapshot, Boolean(recovery), board.parent_board_id, migration);
|
||||
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;
|
||||
@@ -89,6 +91,7 @@ function CanvasEditor({ board, snapshot }: { board: Board; snapshot?: CanvasSnap
|
||||
const treeRef = useRef(tree); treeRef.current = tree;
|
||||
const uploads = useImageUploads(board.workspace_id, (job, asset) => treeRef.current.resolveImageUpload(job.node.id, asset,{width:job.node.width!,height:job.node.height!}));
|
||||
const imageUploading = demoImageUploading;
|
||||
const ioActive=useRef(false);
|
||||
useEffect(() => {
|
||||
const current = treeRef.current;
|
||||
for (const job of uploads.jobs) {
|
||||
@@ -322,6 +325,7 @@ function CanvasEditor({ board, snapshot }: { board: Board; snapshot?: CanvasSnap
|
||||
const file = event.target.files?.[0];
|
||||
event.target.value = "";
|
||||
if (!file) { pendingImage.current = null; return; }
|
||||
if(treeRef.current.isMigrationHeld()){pendingImage.current=null;setError("画布交接期间暂时不能添加图片。");return;}
|
||||
if (!["image/png", "image/jpeg", "image/webp", "image/gif"].includes(file.type) || file.size > (snapshot ? 20 * 1024 * 1024 : 2_000_000)) {
|
||||
pendingImage.current = null; setError(snapshot ? "请选择小于 20 MiB 的 PNG、JPEG、WebP 或 GIF 图片。" : "演示模式请选择小于 2 MB 的图片。"); return;
|
||||
}
|
||||
@@ -370,6 +374,8 @@ function CanvasEditor({ board, snapshot }: { board: Board; snapshot?: CanvasSnap
|
||||
const file = event.target.files?.[0];
|
||||
event.target.value = "";
|
||||
if (!file) return;
|
||||
if(treeRef.current.isMigrationHeld()){setError("画布交接期间暂时不能导入。");return;}
|
||||
ioActive.current=true;
|
||||
try {
|
||||
if (file.size > 5_000_000) throw new Error("请选择小于 5 MB 的图谱文件。");
|
||||
if (tree.rootDocument.nodes.some((node) => node.data.subgraph)) throw new Error("当前画布包含真实子图,请先导出备份并通过子图事务恢复,避免覆盖子图关系。");
|
||||
@@ -379,6 +385,7 @@ function CanvasEditor({ board, snapshot }: { board: Board; snapshot?: CanvasSnap
|
||||
void flow.setViewport(document.viewport);
|
||||
setError("");
|
||||
} catch (error) { setError(error instanceof Error ? error.message : "导入失败。"); }
|
||||
finally{ioActive.current=false;}
|
||||
}
|
||||
async function exportProject() {
|
||||
let documents: Map<string, import("@/lib/canvas").CanvasDocument>;
|
||||
@@ -416,6 +423,7 @@ function CanvasEditor({ board, snapshot }: { board: Board; snapshot?: CanvasSnap
|
||||
<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)) }}>
|
||||
<div className={styles.migrationSurface} inert={tree.migrationHeld}>
|
||||
<ReactFlow<CanvasNode> nodes={nodes} edges={edges} nodeTypes={nodeTypes} edgeTypes={edgeTypes}
|
||||
onBeforeDelete={async ({ nodes: removing }) => {
|
||||
if (tree.busy || tree.transactionBusy) return false;
|
||||
@@ -483,6 +491,7 @@ function CanvasEditor({ board, snapshot }: { board: Board; snapshot?: CanvasSnap
|
||||
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>
|
||||
</div>
|
||||
</EdgeEditing.Provider>
|
||||
</FrameFeedback.Provider>
|
||||
</NodeEditing.Provider>
|
||||
@@ -500,7 +509,7 @@ function CanvasEditor({ board, snapshot }: { board: Board; snapshot?: CanvasSnap
|
||||
<IconButton icon={Download} label="导出图谱" onClick={exportProject} />
|
||||
</div>
|
||||
</div>
|
||||
<div className={`${toolbar.surface} ${toolbar.relaxed} ${styles.toolbar} ${styles.bottomToolbar}`} role="toolbar" aria-label="画布创作工具">
|
||||
<div className={`${toolbar.surface} ${toolbar.relaxed} ${styles.toolbar} ${styles.bottomToolbar}`} role="toolbar" aria-label="画布创作工具" inert={tree.migrationHeld}>
|
||||
<IconButton icon={MousePointer2} label="选择工具(V)" aria-pressed={tool === "select"} onClick={() => setTool("select")} />
|
||||
<IconButton icon={Hand} label="抓手工具(H)" aria-pressed={tool === "hand"} onClick={() => setTool("hand")} />
|
||||
<span className={toolbar.divider} aria-hidden="true" />
|
||||
@@ -542,8 +551,8 @@ function CanvasEditor({ board, snapshot }: { board: Board; snapshot?: CanvasSnap
|
||||
setTool("select");
|
||||
}} />}
|
||||
{!feedback && <div className={styles.membershipNotice} role="status" aria-live="polite">{membershipNotice}</div>}
|
||||
{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)} />}
|
||||
{!tree.migrationHeld && recovery && <DraftRecovery canRestore={canRestoreCanvasDraft(recovery, tree.rootDocument)} exportDraft={() => downloadDraft(recovery)} restore={() => { if(treeRef.current.isMigrationHeld())return;dispatch({ type: "replace", document: recovery }); setRecovery(null); }} discard={() => {if(!treeRef.current.isMigrationHeld())setRecovery(null);}} />}
|
||||
{!tree.migrationHeld && !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>
|
||||
@@ -556,7 +565,11 @@ function CanvasEditor({ board, snapshot }: { board: Board; snapshot?: CanvasSnap
|
||||
</div>
|
||||
</NodeToolbar>;
|
||||
}))}
|
||||
{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>}
|
||||
{migration&&<LegacyCanvasMigration context={migration} workspaceId={board.workspace_id} canvasId={board.id} registry={migration.registry} held={tree.migrationHeld} hold={tree.setMigrationHeld} prepare={tree.prepareMigration}
|
||||
check={()=>{if(recovery||treeRef.current.recoveries.size)throw new Error('draft_recovery_required');if(ioActive.current||uploads.hasPending()||demoImageUploading)throw new Error('migration_pending_uploads');}}
|
||||
canLeave={()=>![...treeRef.current.current.current].some(([id,doc])=>treeRef.current.writers.current.get(id)?.needsSave(doc))}
|
||||
exportDraft={()=>downloadDraft(recovery??treeRef.current.rootDocument)} />}
|
||||
{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> : tree.migrationHeld?"正在检查或交接画布,编辑暂时暂停。":"正在同步画布…"}</div>}
|
||||
{error && !feedback && <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 />
|
||||
@@ -564,6 +577,6 @@ function CanvasEditor({ board, snapshot }: { board: Board; snapshot?: CanvasSnap
|
||||
);
|
||||
}
|
||||
|
||||
export default function GraphCanvas({ board, snapshot }: { board: Board; snapshot?: CanvasSnapshot }) {
|
||||
return <ReactFlowProvider><CanvasEditor board={board} snapshot={snapshot} /></ReactFlowProvider>;
|
||||
export default function GraphCanvas({ board, snapshot, migration }: { board: Board; snapshot?: CanvasSnapshot; migration?:LegacyMigrationContext }) {
|
||||
return <ReactFlowProvider><CanvasEditor board={board} snapshot={snapshot} migration={migration} /></ReactFlowProvider>;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { ArrowUp, Sparkles, Square, X } from "lucide-react";
|
||||
import type { ElementRecord, Json, Result } from "@/lib/canvas-core/types";
|
||||
import { runContainsTarget } from "@/lib/canvas-runtime/runs";
|
||||
import type { RunModel } from "@/lib/canvas-runtime/runs";
|
||||
import type { ProjectEngine } from "@/lib/canvas-runtime/workspace";
|
||||
import { errorText } from "@/lib/api";
|
||||
import { managedImageSource } from "@/lib/canvas-v2";
|
||||
import styles from "./canvas.module.css";
|
||||
|
||||
const object = (value: Json | undefined) => value && typeof value === 'object' && !Array.isArray(value) ? value : {};
|
||||
export function HeadlessAIPanel({ engine, element, close, viewId }: { engine: ProjectEngine; element: ElementRecord; close(): void; viewId:string }) {
|
||||
const saved = object(element.data.ai), root = engine.getSnapshot();
|
||||
const [models, setModels] = useState<readonly RunModel[]>([]), [loading, setLoading] = useState(true), [error, setError] = useState('');
|
||||
const [model, setModel] = useState(typeof saved.modelId === 'string' ? saved.modelId : '');
|
||||
const [prompt, setPrompt] = useState(typeof saved.prompt === 'string' ? saved.prompt : '');
|
||||
const [propagate, setPropagate] = useState(saved.propagate === true);
|
||||
const image = ['image','plain-image'].includes(element.componentId);
|
||||
const relevant = [...root.runtime.runs.values()].filter(run => runContainsTarget(run,element));
|
||||
const run = relevant.at(-1);
|
||||
const starts = [...root.runtime.ledger.operations.values()].filter(record => record.intent.operationType === 'run.start' && record.intent.run.target.id === element.id && record.intent.run.target.incarnation === element.incarnation);
|
||||
const pending=starts.filter(record=>record.phase!=='resolved'), rejectedStart=starts.at(-1)?.resolution==='not-applied'?starts.at(-1):undefined;
|
||||
const actions=[...root.runtime.ledger.operations.values()].filter(record=>(record.intent.operationType==='run.cancel'||record.intent.operationType==='run.apply')&&record.intent.action.runId===run?.id&&record.phase!=='resolved');
|
||||
const cancelling=actions.some(record=>record.intent.operationType==='run.cancel'),applying=actions.some(record=>record.intent.operationType==='run.apply');
|
||||
const busy = pending.length > 0 || actions.length > 0 || !!run && (run.hasMore || !!run.pendingPage || ['queued','running'].includes(run.status));
|
||||
const outputEvent = run?.outputEvents.filter(event => event.node_id === element.id).at(-1), output = object(outputEvent?.output);
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();setLoading(true);setError('');
|
||||
engine.models(element.ownerCanvasId,controller.signal).then(catalog => {
|
||||
if(controller.signal.aborted)return;
|
||||
const compatible = catalog.models.filter(model => model.output_modalities.includes(image ? 'image' : 'text'));
|
||||
setModels(compatible);setModel(current => compatible.some(model => model.id === current) ? current : compatible.find(model => model.available)?.id ?? '');
|
||||
if(catalog.unavailableReason)setError(errorText(new Error(catalog.unavailableReason)));
|
||||
}).catch(error => { if(!controller.signal.aborted)setError(errorText(error)); }).finally(() => { if(!controller.signal.aborted)setLoading(false); });
|
||||
return () => controller.abort();
|
||||
},[engine,element.ownerCanvasId,image]);
|
||||
function check(result: Result) { if(result.status === 'rejected') { setError(errorText(new Error(result.code)));return false; }return true; }
|
||||
function save() {
|
||||
if(!model || busy)return true;
|
||||
return check(engine.editor.dispatch({type:'element.data',target:{id:element.id,incarnation:element.incarnation},patch:{ai:{enabled:true,modelId:model,prompt,propagate,parameters:{}}}},{label:'Configure generation'}));
|
||||
}
|
||||
const appliedOutput=typeof output.output_id==='string'&&run?.appliedOutputIds?.includes(output.output_id);
|
||||
const message = cancelling ? '取消请求已保存,等待确认…' : applying ? '正在应用生成结果…' : rejectedStart ? `生成未受理:${rejectedStart.blockers.join('、')}` : appliedOutput ? '此生成结果已应用' : pending.length ? pending.some(record => record.phase === 'blocked') ? '生成受理需要处理' : '正在等待同步并受理生成…' : run?.blocked ? '生成结果已保留,当前编辑需要协调' : run?.error ? '任务跟踪需要处理,可重试' : run?.hasMore ? '正在同步任务状态…' : run ? ({queued:'已受理,等待生成',running:'正在生成…',succeeded:'已更新当前元素',failed:'生成未完成,原内容已保留',conflict:'内容已改变,生成结果已保留',cancelled:'已取消生成',partially_succeeded:'部分步骤已完成'}[run.status]) : '结果将显示在当前元素';
|
||||
return <section data-canvas-ui-view={viewId} className={`${styles.aiPanel} nodrag nopan nowheel`} aria-label="元素 AI 工具" style={{position:'absolute',right:24,bottom:90,zIndex:110,maxWidth:'calc(100% - 48px)',maxHeight:'70%',overflow:'auto'}} onPointerDown={event=>event.stopPropagation()} onKeyDown={event=>event.stopPropagation()}>
|
||||
<header><Sparkles size={14}/><strong>{image ? '图像创作' : '文本创作'}</strong><span>{element.data.title as string}</span><button aria-label="关闭 AI 工具" onClick={close}><X size={14}/></button></header>
|
||||
<textarea aria-label="AI 指令" placeholder="希望如何处理当前内容?" maxLength={16000} disabled={busy} value={prompt} onChange={event=>setPrompt(event.target.value)} onBlur={save}/>
|
||||
<footer><select aria-label="AI 模型" value={model} disabled={loading || busy} onChange={event=>setModel(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}</option>)}</select>
|
||||
<label><input type="checkbox" aria-label="继续向下传播" checked={propagate} disabled={busy} onChange={event=>setPropagate(event.target.checked)}/>继续向下传播</label>
|
||||
<button aria-label="执行 AI" title="在当前元素生成结果" disabled={busy || loading || !prompt.trim() || !models.some(item=>item.id===model && item.available)} onClick={()=>{setError('');if(save())check(engine.startRun({target:{id:element.id,incarnation:element.incarnation},boardId:element.ownerCanvasId,modelId:model,prompt,parameters:{},propagate}));}}><ArrowUp size={18}/></button>
|
||||
{run && ['queued','running'].includes(run.status) && <button aria-label="取消生成" disabled={cancelling} onClick={()=>check(engine.cancelRun(run.id))}><Square size={15}/></button>}
|
||||
</footer>
|
||||
<p role="status">{message}</p>
|
||||
{(run?.blocked || run?.status==='conflict') && <div className={styles.aiConflict}>{typeof output.text==='string' && <pre>{output.text}</pre>}{typeof output.src==='string' && <img src={managedImageSource(output.src)} alt="保留的生成结果"/>}{run?.status==='conflict'&&!run.blocked&&!run.hasMore&&!appliedOutput&&typeof output.output_id==='string'&&<button disabled={actions.length>0} onClick={()=>check(engine.applyRunOutput(run.id,{id:element.id,incarnation:element.incarnation},output.output_id as string))}>使用此结果替换当前内容</button>}</div>}
|
||||
{(run?.blocked || run?.error || pending.some(record=>record.phase==='blocked')) && <button onClick={()=>void engine.retry().catch(error=>setError(errorText(error)))}>重试任务跟踪</button>}
|
||||
{error && <p role="alert">{error}</p>}
|
||||
</section>;
|
||||
}
|
||||
@@ -0,0 +1,347 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore, type ContextType } from "react";
|
||||
import { Background, BackgroundVariant, ConnectionMode, ReactFlow, ReactFlowProvider, useReactFlow, useViewport, useStore, type Connection,type Viewport } from "@xyflow/react";
|
||||
import { Copy, Download, Frame, Hand, ImagePlus, Images, Maximize, Minus, Ratio, MousePointer2, Plus, Redo2, Sparkles, StickyNote, Type, Undo2 } from "lucide-react";
|
||||
import type { Board } from "@/lib/workspace";
|
||||
import type { ReactRenderers } from "@/lib/canvas-bindings/react-flow";
|
||||
import { CanvasProvider, CanvasRendererCommandsContext, type CanvasRendererCommands } from "@/lib/canvas-bindings/react-flow";
|
||||
import { defaultCanvasRenderers } from "./canvas-renderers";
|
||||
import type { Data, ElementRecord, Geometry, Id, Json, Result } from "@/lib/canvas-core/types";
|
||||
import { equal } from "@/lib/canvas-core/values";
|
||||
import {createSceneProjection} from "@/lib/canvas-bindings/scene-projection";
|
||||
import {createConnectionPaintIndex} from "@/lib/canvas-bindings/connection-paint";
|
||||
import {createRetainedProjection} from "./retained-projection";
|
||||
import {createOverlayIndex} from "@/lib/canvas-bindings/overlays";
|
||||
import {CanvasOverlays} from "./canvas-overlays";
|
||||
import {createProjectedPaintIndex} from "@/lib/canvas-bindings/projected-paint";
|
||||
import {createFrameSource} from '@/lib/canvas-bindings/frame-queue';
|
||||
import {createSessionFrameSource} from "@/lib/canvas-bindings/session-frames";
|
||||
import {routeCanvasKey,canvasKeyContext} from "@/lib/canvas-bindings/input-router";
|
||||
import {hitProjectedCanvas} from "@/lib/canvas-bindings/hit-query";
|
||||
import {compatConnector,measuredConnector} from "@/lib/canvas-bindings/connectors";
|
||||
import {createLoadingRendererCatalog} from '@/lib/canvas-bindings/renderers';
|
||||
import { createProjectionCache } from "@/lib/canvas-bindings/projection-cache";
|
||||
import { createResourceView, type ResourceViewSource } from "@/lib/canvas-bindings/resource-view";
|
||||
import { ancestors, commonCanvas } from "@/lib/canvas-core/document";
|
||||
import { toLocal, transformPath, type ProjectionNode } from "@/lib/canvas-bindings/projection";
|
||||
import type { ProjectEngine } from "@/lib/canvas-runtime/workspace";
|
||||
import { errorText } from "@/lib/api";
|
||||
import { IconButton } from "@/components/ui/icon-button";
|
||||
import toolbar from "@/components/ui/toolbar.module.css";
|
||||
import { guardEditingLease } from "./editing-lease";
|
||||
import { NodeEditing } from "./transformable-node";
|
||||
import { EdgeEditing } from "./edge-editing";
|
||||
import { HeadlessAIPanel } from "./headless-ai-panel";
|
||||
import { HeadlessResourcePicker } from "./headless-resource-picker";
|
||||
import { HeadlessImports } from "./headless-imports";
|
||||
import { HeadlessRecoveryDialog } from "./headless-recovery-dialog";
|
||||
import { UnsupportedNode, UnsupportedEdge } from "./unsupported-elements";
|
||||
import { ImagePreviews } from "./canvas-image";
|
||||
import styles from "./canvas.module.css";
|
||||
|
||||
const json = (value: unknown): Data => JSON.parse(JSON.stringify(value));
|
||||
function CanvasView({ engine, board, viewId, renderers,fitLoading=true }: { engine: ProjectEngine; board: Board; viewId: Id; renderers:ReactRenderers;fitLoading?:boolean }) {
|
||||
const sceneCache=useMemo(()=>createSceneProjection(),[engine,viewId]);
|
||||
const subscribe = useCallback((notify: (immediate?:boolean) => void) => {
|
||||
let before=engine.getSnapshot().document;
|
||||
return engine.editor.subscribe((commit,next)=>{sceneCache.advance(before,next.document,commit.changes);before=next.document;
|
||||
// Controlled inputs must publish their new value before the native event
|
||||
// finishes. Batch creation/background work may share a presentation frame.
|
||||
const immediate=commit.origin==='undo'||commit.origin==='redo'||commit.origin==='user'&&commit.changes.some(change=>change.kind==='field'||change.before!==null);
|
||||
notify(immediate);
|
||||
});
|
||||
}, [engine,sceneCache]);
|
||||
const rootFrames=useMemo(()=>createFrameSource({subscribe,getSnapshot:engine.getSnapshot},{request:callback=>requestAnimationFrame(callback),cancel:handle=>cancelAnimationFrame(handle as number)}),[engine,subscribe]);
|
||||
const root = useSyncExternalStore(rootFrames.subscribe, rootFrames.getSnapshot, rootFrames.getSnapshot);
|
||||
const sessionFrames=useMemo(()=>createSessionFrameSource(engine.sessions,{request:callback=>requestAnimationFrame(callback),cancel:handle=>cancelAnimationFrame(handle as number)}),[engine]);
|
||||
const sessions = useSyncExternalStore(sessionFrames.subscribe, sessionFrames.getSnapshot, sessionFrames.getSnapshot);
|
||||
const liveView = sessions.views.get(viewId), lastView = useRef(liveView);
|
||||
const loading=root.runtime.loading,needsLoadingFit=useRef(!!loading&&fitLoading);
|
||||
|
||||
if (liveView) lastView.current = liveView;
|
||||
const view = liveView ?? lastView.current!, flow = useReactFlow<ProjectionNode>();
|
||||
const canCreate=!loading||loading.version>=4&&engine.editor.getQueries(root.document).coverage.completeCanvasIds.includes(view.canvasId);
|
||||
useEffect(()=>{
|
||||
if(!needsLoadingFit.current||!flow.viewportInitialized||!root.document.canvases.has(view.canvasId))return;
|
||||
// Expanded descendants can extend far beyond their partition entry. Initial
|
||||
// framing belongs to the direct members of this canvas.
|
||||
const nodes=engine.editor.getQueries(root.document).members(view.canvasId).filter(id=>root.document.elements.get(id)?.structure==='node').map(id=>({id}));
|
||||
if(!nodes.length)return;
|
||||
needsLoadingFit.current=false;void flow.fitView({nodes,padding:.2,maxZoom:1});
|
||||
},[engine,flow,root.document,view.canvasId]);
|
||||
const preparingCopy = [...(root.runtime.imports?.values() ?? [])].some(job => job.status === 'preparing' && job.source?.targetCanvasId === view.canvasId);
|
||||
const [focusedEditor, setFocusedEditor] = useState<HTMLElement | null>(null), focusEpoch = useRef(0);
|
||||
useEffect(() => { focusEpoch.current++; setFocusedEditor(null); return () => { focusEpoch.current++; }; }, [engine,viewId]);
|
||||
// Removing an active renderer need not dispatch a native blur. Retire only
|
||||
// that detached input, without clearing a newer focus acquired in the meantime.
|
||||
useEffect(()=>{if(focusedEditor&&!focusedEditor.isConnected)setFocusedEditor(current=>current===focusedEditor?null:current);});
|
||||
const [copying, setCopying] = useState(false);
|
||||
const [resourcePicker,setResourcePicker]=useState<{canvasId:Id;position:{x:number;y:number}}|null>(null);
|
||||
const copyAvailability=useMemo(()=>engine.imports.canDuplicate([...view.selection].map(id=>root.document.elements.get(id)).filter((element):element is ElementRecord=>!!element).map(({id,incarnation})=>({id,incarnation})),{viewCanvasId:view.canvasId}),[engine,root,view.selection,view.canvasId]);
|
||||
const [showAI, setShowAI] = useState(false), [showRecovery, setShowRecovery] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [displayState, setDisplayState] = useState<{engine:ProjectEngine|null;viewId:Id|null;sources:Map<Id,ResourceViewSource>;failures:number}>({engine:null,viewId:null,sources:new Map(),failures:0});
|
||||
const resourceView=useRef<ReturnType<typeof createResourceView>|null>(null);
|
||||
const pointerStart = useRef<{ x: number; y: number;pointerId?:number } | null>(null), drag = useRef<{ token: number;pointerId?:number; start: { x: number; y: number }; positions: Map<Id, { x: number; y: number }> } | null>(null);
|
||||
const projectionCache = useRef<ReturnType<typeof createProjectionCache> | null>(null);
|
||||
projectionCache.current ??= createProjectionCache();
|
||||
const measurements = useRef(new Map<Id, { width: number; height: number; measured: { width: number; height: number } }>());
|
||||
const imageInput = useRef<HTMLInputElement>(null), groups = useRef(new Map<Id,{incarnation:number;groupId:Id}>());
|
||||
useEffect(()=>{for(const [id,group]of groups.current)if(root.document.elements.get(id)?.incarnation!==group.incarnation)groups.current.delete(id);},[root.document]);
|
||||
const catalog=useMemo(()=>loading?createLoadingRendererCatalog(engine.editor.registry,renderers.catalog):renderers.catalog,[engine,renderers,!!loading]);
|
||||
const scene=sceneCache.project(engine,root.document,view,sessions,engine.editor.registry,catalog,engine.editor.getQueries(root.document));
|
||||
const projection = useMemo(() => {
|
||||
const result = scene;
|
||||
// Preserve renderer measurements across metadata/preview updates. Dropping them
|
||||
// makes React Flow discard handle bounds and unmount active edge controls.
|
||||
const visibleIds = new Set(result.nodes.map(node => node.id));
|
||||
for (const id of measurements.current.keys()) if (!visibleIds.has(id)) measurements.current.delete(id);
|
||||
for (const node of result.nodes) { const cached = measurements.current.get(node.id); if (cached && cached.width === node.width && cached.height === node.height && projectionCache.current!.canRetainMeasurement(engine,root.document,view.canvasId,node)) node.measured = cached.measured; }
|
||||
return projectionCache.current!.reuse(engine,root.document,view.canvasId,result);
|
||||
}, [engine, root.document, scene,view.canvasId]);
|
||||
const surface=useRef<HTMLDivElement>(null),overlays=useMemo(()=>createOverlayIndex(viewId),[engine,viewId]);
|
||||
const overlayScope=useMemo(()=>({index:overlays,engine}),[overlays,engine]);
|
||||
const overlaySnapshot=useSyncExternalStore(overlays.subscribe,overlays.getSnapshot,overlays.getSnapshot);
|
||||
const surfaceBounds=surface.current?.getBoundingClientRect();
|
||||
|
||||
const paintCache=useMemo(()=>createProjectedPaintIndex(),[engine,view.canvasId]);
|
||||
const paint=useMemo(()=>paintCache.sync(projection,engine.editor.registry),[paintCache,projection,engine]);
|
||||
const viewport=useViewport(),surfaceWidth=useStore(state=>state.width),surfaceHeight=useStore(state=>state.height);
|
||||
const sceneViewport={x:-viewport.x/viewport.zoom,y:-viewport.y/viewport.zoom,width:surfaceWidth/viewport.zoom,height:surfaceHeight/viewport.zoom};
|
||||
const visibleOverlays=overlaySnapshot.length?(surfaceBounds?overlays.visible(surfaceBounds,target=>root.document.elements.get(target.id)?.incarnation===target.incarnation):overlaySnapshot.filter(entry=>root.document.elements.get(entry.target.id)?.incarnation===entry.target.incarnation)):[];
|
||||
const paintedNodes=[...paint.unknown,...paint.query(sceneViewport)];
|
||||
const nativeNodes=useStore(state=>state.nodes);
|
||||
const connectionPaintCache=useMemo(()=>createConnectionPaintIndex(),[engine,view.canvasId]);
|
||||
const connectionPaint=useMemo(()=>connectionPaintCache.sync(projection,engine.editor.registry,{anchor:(node,handle,end,edge)=>{const measured=flow.getInternalNode(node.id);return node.data.__headlessRecord||edge.type==='unsupported'?measuredConnector(measured,handle,end):compatConnector(node,handle,end==='source'?'right':'left');}}),[connectionPaintCache,projection,engine,flow,nativeNodes]);
|
||||
// Exhausted geometry work cannot prove any edge invisible. Retain the edge
|
||||
// layer and its endpoints, not unrelated nodes or a synthetic sentinel owner.
|
||||
const paintedEdges=connectionPaint.unknown.includes('paint_budget')?projection.edges.map(edge=>edge.id):[...connectionPaint.unknown,...connectionPaint.query(sceneViewport)];
|
||||
const nodeTypes=useMemo(()=>({...renderers.nodeTypes,unsupported:UnsupportedNode}),[renderers]),edgeTypes=useMemo(()=>({...renderers.edgeTypes,unsupported:UnsupportedEdge}),[renderers]);
|
||||
const current = useRef({ root, projection, view, engine, sessions }); current.current = { root, projection, view, engine, sessions };
|
||||
const report = useCallback((result: Result | { enabled: boolean; code?: string }) => {
|
||||
if ('status' in result && result.status === 'rejected' || 'enabled' in result && !result.enabled) setError(errorText(new Error(result.code)));
|
||||
}, []);
|
||||
const target = (id: Id) => { const element = engine.getSnapshot().document.elements.get(id); if (!element) throw new Error('element_missing'); return { id, incarnation: element.incarnation }; };
|
||||
const data = (id: Id, patch: Data, groupId?: Id) => report(engine.editor.dispatch({ type: 'element.data', target: target(id), patch }, { groupId }));
|
||||
function selectChanges(changes: readonly { id: Id; selected: boolean }[]) {
|
||||
const current = engine.sessions.getSnapshot().views.get(viewId)!;
|
||||
const ids = new Set(current.selection);
|
||||
for (const change of changes) if (change.selected) ids.add(change.id); else ids.delete(change.id);
|
||||
if (ids.size !== current.selection.size || [...ids].some(id => !current.selection.has(id))) engine.sessions.select(viewId, [...ids]);
|
||||
}
|
||||
function selectAtPane(event: {clientX:number;clientY:number}) {
|
||||
const captured=current.current;
|
||||
if(captured.view.tool!=='select'){engine.sessions.select(viewId,[]);return;}
|
||||
// Native handles stay in the renderer adapter; the core never reads DOM or guesses custom ports.
|
||||
const result=hitProjectedCanvas(captured.projection,engine.editor.getQueries(captured.root.document),engine.editor.registry,viewId,{
|
||||
space:'scene',point:flow.screenToFlowPosition({x:event.clientX,y:event.clientY}),zoom:flow.getViewport().zoom,tolerancePx:12,
|
||||
},{
|
||||
previewIds:new Set(captured.sessions.gesture?.targets.map(target=>target.id)),
|
||||
anchor:(node,handle,end,edge)=>{
|
||||
const measured=flow.getInternalNode(node.id);if(!measured)return undefined;
|
||||
return node.data.__headlessRecord||edge.type==='unsupported'?measuredConnector(measured,handle,end):compatConnector({...measured,position:measured.internals.positionAbsolute},handle,end==='source'?'right':'left');
|
||||
},
|
||||
layer:(id,kind)=>kind==='node'?flow.getInternalNode(id)?.internals.z:undefined,
|
||||
});
|
||||
// A possible hit is deliberately not selected; a stale frame must not select a new incarnation.
|
||||
if(engine.getSnapshot().document!==captured.root.document||engine.sessions.getSnapshot()!==captured.sessions)return;
|
||||
const hit=result.hits[0];
|
||||
engine.sessions.select(viewId,hit?.certainty==='confirmed'?[hit.target.id]:[]);
|
||||
}
|
||||
function beginTransform(id: Id) {
|
||||
const result = engine.sessions.begin(viewId, [target(id)]); report(result); if (!result.enabled) return false;
|
||||
const token = engine.sessions.getSnapshot().gesture!.token;
|
||||
const current = () => engine.sessions.getSnapshot().gesture?.token === token;
|
||||
return guardEditingLease(current, { preview: (change: Parameters<typeof transform>[1]) => transform(id, change), commit: () => report(engine.sessions.commit(viewId)), cancel: () => engine.sessions.cancel('pointer_cancel') });
|
||||
}
|
||||
function transform(id: Id, change: { position?: { x: number; y: number }; width?: number; height?: number; rotation?: number; fontSize?: number }) {
|
||||
const { root, projection } = current.current, element = root.document.elements.get(id);
|
||||
if (element?.structure !== 'node') return;
|
||||
const t = projection.transforms.get(element.ownerCanvasId)!;
|
||||
const displayed = projectionCache.current!.getNode(id)!;
|
||||
const width = (change.width ?? displayed.width!) / t.scale, height = (change.height ?? displayed.height!) / t.scale;
|
||||
const position = change.position ?? displayed.position, center = toLocal(t, { x: position.x + width * t.scale / 2, y: position.y + height * t.scale / 2 });
|
||||
const patch: Partial<Geometry> = { x: center.x - width / 2, y: center.y - height / 2, width, height, ...(change.rotation !== undefined ? { rotation: ((change.rotation - t.rotation) % 360 + 360) % 360 } : {}) };
|
||||
report(engine.sessions.update(viewId, target(id), patch));
|
||||
}
|
||||
function add(kind: 'text' | 'plain-text' | 'image' | 'frame', ref?: { id: Id; src: string }, tx?: Parameters<Parameters<typeof engine.editor.transaction>[0]>[0], at?: { x: number; y: number }) {
|
||||
const id = crypto.randomUUID(), canvasId = current.current.view.canvasId;
|
||||
const rect = window.document.querySelector('[data-headless-canvas]')?.getBoundingClientRect();
|
||||
const position = at ?? flow.screenToFlowPosition({ x: (rect?.left ?? 0) + (rect?.width ?? window.innerWidth) / 2, y: (rect?.top ?? 0) + (rect?.height ?? window.innerHeight) / 2 });
|
||||
const size = kind === 'frame' ? { width: 640, height: 400 } : kind === 'plain-text' ? { width: 240, height: 72 } : { width: 260, height: 200 };
|
||||
const element: ElementRecord = { id, incarnation: 1, structure: 'node', componentId: kind, dataVersion: 1, ownerCanvasId: canvasId, geometry: { ...position, ...size, rotation: 0 }, data: { title: kind === 'frame' ? '未命名分区' : kind === 'image' ? '视觉参考' : '灵感笔记', text: '', ...(ref ? { assetId: ref.id, src: ref.src } : {}) } };
|
||||
const create = (transaction: NonNullable<typeof tx>) => { transaction.dispatch({ type: 'element.create', element }); if (kind === 'frame') transaction.dispatch({ type: 'canvas.create', canvas: { id: crypto.randomUUID(), parent: { canvasId, entryElementId: id } } }); };
|
||||
if (tx) create(tx); else report(engine.editor.transaction(create, { label: 'Add element' }));
|
||||
return id;
|
||||
}
|
||||
function connect(connection: Connection) {
|
||||
if(engine.sessions.getSnapshot().views.get(viewId)?.tool!=='select')return;
|
||||
const document = engine.getSnapshot().document, source = document.elements.get(connection.source), destination = document.elements.get(connection.target);
|
||||
if (!source || !destination) return;
|
||||
if([source.id,destination.id].some(id=>projectionCache.current!.getNode(id)?.type==='unsupported')){setError('此元素暂不支持创建新连线');return;}
|
||||
report(engine.editor.dispatch({ type: 'element.create', element: { id: crypto.randomUUID(), incarnation: 1, componentId: 'connection', dataVersion: 1, structure: 'connection', ownerCanvasId: commonCanvas(document, source.ownerCanvasId, destination.ownerCanvasId), source: { elementId: source.id, ...(connection.sourceHandle ? { portId: connection.sourceHandle } : {}) }, target: { elementId: destination.id, ...(connection.targetHandle ? { portId: connection.targetHandle } : {}) }, data: {}, path: null } }));
|
||||
}
|
||||
const resourceKey = JSON.stringify(projection.nodes.filter(node => typeof node.data.assetId==='string'&&node.data.assetId).map(node => [node.id, node.data.assetId]));
|
||||
useEffect(() => {
|
||||
if (!liveView) return;
|
||||
const view=createResourceView({resolve:(id,signal)=>engine.displayResource(id,{signal}),onChange:(sources,failures)=>{if(resourceView.current===view)setDisplayState({engine,viewId,sources,failures});},onError:error=>{if(resourceView.current===view)setError(errorText(error));}});
|
||||
resourceView.current=view;
|
||||
return ()=>{if(resourceView.current===view)resourceView.current=null;view.dispose();};
|
||||
}, [engine,viewId,Boolean(liveView)]);
|
||||
useEffect(()=>{try{resourceView.current?.sync(JSON.parse(resourceKey) as [Id,Id][]);}catch(error){setError(errorText(error));}},[engine,viewId,resourceKey,Boolean(liveView)]);
|
||||
const sources=useMemo(()=>{if(displayState.engine!==engine||displayState.viewId!==viewId)return new Map<Id,string>();const wanted=new Map(JSON.parse(resourceKey) as [Id,Id][]);return new Map([...displayState.sources].filter(([id,source])=>wanted.get(id)===source.resourceId).map(([id,source])=>[id,source.url]));},[displayState,engine,viewId,resourceKey]);
|
||||
function removeSelected() { report(engine.editor.transaction(tx => { for (const id of current.current.view.selection) if (tx.getDocument().elements.has(id)) tx.dispatch({ type: 'element.remove', target: target(id) }); }, { label: 'Delete selection' })); }
|
||||
const pending = [...root.runtime.ledger.operations.values()].filter(operation => operation.phase !== 'resolved');
|
||||
const blocked = pending.some(operation => operation.phase === 'blocked' && operation.blockers.some(reason => reason !== 'waiting_digest')) || [...root.runtime.ledger.requests.values()].some(request => request.phase !== 'resolved' && request.error && request.retryAt === undefined) || root.runtime.conflicts.size > 0;
|
||||
useEffect(() => {
|
||||
const cancel = () => { const state = engine.sessions.getSnapshot(); if (state.gesture?.viewId === viewId || state.connectionGesture?.viewId === viewId) engine.sessions.cancel('view_interrupted'); drag.current = null; };
|
||||
// Bubble after the target/React handlers: an input owner can claim Escape first.
|
||||
const escape = (event: KeyboardEvent) => {
|
||||
if(event.key!=='Escape')return;
|
||||
const claim=routeCanvasKey({key:event.key,composing:event.isComposing,defaultPrevented:event.defaultPrevented,...canvasKeyContext(event)},{gesture:()=>{const state=engine.sessions.getSnapshot();if(state.gesture?.viewId!==viewId&&state.connectionGesture?.viewId!==viewId)return false;cancel();return true;}});
|
||||
if(claim==='gesture'){event.preventDefault();event.stopPropagation();}
|
||||
};
|
||||
window.addEventListener('blur', cancel); window.addEventListener('keydown', escape);
|
||||
return () => { window.removeEventListener('blur', cancel); window.removeEventListener('keydown', escape); };
|
||||
}, [engine, viewId]);
|
||||
const modes = view.modes;
|
||||
// Retain explicit owners in the native renderer, leaving the full graph in its
|
||||
// lookup for edges/queries. No synthetic dragging or altered geometry is used.
|
||||
const retained=new Set<Id>([...modes.keys(),...paintedNodes,...paintedEdges]);
|
||||
for(const entry of visibleOverlays)retained.add(entry.target.id);
|
||||
if(sessions.gesture?.viewId===viewId)for(const target of sessions.gesture.targets)retained.add(target.id);
|
||||
if(sessions.connectionGesture?.viewId===viewId)retained.add(sessions.connectionGesture.target.id);
|
||||
const activeEditor=focusedEditor?.isConnected?focusedEditor:null;
|
||||
const focusedOverlay=activeEditor?.closest<HTMLElement>('[data-canvas-overlay-target]');
|
||||
const focusedUI=activeEditor?.closest<HTMLElement>('[data-canvas-ui-view]');
|
||||
const independentFocus=focusedUI?.dataset.canvasUiView===viewId;
|
||||
const focusedNative=activeEditor?.closest<HTMLElement>('.react-flow__node,.react-flow__edge');
|
||||
const focusedId=focusedOverlay?.dataset.canvasOverlayView===viewId?focusedOverlay.dataset.canvasOverlayTarget:focusedNative?.closest('[data-headless-canvas]')===surface.current?focusedNative?.dataset.id:undefined;
|
||||
if(focusedId)retained.add(focusedId);
|
||||
// Retained connections also keep their measured endpoint owners alive.
|
||||
for(const id of [...retained]){const element=root.document.elements.get(id);if(element?.structure==='connection'){retained.add(element.source.elementId);retained.add(element.target.elementId);}}
|
||||
const scoped=!(activeEditor&&!focusedId&&!independentFocus)&&[...retained].every(id=>root.document.elements.has(id));
|
||||
const cullVisible=!!surfaceWidth&&!!surfaceHeight&&scoped;
|
||||
const retainedProjection=useMemo(()=>createRetainedProjection(),[engine,viewId]);
|
||||
const rendered=retainedProjection(projection.nodes,projection.edges,retained);
|
||||
const selected = view.selection.size === 1 ? root.document.elements.get([...view.selection][0]) : undefined;
|
||||
const selectedResource=selected?.structure==='node'&&engine.editor.registry.resolve(selected)==='known'&&engine.editor.registry.supports(selected,'geometry.resize')&&typeof selected.data.assetId==='string'&&engine.editor.registry.components.get(selected.componentId)?.resourceFields?.includes('assetId')?root.runtime.resources.get(selected.data.assetId):undefined;
|
||||
const canFitImage=!!selectedResource&&(selectedResource.image?.version===1&&selectedResource.image.status==='ready'||!!selectedResource.stored)&&!sessions.gesture;
|
||||
const aiElement = selected?.structure === 'node' && engine.editor.registry.resolve(selected) === 'known' && ['text','plain-text','script','image','plain-image'].includes(selected.componentId) ? selected : undefined;
|
||||
// Runtime-only publications must not invalidate every card's editing context.
|
||||
// Actions use current.current / engine snapshots instead of captured document projections.
|
||||
const rotating = [...modes].find(([, mode]) => mode === 'rotate')?.[0] ?? null;
|
||||
const resizing = [...modes].find(([, mode]) => mode === 'resize')?.[0] ?? null;
|
||||
const imagePreviews = useMemo<ContextType<typeof ImagePreviews>>(() => ({sources,loaded:()=>{}}),[sources]);
|
||||
const nodeEditing = useMemo<ContextType<typeof NodeEditing>>(() => ({ workspace: board.workspace_id, schema: 2,
|
||||
begin: id => { if (id) { groups.current.set(id,{incarnation:target(id).incarnation,groupId:crypto.randomUUID()}); engine.sessions.mode(viewId, id, 'editing'); } },
|
||||
endEditing: id => { groups.current.delete(id); engine.sessions.mode(viewId, id, null); },
|
||||
select: id => engine.sessions.select(viewId, [id]), edit: (id, text) => data(id,{text},groups.current.get(id)?.incarnation===engine.getSnapshot().document.elements.get(id)?.incarnation?groups.current.get(id)?.groupId:undefined), rename: (id, title) => data(id, { title }),
|
||||
beginTransform, transform, finishTransform: () => { if (engine.sessions.getSnapshot().gesture?.viewId === viewId) report(engine.sessions.commit(viewId)); }, cancelTransform: () => { if (engine.sessions.getSnapshot().gesture?.viewId === viewId) engine.sessions.cancel('pointer_cancel'); },
|
||||
style: (id, textStyle, fontSize) => data(id, json({ textStyle, ...(fontSize !== undefined ? { fontSize } : {}) })), frame: (id, patch) => data(id, json(patch)),
|
||||
rotating, rotate: id => { for (const [key, mode] of engine.sessions.getSnapshot().views.get(viewId)?.modes ?? []) if (mode === 'rotate') engine.sessions.mode(viewId, key, null); if (id) engine.sessions.mode(viewId, id, 'rotate'); },
|
||||
resizing, resize: id => { for (const [key, mode] of engine.sessions.getSnapshot().views.get(viewId)?.modes ?? []) if (mode === 'resize') engine.sessions.mode(viewId, key, null); if (id) engine.sessions.mode(viewId, id, 'resize'); },
|
||||
branch: (id, direction) => {
|
||||
const node = projectionCache.current!.getNode(id); if (!node) return;
|
||||
const dx = direction === 'left' ? -340 : direction === 'right' ? 340 : 0, dy = direction === 'top' ? -280 : direction === 'bottom' ? 280 : 0;
|
||||
report(engine.editor.transaction(tx => { const created = add('text', undefined, tx, { x: node.position.x + dx, y: node.position.y + dy }); const source = tx.getDocument().elements.get(id)!, next = tx.getDocument().elements.get(created)!; tx.dispatch({ type: 'element.create', element: { id: crypto.randomUUID(), incarnation: 1, componentId: 'connection', dataVersion: 1, structure: 'connection', ownerCanvasId: commonCanvas(tx.getDocument(), source.ownerCanvasId, next.ownerCanvasId), source: { elementId: id, portId: direction }, target: { elementId: created }, data: {}, path: null } }); }, { label: 'Add branch' }));
|
||||
},
|
||||
|
||||
}),[engine,viewId,board.workspace_id,flow,report,rotating,resizing]);
|
||||
const edgeEditing = useMemo<ContextType<typeof EdgeEditing>>(() => ({ active: new Set(), begin: id => {
|
||||
if (!id) return; const result = engine.sessions.beginConnection(viewId, target(id)); report(result); if (!result.enabled) return false;
|
||||
const token = engine.sessions.getSnapshot().connectionGesture!.token, element = engine.getSnapshot().document.elements.get(id)!;
|
||||
const transform = current.current.projection.transforms.get(element.ownerCanvasId)!;
|
||||
const ownsGesture = () => engine.sessions.getSnapshot().connectionGesture?.token === token;
|
||||
return guardEditingLease(ownsGesture, { preview: (path: NonNullable<import('@/lib/canvas-v2').EdgeData['path']>) => report(engine.sessions.updateConnection(viewId, transformPath(path as Json, transform, true))), commit: () => report(engine.sessions.commitConnection(viewId)), cancel: () => engine.sessions.cancel('pointer_cancel') });
|
||||
},
|
||||
finish: () => { if (engine.sessions.getSnapshot().connectionGesture?.viewId === viewId) report(engine.sessions.commitConnection(viewId)); }, cancel: () => { if (engine.sessions.getSnapshot().connectionGesture?.viewId === viewId) engine.sessions.cancel('pointer_cancel'); },
|
||||
patch: (id, value, transient) => {
|
||||
const element = engine.getSnapshot().document.elements.get(id); if (element?.structure !== 'connection') return;
|
||||
const t = current.current.projection.transforms.get(element.ownerCanvasId)!;
|
||||
const path = transformPath((value.path ?? null) as Json, t, true);
|
||||
const displayedPath = current.current.projection.edges.find(edge => edge.id === id)?.data?.path;
|
||||
const changedPath = Object.hasOwn(value, 'path') && !equal(value.path, displayedPath);
|
||||
if (transient) { report(engine.sessions.updateConnection(viewId, path)); return; }
|
||||
const { path: ignored, ...rest } = value; void ignored;
|
||||
report(engine.editor.transaction(tx => { tx.dispatch({ type: 'element.data', target: target(id), patch: json(rest) }); if (changedPath) tx.dispatch({ type: 'connection.path', target: target(id), path }); }, { label: 'Edit connection' }));
|
||||
},
|
||||
|
||||
}),[engine,viewId,report]);
|
||||
const rendererCommands=useMemo<CanvasRendererCommands>(()=>({dispatch(command,options){const result=engine.editor.dispatch(command,{label:options?.label,groupId:options?.groupId});report(result);return result;},transaction(callback,options){const result=engine.editor.transaction(callback,{label:options?.label,groupId:options?.groupId});report(result);return result;}}),[engine,report]);
|
||||
if (!liveView) return <p role="status">画布已关闭</p>;
|
||||
return <div ref={surface} className={`${styles.editor} ${view.tool === 'pan' ? styles.handMode : ''}`} data-headless-canvas data-model-loading={loading?"true":undefined} tabIndex={-1}
|
||||
onFocusCapture={event => { if ((event.target as HTMLElement).closest('input,textarea,[contenteditable="true"],[role="textbox"]')) { focusEpoch.current++; setFocusedEditor(event.target as HTMLElement); } }}
|
||||
onBlurCapture={() => { const epoch = ++focusEpoch.current; queueMicrotask(() => { if (focusEpoch.current === epoch) setFocusedEditor(null); }); }}
|
||||
onMouseDownCapture={event => { if (event.button === 0 && (event.target as HTMLElement).closest('.react-flow__node')) pointerStart.current = {...flow.screenToFlowPosition({ x: event.clientX, y: event.clientY }),pointerId:pointerStart.current?.pointerId}; }}
|
||||
onTouchStartCapture={event => { if (event.touches.length > 1) { const state=engine.sessions.getSnapshot();if(state.gesture?.viewId===viewId||state.connectionGesture?.viewId===viewId)engine.sessions.cancel('multi_touch');drag.current = null;pointerStart.current=null;return; } const touch = event.touches[0]; if (touch && (event.target as HTMLElement).closest('.react-flow__node')) pointerStart.current = {...flow.screenToFlowPosition({ x: touch.clientX, y: touch.clientY }),pointerId:pointerStart.current?.pointerId}; }}
|
||||
onPointerDownCapture={event => { if (event.button === 0 && (event.target as HTMLElement).closest('.react-flow__node')) pointerStart.current = {...flow.screenToFlowPosition({ x: event.clientX, y: event.clientY }),pointerId:event.pointerId}; }}
|
||||
onPointerCancelCapture={event => { const currentDrag=drag.current;if(!currentDrag||currentDrag.pointerId!==undefined&¤tDrag.pointerId!==event.pointerId)return;const state=engine.sessions.getSnapshot();if(state.gesture?.viewId===viewId&&state.gesture.token===currentDrag.token)engine.sessions.cancel('pointer_cancel');drag.current=null; }}
|
||||
onKeyDown={event => {
|
||||
const claim=routeCanvasKey({key:event.key,composing:event.nativeEvent.isComposing,...canvasKeyContext(event.nativeEvent),defaultPrevented:event.defaultPrevented,meta:event.metaKey,ctrl:event.ctrlKey,alt:event.altKey,shift:event.shiftKey},{
|
||||
gesture:input=>{const state=engine.sessions.getSnapshot();if(input.key!=='Escape'||state.gesture?.viewId!==viewId&&state.connectionGesture?.viewId!==viewId)return false;engine.sessions.cancel('escape');drag.current=null;return true;},
|
||||
tool:input=>{if(input.ctrl||input.meta||input.alt||input.shift)return false;const key=input.key.toLowerCase();if(key!=='h'&&key!=='v')return false;engine.sessions.tool(viewId,key==='h'?'pan':'select');return true;},
|
||||
shortcut:input=>{if(input.alt)return false;if((input.meta||input.ctrl)&&input.key.toLowerCase()==='z'){report(input.shift?engine.editor.history.redo():engine.editor.history.undo());return true;}if(!input.meta&&!input.ctrl&&!input.shift&&(input.key==='Delete'||input.key==='Backspace')&¤t.current.view.selection.size){removeSelected();return true;}return false;},
|
||||
});
|
||||
if(claim==='gesture'||claim==='tool'||claim==='shortcut'){event.preventDefault();event.stopPropagation();}
|
||||
}}>
|
||||
<CanvasOverlays.Provider value={overlayScope}><ImagePreviews.Provider value={imagePreviews}>
|
||||
<CanvasRendererCommandsContext.Provider value={rendererCommands}>
|
||||
<NodeEditing.Provider value={nodeEditing}>
|
||||
<EdgeEditing.Provider value={edgeEditing}>
|
||||
<ReactFlow<ProjectionNode> elevateEdgesOnSelect onlyRenderVisibleElements={cullVisible} nodes={rendered.nodes} edges={rendered.edges} nodeTypes={nodeTypes} edgeTypes={edgeTypes} connectionMode={ConnectionMode.Loose}
|
||||
nodesConnectable={view.tool === 'select'} nodesDraggable={view.tool === 'select'} panOnDrag={view.tool === 'pan' ? true : [1, 2]} selectionOnDrag={view.tool === 'select'} panOnScroll zoomOnScroll={false} minZoom={.1} maxZoom={4} deleteKeyCode={null} defaultViewport={view.viewport}
|
||||
|
||||
onMoveEnd={(_, viewport) => engine.sessions.viewport(viewId, viewport)}
|
||||
onNodeDragStart={(event, node, nodes) => {
|
||||
const ids = new Set((nodes.length ? nodes : [node]).map(node => node.id));
|
||||
const top = [...ids].filter(id => { const element = current.current.root.document.elements.get(id)!; return !ancestors(current.current.root.document, element.ownerCanvasId).some(canvasId => { const parent = current.current.root.document.canvases.get(canvasId)?.parent; return parent && ids.has(parent.entryElementId); }); });
|
||||
const point = 'clientX' in event ? event : event.changedTouches[0];
|
||||
drag.current = { token: 0,pointerId:pointerStart.current?.pointerId, start: pointerStart.current ?? flow.screenToFlowPosition({ x: point.clientX, y: point.clientY }), positions: new Map(top.map(id => [id, projectionCache.current!.getNode(id)!.position])) };
|
||||
const result = engine.sessions.begin(viewId, top.map(target)); report(result);
|
||||
if (result.enabled) drag.current.token = engine.sessions.getSnapshot().gesture!.token; else drag.current = null;
|
||||
}}
|
||||
onEdgesChange={changes => selectChanges(changes.filter(change => change.type === 'select'))}
|
||||
onNodesChange={changes => { for (const change of changes) if (change.type === 'dimensions' && change.dimensions) { const node = projectionCache.current!.getNode(change.id); if (node) measurements.current.set(change.id, { width: node.width!, height: node.height!, measured: { ...change.dimensions } }); } selectChanges(changes.filter(change => change.type === 'select')); for (const change of changes) if (change.type === 'position' && change.position && engine.sessions.getSnapshot().gesture?.viewId === viewId && drag.current?.token === engine.sessions.getSnapshot().gesture?.token && engine.sessions.getSnapshot().gesture?.targets.some(target => target.id === change.id)) transform(change.id, { position: change.position }); }}
|
||||
onNodeDragStop={event => {
|
||||
const saved = drag.current; drag.current = null; pointerStart.current = null;
|
||||
if (engine.sessions.getSnapshot().gesture?.viewId !== viewId || !saved || engine.sessions.getSnapshot().gesture?.token !== saved.token) return;
|
||||
const point = 'clientX' in event ? event : event.changedTouches[0], end = flow.screenToFlowPosition({ x: point.clientX, y: point.clientY });
|
||||
for (const [id, start] of saved.positions) transform(id, { position: { x: start.x + end.x - saved.start.x, y: start.y + end.y - saved.start.y } });
|
||||
report(engine.sessions.commit(viewId));
|
||||
}}
|
||||
onConnect={connect} onPaneClick={selectAtPane}>
|
||||
<Background variant={BackgroundVariant.Dots} gap={24} size={1} color="#ded7ce" />
|
||||
</ReactFlow>
|
||||
</EdgeEditing.Provider></NodeEditing.Provider></CanvasRendererCommandsContext.Provider></ImagePreviews.Provider></CanvasOverlays.Provider>
|
||||
<div data-canvas-ui-view={viewId} className={styles.topRight}><div className={`${toolbar.surface} ${styles.toolbar}`} role="toolbar" aria-label="图谱视图控制">
|
||||
<IconButton icon={Undo2} label="撤销" disabled={!root.history.undo.length} onClick={() => report(engine.editor.history.undo())} /><IconButton icon={Redo2} label="重做" disabled={!root.history.redo.length} onClick={() => report(engine.editor.history.redo())} />
|
||||
<IconButton icon={Copy} label={copying || preparingCopy ? "正在准备复制" : "深复制选中内容"} disabled={copying || preparingCopy || !copyAvailability.enabled} onClick={() => {
|
||||
const captured = current.current, selection = [...captured.view.selection].map(id => captured.root.document.elements.get(id)).filter((item): item is ElementRecord => !!item).map(({id, incarnation}) => ({id, incarnation}));
|
||||
setCopying(true); setError("");
|
||||
void engine.imports.duplicate(selection, {viewCanvasId: captured.view.canvasId}).catch(error => { if (current.current.engine === engine && current.current.view.id === viewId) setError(errorText(error)); }).finally(() => { if (current.current.engine === engine && current.current.view.id === viewId) setCopying(false); });
|
||||
}} />
|
||||
<IconButton icon={Minus} label="缩小" onClick={() => flow.zoomOut()} /><IconButton icon={Plus} label="放大" onClick={() => flow.zoomIn()} /><IconButton icon={Maximize} label="适应画布" onClick={() => flow.fitView({ padding: .2 })} />
|
||||
<IconButton icon={Download} label="导出图谱" disabled={!!loading} onClick={() => { const snapshot = engine.getSnapshot(), url = URL.createObjectURL(new Blob([JSON.stringify({ documentFormatVersion: 1, projectId: snapshot.document.projectId, canvases: [...snapshot.document.canvases.values()], elements: [...snapshot.document.elements.values()], tombstones: Object.fromEntries(snapshot.document.tombstones) })], { type: 'application/json' })); const a = window.document.createElement('a'); a.href = url; a.download = board.title + '.json'; a.click(); setTimeout(() => URL.revokeObjectURL(url), 1000); }} />
|
||||
</div><p role="status" style={{ fontSize: 11 }}>{blocked ? '有修改需要处理' : loading ? `正在加载… ${loading.receivedElements}/${loading.totalElements},${loading.version>=3?"按已就绪范围同步":"修改已在本地排队"}` : pending.length ? '正在同步…' : '已同步'}{blocked && <><button onClick={() => void engine.retry().catch(e => setError(errorText(e)))}>重试</button><button aria-label="处理同步修改" onClick={() => setShowRecovery(true)}>处理同步修改</button></>}</p></div>
|
||||
<div data-canvas-ui-view={viewId} className={`${toolbar.surface} ${toolbar.relaxed} ${styles.toolbar} ${styles.bottomToolbar}`} role="toolbar" aria-label="画布创作工具">
|
||||
<IconButton icon={MousePointer2} label="选择工具(V)" aria-pressed={view.tool === 'select'} onClick={() => engine.sessions.tool(viewId, 'select')} /><IconButton icon={Hand} label="抓手工具(H)" aria-pressed={view.tool === 'pan'} onClick={() => engine.sessions.tool(viewId, 'pan')} />
|
||||
<IconButton icon={Ratio} label="按图片比例调整" disabled={!!loading||!canFitImage} onClick={()=>{if(selected&&selectedResource)report(engine.fitImage({id:selected.id,incarnation:selected.incarnation},selectedResource.id));}} />
|
||||
<IconButton icon={Sparkles} label="AI 创作" disabled={!!loading||!aiElement} aria-pressed={showAI} onClick={() => setShowAI(value => !value)} />
|
||||
<IconButton icon={Type} label="添加纯文本" disabled={!canCreate} onClick={() => add('plain-text')} /><IconButton icon={StickyNote} label="添加文本卡片" disabled={!canCreate} onClick={() => add('text')} /><IconButton icon={ImagePlus} label="添加图片" disabled={!engine.canStageFiles(view.canvasId).enabled} onClick={() => imageInput.current?.click()} /><IconButton icon={Images} label="复用其它画布图片" disabled={!engine.canStageFiles(view.canvasId).enabled} onClick={()=>{const rect=surface.current?.getBoundingClientRect();setResourcePicker({canvasId:view.canvasId,position:flow.screenToFlowPosition({x:(rect?.left??0)+(rect?.width??window.innerWidth)/2,y:(rect?.top??0)+(rect?.height??window.innerHeight)/2})});}} /><IconButton icon={Frame} label="添加框选区" disabled={!canCreate} onClick={() => add('frame')} />
|
||||
</div>
|
||||
<input data-canvas-ui-view={viewId} ref={imageInput} type="file" accept="image/png,image/jpeg,image/gif,image/webp" multiple hidden onChange={event => { const files = [...event.target.files ?? []]; event.target.value = ''; try { report(engine.stageFiles(files.map(file => ({ name: file.name, bytes: file, defaultSize: true })), (refs, tx) => { for (const ref of refs) add('image', ref, tx); })); } catch (e) { setError(errorText(e)); } }} />
|
||||
{(!loading||loading.version>=5)&&<HeadlessImports viewId={viewId} engine={engine} canvasId={view.canvasId} />}
|
||||
{resourcePicker&&resourcePicker.canvasId===view.canvasId&&<HeadlessResourcePicker viewId={viewId} engine={engine} workspaceId={board.workspace_id} canvasId={resourcePicker.canvasId} position={resourcePicker.position} close={()=>setResourcePicker(null)} onAdded={id=>engine.sessions.select(viewId,[id])} />}
|
||||
{showAI && aiElement && <HeadlessAIPanel viewId={viewId} key={`${aiElement.id}:${aiElement.incarnation}:${aiElement.ownerCanvasId}`} engine={engine} element={aiElement} close={() => setShowAI(false)} />}
|
||||
{showRecovery && <HeadlessRecoveryDialog viewId={viewId} engine={engine} close={() => setShowRecovery(false)} />}
|
||||
{error && <div className={styles.error} role="alert">{error} {displayState.engine===engine&&displayState.viewId===viewId&&displayState.failures>0&&<button onClick={()=>{setError('');resourceView.current?.retry();}}>重试图片</button>}<button onClick={() => setError('')}>关闭</button></div>}
|
||||
</div>;
|
||||
}
|
||||
export default function HeadlessGraphCanvas({ engine, board, renderers=defaultCanvasRenderers,initialViewport }: { engine: ProjectEngine; board: Board; renderers?:ReactRenderers;initialViewport?:Viewport }) {
|
||||
const [handoffViewport]=useState(initialViewport);
|
||||
const [viewId] = useState(() => crypto.randomUUID()), [attached, setAttached] = useState(false);
|
||||
useEffect(() => { const detach = engine.sessions.attach(viewId, board.id);if(handoffViewport)engine.sessions.viewport(viewId,handoffViewport); setAttached(true); return () => { setAttached(false); detach(); }; }, [engine, viewId, board.id,handoffViewport]);
|
||||
return attached ? <ReactFlowProvider><CanvasProvider engine={engine} viewId={viewId}><CanvasView engine={engine} board={board} viewId={viewId} renderers={renderers} fitLoading={!handoffViewport} /></CanvasProvider></ReactFlowProvider> : <p role="status">正在准备画布…</p>;
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useRef, useState, useSyncExternalStore } from "react";
|
||||
import { FolderInput } from "lucide-react";
|
||||
import type { ProjectEngine } from "@/lib/canvas-runtime/workspace";
|
||||
import { IconButton } from "@/components/ui/icon-button";
|
||||
import { errorText } from "@/lib/api";
|
||||
import styles from "./canvas.module.css";
|
||||
|
||||
const reason = (code: string) => ({ import_asset_authorization_failed: "资源访问授权失败,此记录未加入", import_asset_capacity: "本组资源过大,无法一次准备", asset_depot_unavailable: "资源仓库暂不可用", import_resource_authorization_required: "资源需要重新授权,此记录尚未加入", invalid_document_import: "导出文档结构不合法", invalid_json_array: "JSON 记录数组格式错误", incomplete_json_array: "JSON 数组尾部不完整", json_nesting_limit: "JSON 嵌套层数过多", invalid_json: "记录格式错误", invalid_json_value: "记录包含不支持的数据", invalid_utf8: "文件编码不是 UTF-8", record_too_large: "单条记录过大", unresolved_dependency: "引用的记录未加入", invalid_native_import_record: "不是画布记录", import_component_unavailable: "元素类型暂不支持", invalid_geometry: "元素尺寸不合法", invalid_endpoint: "连线端点已不存在", duplicate_import_source_key: "记录标识重复", import_source_read_failed: "暂时无法读取源文件,可重试;已加入内容保留", import_source_verification_failed: "源文件校验失败,可重试;已加入内容保留", invalid_import_read_proof: "源文件读取证据不一致,任务已暂停", import_source_unavailable: "导入源文件不可用", import_source_changed: "源文件内容与保存时不一致,任务已暂停", import_source_integrity_unavailable: "旧任务缺少源内容校验,无法安全继续;已加入内容保留", import_source_integrity_missing: "源校验记录缺失,任务已暂停", import_source_integrity_interpreter_unavailable: "源校验版本暂不支持", import_target_missing: "目标分区已不存在", import_target_changed: "目标分区已改变", import_dependency_changed: "引用对象已删除或恢复为新版本", dependency_capacity: "等待引用过多,请调整文件顺序", copy_external_changed: "原图引用已删除、移动或恢复为新版本", invalid_duplicate_source: "复制源数据不可用", invalid_copy_snapshot: "复制快照不可用", invalid_copy_preparation: "复制准备记录不可用", copy_interpreter_changed: "复制规则已变化,需要重新复制", invalid_import_source: "文件过大或格式不支持" } as Record<string,string>)[code] ?? "这条记录暂时无法加入";
|
||||
|
||||
export function HeadlessImports({ engine, canvasId, viewId }: { engine: ProjectEngine; canvasId: string; viewId:string }) {
|
||||
const subscribe = useCallback((notify: () => void) => engine.editor.subscribe(notify), [engine]);
|
||||
const snapshot = useSyncExternalStore(subscribe, engine.getSnapshot, engine.getSnapshot);
|
||||
const input = useRef<HTMLInputElement>(null), [open, setOpen] = useState(false), [error, setError] = useState("");
|
||||
const scope = useRef({ engine, canvasId }); scope.current = { engine, canvasId };
|
||||
const jobs = [...(snapshot.runtime.imports?.values() ?? [])].filter(job => job.source?.targetCanvasId === canvasId).slice(-3).reverse();
|
||||
const control = (work: Promise<void>) => { void work.then(() => { if (scope.current.engine === engine && scope.current.canvasId === canvasId) setError(""); }).catch(error => { if (scope.current.engine === engine && scope.current.canvasId === canvasId) setError(errorText(error)); }); };
|
||||
return <aside data-canvas-ui-view={viewId} className={styles.importPanel} aria-label="画布导入">
|
||||
<div className={styles.importActions}><IconButton icon={FolderInput} label="导入画布记录" disabled={!engine.imports.canStart(canvasId).enabled} onClick={() => input.current?.click()} /><span>NDJSON / JSON 记录数组</span></div>
|
||||
<input data-canvas-import ref={input} type="file" accept=".ndjson,.jsonl,.json,application/json,application/x-ndjson" hidden onChange={async event => {
|
||||
const file = event.target.files?.[0]; event.target.value = ""; if (!file) return;
|
||||
try { if(file.name.toLowerCase().endsWith('.json')){const before=engine.getSnapshot().document,target=before.canvases.get(canvasId),parent=JSON.stringify(target?.parent),entry=target?.parent?before.elements.get(target.parent.entryElementId)?.incarnation:undefined;const prefix=(await file.slice(0,65536).text()).trimStart()[0];if(scope.current.engine!==engine||scope.current.canvasId!==canvasId)return;const current=engine.getSnapshot().document,now=current.canvases.get(canvasId);if(!target||!now||JSON.stringify(now.parent)!==parent||(now.parent?current.elements.get(now.parent.entryElementId)?.incarnation:undefined)!==entry)throw Error('import_target_changed');if(prefix==='{')engine.imports.startDocument(file,canvasId);else if(prefix==='[')engine.imports.startJsonArray(file,canvasId);else throw Error('invalid_import_source');}else engine.imports.startNdjson(file,canvasId); setOpen(true); setError(""); } catch (error) { if(scope.current.engine===engine&&scope.current.canvasId===canvasId)setError(errorText(error)); }
|
||||
}} />
|
||||
{jobs.length > 0 && <details open={open} onToggle={event => setOpen(event.currentTarget.open)}><summary>导入与复制任务 · {jobs.some(job => job.status === "preparing") && "正在准备复制 · "}{jobs.reduce((count, job) => count + job.committedSourceKeys.length, 0)} 项已加入</summary>
|
||||
{jobs.map((job, index) => <section className={styles.importJob} key={job.id} aria-label={`导入任务 ${index + 1}`}>
|
||||
<strong>{job.source?.name || `导入任务 ${index + 1}`}</strong>
|
||||
<p role="status">{["running","preparing"].includes(job.status) && job.source?.integrityVersion === 1 && !job.source.sha256 ? "正在校验源文件" : job.status === "preparing" ? "正在准备复制" : job.status === "running" ? "正在加入" : job.status === "paused" ? "已暂停" : job.status === "cancelled" ? "已停止" : job.status === "partial" ? "部分加入" : "已加入画布"} · {job.committedSourceKeys.length} 项{job.pending.length > 0 && `,${job.pending.length} 项等待`}</p>
|
||||
{!(job.status==='cancelled'&&!job.committedSourceKeys.length)&&job.source?.integrityVersion===2&&!job.source.sha256&&!job.source.integrityError&&<small>内容可继续编辑,源文件校验完成后同步。</small>}
|
||||
{job.source?.retirement?.purgedAt !== undefined && <small>本地源文件已清理;画布内容和撤销记录保留。</small>}
|
||||
{job.error && <p>{reason(job.error)}</p>}
|
||||
<div className={styles.importActions}>
|
||||
{job.source?.integrityError&&<button onClick={()=>control(engine.retry())}>重试源文件校验</button>}
|
||||
{["running","preparing"].includes(job.status) && <button onClick={() => control(engine.imports.pause(job.id))}>暂停导入</button>}
|
||||
{job.status === "paused" && <button onClick={() => control(engine.imports.resume(job.id))}>继续导入</button>}
|
||||
{["running","preparing","paused"].includes(job.status) && <button onClick={() => control(engine.imports.cancel(job.id))}>停止导入</button>}
|
||||
</div>
|
||||
{job.status === "cancelled" && <small>已加入的内容保留并继续同步。</small>}
|
||||
{job.failures.length > 0 && <details><summary>{job.failures.length} 条未加入记录</summary><ul>{job.failures.slice(0, 10).map((failure, item) => <li key={`${failure.key}:${item}`}>{failure.key.startsWith("byte:") ? `文件位置 ${failure.cursor}` : failure.key}:{reason(failure.code)}</li>)}</ul></details>}
|
||||
</section>)}
|
||||
</details>}
|
||||
{error && <p role="alert">{error}{jobs.length > 0 && <button onClick={() => control(engine.retry())}>重试保存</button>}</p>}
|
||||
</aside>;
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import type { Change, Document, Id, Json, OperationIntent } from '@/lib/canvas-core/types';
|
||||
import type { ProjectEngine } from '@/lib/canvas-runtime/workspace';
|
||||
import type { RecoverySession } from '@/lib/canvas-runtime/engine';
|
||||
import type { ConflictPlan, RecoveryChoice } from '@/lib/canvas-runtime/conflicts';
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogTitle } from '@/components/ui/dialog';
|
||||
|
||||
function message(error:unknown){
|
||||
const code=(error as {code?:string;message?:string})?.code??(error as Error)?.message;
|
||||
if(code==='recovery_waiting_for_receipts')return '还有请求正在确认。请关闭此窗口,重试同步后再处理。';
|
||||
if(code==='recovery_read_behind_receipt')return '服务端读取尚未包含已确认的修改,请稍后重新检查。';
|
||||
if(code?.endsWith('receipt_interpreter_unavailable')||code==='recovery_receipt_missing')return '暂时无法验证原成功回执,修改和原请求仍已保留。请稍后重试。';
|
||||
if(code==='recovery_in_progress')return '上一次处理尚未结束。可先恢复编辑,再重新检查。';
|
||||
if(code==='recovery_choices_incomplete')return '请为每一项修改选择处理方式。';
|
||||
if(code==='stale_incarnation')return '服务端已删除或替换了这个元素。可以放弃这项本地修改。';
|
||||
if(code==='operation_context_changed')return '元素所属分区已改变,请重新预览这项修改。';
|
||||
if(code==='ownership_conflict')return '元素已移入其它分区,当前坐标需要重新确认,暂时无法直接保留。';
|
||||
if(code==='recovery_identity_collision')return '这个身份已在服务端使用,不能覆盖另一份内容。可以放弃本地创建,或重新创建一个元素。';
|
||||
if(code==='stale_endpoint')return '连线所指元素已删除或进入新的生命周期,请重新选择端点。';
|
||||
if(code==='unsupported_presentation_units'||code==='invalid_presentation_units')return '这个元素的展示单位暂时无法转换,原内容已保留,请确认展示格式后重试。';
|
||||
if(code==='unsupported_coordinate_path')return '这条曲线使用未知坐标格式,当前无法安全转换归属,原路径仍已保留。';
|
||||
if(code==='history_replay_interpreter_unavailable'||code==='history_transition_interpreter_unavailable'||code==='operation_interpreter_unavailable')return '这份修改或恢复记录来自当前无法解析的版本,原内容和请求已保留。请更新应用后重试。';
|
||||
if(code==='recovery_history_adoption_evidence_missing')return '这次恢复缺少完整历史步骤,暂时无法安全继续处理;原内容和请求已保留。';
|
||||
if(code==='recovery_history_traversal_order')return '这些撤销或重做有先后顺序,不能跳过前一项继续处理后一项。请重新选择。';
|
||||
if(code==='recovery_history_traversal_conflict')return '服务端内容与这项历史记录已有冲突,暂时无法安全调整撤销或重做;原内容和历史均已保留。';
|
||||
if(code?.startsWith('recovery_history')||code==='recovery_record_replacement_not_supported')return '这批修改含结构或撤销操作,暂时无法自动处理。原内容和历史均已保留。';
|
||||
if(code==='recovery_action_requires_new_command')return '生成任务需要重新手动发起。可以在这里放弃尚未执行的请求。';
|
||||
if(code==='forbidden'||code==='unauthorized'||code==='identity_changed')return '当前账号无法读取或修改此画布,请确认权限后重试。';
|
||||
return '处理未完成,当前修改仍已保留。请重试,或恢复编辑后稍后处理。';
|
||||
}
|
||||
function PreviewValue({value}:{value:Json|undefined}){
|
||||
if(value===undefined)return <span>未设置</span>;
|
||||
const text=typeof value==='string'?value:JSON.stringify(value);
|
||||
return text.length>240?<details><summary>{text.slice(0,240)}…(展开全文)</summary><pre style={{whiteSpace:'pre-wrap',overflowWrap:'anywhere'}}>{text}</pre></details>:<span>{text}</span>;
|
||||
}
|
||||
const fieldLabel=(field:string)=>({'data.title':'标题','data.text':'正文',geometry:'位置与尺寸',path:'连线路径'}[field]??(field.startsWith('data.')?'内容字段':'元素关系'));
|
||||
|
||||
function StructureReview({change,document}:{change:Exclude<Change,{kind:'field'}>;document:Document}){
|
||||
const current=change.kind==='element'?document.elements.get(change.id):document.canvases.get(change.id),kind=change.kind==='element'?'元素':'分区';
|
||||
const title=change.kind==='element'?(change.after?.data.title??change.before?.data.title??change.id):change.id;
|
||||
return <div style={{fontSize:13,overflowWrap:'anywhere',marginTop:8}}><strong>{change.after===null?'删除':change.before===null?'创建':'调整'}{kind} · {String(title)}</strong>
|
||||
<div>服务端:{current?<PreviewValue value={current as unknown as Json}/>:<span>不存在</span>}</div><div>本地请求:{change.after?<PreviewValue value={change.after as unknown as Json}/>:<span>删除此{kind}</span>}</div></div>;
|
||||
}
|
||||
function PlanStructureReview({plan}:{plan:ConflictPlan}){
|
||||
const changes=plan.operationIds.flatMap(id=>plan.operations.get(id)!.changes),structural=changes.filter(change=>change.kind!=='field'||['ownerCanvasId','source','target','path'].includes(change.field));
|
||||
if(!structural.length)return null;
|
||||
const elements=structural.filter(change=>change.kind==='element'&&change.after===null).length,canvases=structural.filter(change=>change.kind==='canvas'&&change.after===null).length;
|
||||
return <details><summary>关联结构变更 {structural.length} 项 · 删除 {elements} 个元素、{canvases} 个分区(展开查看)</summary>
|
||||
{structural.map((change,index)=><div key={index} style={{borderBottom:'1px solid #ddd5ca',fontSize:12,overflowWrap:'anywhere',padding:8}}><strong>{change.kind==='field'?fieldLabel(change.field):change.kind==='element'?'元素':'分区'} · {change.kind==='field'?change.target.id:change.id}</strong><div>修改前:<PreviewValue value={change.before as unknown as Json}/></div><div>修改后:<PreviewValue value={change.after as unknown as Json}/></div></div>)}
|
||||
</details>;
|
||||
}
|
||||
|
||||
export function HeadlessRecoveryDialog({engine,close,viewId}:{engine:ProjectEngine;close():void;viewId:string}){
|
||||
const owner=useRef<RecoverySession|undefined>(undefined),[session,setSession]=useState<RecoverySession>(),[records,setRecords]=useState<readonly OperationIntent[]>([]);
|
||||
const [choices,setChoices]=useState(new Map<Id,RecoveryChoice>()),[plan,setPlan]=useState<ConflictPlan>(),[error,setError]=useState(''),[busy,setBusy]=useState(true),[frozen,setFrozen]=useState(false);
|
||||
useEffect(()=>{
|
||||
let live=true;
|
||||
Promise.resolve().then(()=>live?engine.prepareRecovery():undefined).then(async session=>{if(!session)return;if(!live){await session.cancel();return;}owner.current=session;setSession(session);setRecords([...engine.getSnapshot().runtime.ledger.operations.values()].filter(record=>session.pendingOperationIds.includes(record.intent.id)&&record.intent.operationType!=='asset.upload').sort((a,b)=>a.intent.sequence-b.intent.sequence).map(record=>record.intent));}).catch(error=>{if(live)setError(message(error));}).finally(()=>{if(live)setBusy(false);});
|
||||
return()=>{live=false;void owner.current?.cancel().catch(()=>{/* Engine retains the failed durable barrier; retry/cancelRecovery can finish it. */});};
|
||||
},[engine]);
|
||||
async function cancel(){if(busy&&session)return;setBusy(true);try{await engine.cancelRecovery();close();}catch(error){setError(message(error));}finally{setBusy(false);}}
|
||||
async function apply(){if(!session||busy)return;setBusy(true);setError('');setFrozen(true);try{const result=plan?await session.resolve(plan):await session.apply();if(result.status==='rejected'){setFrozen(false);setError(message({code:result.code}));return;}close();}catch(error){setError(message(error));}finally{setBusy(false);}}
|
||||
const canonical=session?.canonical.document;
|
||||
return <Dialog open onOpenChange={open=>{if(!open)void cancel();}}><DialogContent data-canvas-ui-view={viewId} aria-label="处理同步修改" style={{zIndex:300,maxHeight:'80vh',overflowY:'auto',background:'var(--background, #fffdf9)',color:'var(--foreground, #302d29)'}} onEscapeKeyDown={event=>{if(busy&&session)event.preventDefault();}}>
|
||||
<DialogTitle>处理同步修改</DialogTitle>
|
||||
<DialogDescription>检查期间暂停编辑。逐项选择要保留的修改,预览后再应用;原内容和撤销记录会保留备份。</DialogDescription>
|
||||
{busy&&!session&&<p role="status">正在等待同步并读取最新内容…</p>}
|
||||
{!!session?.acknowledgedRequestIds.length&&<p>已找回 {session.acknowledgedRequestIds.length} 个成功回执,仅处理后续尚未同步的修改。</p>}
|
||||
{session&&records.length===0&&<p>没有待处理的本地修改,可以采用服务端最新内容。</p>}
|
||||
{session&&records.map((intent,index)=><article key={intent.id} style={{borderBottom:'1px solid #ddd5ca',paddingBottom:12}}>
|
||||
<strong>修改 {index+1}{intent.origin==='undo'?' · 未同步的撤销':intent.origin==='redo'?' · 未同步的重做':''}{intent.operationType==='run.start'?' · 尚未受理的生成':intent.operationType==='run.cancel'?' · 尚未确认的取消':''}</strong>
|
||||
{intent.changes.map((change,changeIndex)=>{if(change.kind!=='field')return <StructureReview key={changeIndex} change={change} document={canonical!}/>;const element=canonical?.elements.get(change.target.id),key=change.field.startsWith('data.')?change.field.slice(5):undefined,value=key===undefined?(element as unknown as Record<string,Json>|undefined)?.[change.field]:element?.data[key],proposed=plan?.document.elements.get(change.target.id),proposedValue=key===undefined?(proposed as unknown as Record<string,Json>|undefined)?.[change.field]:proposed?.data[key];return <div key={changeIndex} style={{fontSize:13,whiteSpace:'pre-wrap',overflowWrap:'anywhere',marginTop:8}}><span>{fieldLabel(change.field)}</span><div>服务端:{element?<PreviewValue value={value}/>:'元素已不存在'}</div><div>本地:{change.afterPresent===false?'移除此字段':<PreviewValue value={change.after}/>}</div>{plan&&<div>处理后:{proposed?<PreviewValue value={proposedValue}/>:'元素已不存在'}</div>}</div>;})}
|
||||
<select aria-label={`处理第${index+1}项修改`} value={choices.get(intent.id)??''} disabled={busy||frozen} onChange={event=>{const next=new Map(choices);if(event.target.value)next.set(intent.id,event.target.value as RecoveryChoice);else next.delete(intent.id);setChoices(next);setPlan(undefined);setError('');}}>
|
||||
<option value="">选择处理方式</option>{['document.change','run.apply'].includes(intent.operationType)&&<option value="keep-local">{intent.origin==='undo'?'保留这次撤销':intent.origin==='redo'?'保留这次重做':'保留这项本地修改'}</option>}<option value="discard-local">{intent.origin==='undo'?'放弃这次撤销':intent.origin==='redo'?'放弃这次重做':'放弃这项本地修改'}</option>
|
||||
</select>
|
||||
</article>)}
|
||||
{plan&&<p role="status">方案已生成:保留 {plan.audit.decisions.filter(item=>item.choice==='keep-local').length} 项,放弃 {plan.audit.decisions.filter(item=>item.choice==='discard-local').length} 项。撤销记录由 {engine.getSnapshot().history.undo.length} 项调整为 {plan.history.undo.length} 项,重做记录由 {engine.getSnapshot().history.redo.length} 项调整为 {plan.history.redo.length} 项。</p>}
|
||||
{plan&&<PlanStructureReview plan={plan}/>}
|
||||
{error&&<p role="alert">{error}</p>}
|
||||
<DialogFooter><button disabled={busy&&!!session} onClick={()=>void cancel()}>{session?'取消并恢复编辑':'恢复编辑'}</button>
|
||||
{session&&records.length>0&&<button aria-label="预览处理方案" disabled={busy||frozen||choices.size!==records.length} onClick={()=>{try{setPlan(session.plan(choices));setError('');}catch(error){setPlan(undefined);setError(message(error));}}}>预览处理方案</button>}
|
||||
{session&&<button aria-label="应用处理方案" disabled={busy||records.length>0&&!plan} onClick={()=>void apply()}>{busy?'正在保存…':frozen?'重试保存处理结果':'应用处理方案'}</button>}
|
||||
</DialogFooter>
|
||||
</DialogContent></Dialog>;
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useId, useRef, useState } from "react";
|
||||
import { ImageIcon } from "lucide-react";
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { useCanvasRuntimeHost } from "@/components/workspace/canvas-runtime-host";
|
||||
import type { ProjectEngine } from "@/lib/canvas-runtime/workspace";
|
||||
import type { Id, Point } from "@/lib/canvas-core/types";
|
||||
import { fault } from "@/lib/canvas-core/values";
|
||||
import { errorText } from "@/lib/api";
|
||||
|
||||
function Preview({src}:{src:string}){const [failed,setFailed]=useState(false);return failed?<ImageIcon aria-hidden className="h-8 w-8 opacity-50"/>:<img src={src} alt="" loading="lazy" className="h-full w-full object-contain" onError={()=>setFailed(true)}/>;}
|
||||
type Candidate = { id: Id; title: string };
|
||||
export function HeadlessResourcePicker({engine,workspaceId,canvasId,position,close,onAdded,viewId}:{engine:ProjectEngine;workspaceId:Id;canvasId:Id;position:Point;close():void;onAdded(id:Id):void;viewId:string}) {
|
||||
const host=useCanvasRuntimeHost(),[items,setItems]=useState<Candidate[]>([]),[loading,setLoading]=useState(true),[busy,setBusy]=useState<Id>(),[error,setError]=useState("");
|
||||
const lifecycle=useRef<AbortController|null>(null),requesting=useRef(false),descriptionId=useId();
|
||||
const dismiss=()=>{lifecycle.current?.abort();close();};
|
||||
const [target]=useState(()=>{const document=engine.getSnapshot().document,canvas=document.canvases.get(canvasId),entry=canvas?.parent?document.elements.get(canvas.parent.entryElementId):undefined;return {canvas,entry};});
|
||||
useEffect(()=>{
|
||||
const controller=new AbortController();lifecycle.current=controller;
|
||||
const identity=host.identity(),workspace=host.getWorkspaces().get(workspaceId);
|
||||
void (async()=>{
|
||||
if(!workspace?.isCurrentIdentity()||workspace.accountId!==identity.accountId)throw fault('identity_changed');
|
||||
const results=await Promise.allSettled([...workspace.getProjects()].filter(([id])=>id!==engine.getSnapshot().document.projectId).map(([,project])=>project));
|
||||
if(controller.signal.aborted)return;
|
||||
if(!workspace.isCurrentIdentity()||host.identity().authEpoch!==identity.authEpoch)throw fault('identity_changed');
|
||||
const candidates=new Map<Id,Candidate>(),owned=engine.getSnapshot().runtime.resources;
|
||||
for(const result of results)if(result.status==='fulfilled'){
|
||||
const snapshot=result.value.getSnapshot();
|
||||
for(const element of snapshot.document.elements.values()){
|
||||
const id=element.data.assetId;
|
||||
if(element.structure!=='node'||typeof id!=='string'||!id||owned.has(id)||candidates.has(id)||snapshot.runtime.resources.has(id)&&!snapshot.runtime.resources.get(id)!.stored)continue;
|
||||
if(!result.value.editor.registry.components.get(element.componentId)?.resourceFields?.includes('assetId'))continue;
|
||||
candidates.set(id,{id,title:typeof element.data.title==='string'&&element.data.title.trim()?element.data.title:'未命名图片'});
|
||||
}
|
||||
}
|
||||
setItems([...candidates.values()]);setLoading(false);
|
||||
})().catch(error=>{if(!controller.signal.aborted){setLoading(false);setError(errorText(error));}});
|
||||
return()=>{controller.abort();if(lifecycle.current===controller)lifecycle.current=null;};
|
||||
},[engine,host,workspaceId]);
|
||||
async function insert(item:Candidate){
|
||||
const signal=lifecycle.current?.signal;if(!signal||signal.aborted||requesting.current)return;
|
||||
requesting.current=true;
|
||||
setBusy(item.id);setError("");
|
||||
try{
|
||||
const id=crypto.randomUUID(),result=await engine.adoptResource(item.id,(resource,tx)=>{
|
||||
const document=tx.getDocument(),canvas=document.canvases.get(canvasId),entry=canvas?.parent?document.elements.get(canvas.parent.entryElementId):undefined;
|
||||
if(!canvas||canvas.parent?.entryElementId!==target.canvas?.parent?.entryElementId||target.entry&&(entry?.id!==target.entry.id||entry.incarnation!==target.entry.incarnation))throw fault('asset_target_changed');
|
||||
const scale=Math.min(260/resource.asset.width,200/resource.asset.height),width=resource.asset.width*scale,height=resource.asset.height*scale;
|
||||
tx.dispatch({type:'element.create',element:{id,incarnation:1,structure:'node',componentId:'image',dataVersion:1,ownerCanvasId:canvasId,geometry:{x:position.x-width/2,y:position.y-height/2,width,height,rotation:0},data:{title:item.title,text:'',assetId:resource.id,src:resource.src}}});
|
||||
},{signal});
|
||||
if(result.status==='rejected')throw fault(result.code,result.targets);
|
||||
if(!signal.aborted){onAdded(id);close();}
|
||||
}catch(error){if(!signal.aborted)setError(error instanceof Error&&error.message==='not_found'?'无法读取这张图片,请确认仍有原画布的访问权限。':errorText(error));}
|
||||
finally{requesting.current=false;if(!signal.aborted)setBusy(undefined);}
|
||||
}
|
||||
return <Dialog open onOpenChange={open=>{if(!open)dismiss();}}><DialogContent data-canvas-ui-view={viewId} aria-describedby={descriptionId}>
|
||||
<DialogHeader><DialogTitle>从其它画布添加图片</DialogTitle><DialogDescription id={descriptionId}>选择同一工作区已打开画布中的图片,无需再次上传。</DialogDescription></DialogHeader>
|
||||
{loading?<p role="status">正在查找图片…</p>:items.length===0?<p>暂无可复用的图片。先打开有已上传图片的画布,再回到这里。</p>:<div className="grid max-h-80 grid-cols-2 gap-3 overflow-y-auto">{items.map(item=><button key={item.id} type="button" aria-label={`添加图片:${item.title}`} disabled={!!busy} onClick={()=>void insert(item)} className="flex min-w-0 flex-col items-center gap-2 rounded-lg border border-border p-3 text-sm hover:bg-muted disabled:opacity-50">
|
||||
<span className="flex h-20 w-full items-center justify-center overflow-hidden rounded bg-muted"><Preview src={`/api/foundry/workspaces/${encodeURIComponent(workspaceId)}/assets/${encodeURIComponent(item.id)}?variant=preview`}/></span><span className="w-full truncate">{item.title}</span>{busy===item.id&&<span role="status">正在添加…</span>}
|
||||
</button>)}</div>}
|
||||
{error&&<p role="alert">{error}</p>}
|
||||
</DialogContent></Dialog>;
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
"use client";
|
||||
import {useEffect,useRef,useState} from 'react';
|
||||
import type {Registry} from '@/lib/canvas-core/types';
|
||||
import {claimLegacyCanvasEditing,createBrowserCanvasMigration,type LegacyMigrationContext} from '@/lib/browser-canvas-migration';
|
||||
import {canvasMigrationStorageKey,migrationBlocksCanvas,readCanvasMigration,type CanvasMigrationJob} from '@/lib/canvas-migration';
|
||||
import {errorText} from '@/lib/api';
|
||||
import styles from './canvas.module.css';
|
||||
|
||||
function message(error:unknown){
|
||||
const code=error instanceof Error?error.message:String(error);
|
||||
return ({migration_busy:'其他旧画布页面仍在编辑或升级,请先完成那里的保存和上传,再关闭页面重试。',migration_lock_unavailable:'此浏览器不支持安全升级,请使用支持 Web Locks 的浏览器。',migration_response_unknown:'升级结果尚未确认,编辑已暂停。可继续查询原请求。',invalid_migration_receipt:'升级回执无法确认,已保留原请求并暂停编辑。请继续查询或联系支持。',migration_pending_uploads:'请先完成或取消图片上传,再升级。',draft_recovery_required:'请先处理本地草稿恢复,再升级。',canvas_busy:'请先完成当前操作与同步,再升级。',migration_waiting_for_runs:'请等待生成任务结束,再重新准备升级。',migration_version_conflict:'画布已发生变化,请重新准备升级。',workspace_migration_pending:'本工作区还有另一画布的升级待确认,请先处理原画布。',invalid_migration_record:'无法读取本地升级进度,已保留记录并暂停编辑。',migration_interpreter_unavailable:'本地升级进度需要其他版本解释,已保留记录并暂停编辑。',migration_not_durable:'无法可靠保存升级进度,请保留页面并重试。'} as Record<string,string>)[code]??errorText(error instanceof Error?error:new Error(code));
|
||||
}
|
||||
type Props={context:LegacyMigrationContext;workspaceId:string;canvasId:string;registry:Registry;held:boolean;hold(value:boolean):void;prepare(rootId:string):Promise<void>;check():void;canLeave():boolean;exportDraft():void};
|
||||
export function LegacyCanvasMigration(props:Props){
|
||||
const port=useRef(props);port.current=props;
|
||||
const [job,setJob]=useState<CanvasMigrationJob>(),[error,setError]=useState(''),[checking,setChecking]=useState(true),[running,setRunning]=useState(false),[changedElsewhere,setChangedElsewhere]=useState(false);
|
||||
const lease=useRef<{release():Promise<void>}|undefined>(undefined),life=useRef<AbortController|undefined>(undefined),attempt=useRef(false),finished=useRef(false),previous=useRef<CanvasMigrationJob|undefined>(undefined);
|
||||
const scope={accountId:props.context.accountId,workspaceId:props.workspaceId},key=canvasMigrationStorageKey(scope);
|
||||
const refresh=useRef<()=>Promise<void>>(async()=>{});
|
||||
useEffect(()=>{
|
||||
const controller=new AbortController();life.current=controller;
|
||||
const current=()=>!controller.signal.aborted&&port.current.context.isCurrentIdentity();
|
||||
const read=()=>readCanvasMigration(localStorage,scope);
|
||||
async function inspect(){
|
||||
if(attempt.current||!current())return;
|
||||
setChecking(true);
|
||||
try{
|
||||
const next=read();previous.current=next;setJob(next);
|
||||
if(migrationBlocksCanvas(next,props.canvasId)||finished.current){
|
||||
port.current.hold(true);const held=lease.current;lease.current=undefined;await held?.release();setError('');return;
|
||||
}
|
||||
if(lease.current){port.current.hold(false);setError('');return;}
|
||||
port.current.hold(true);
|
||||
const acquired=await claimLegacyCanvasEditing({scope,canvasId:props.canvasId,signal:controller.signal,isCurrentIdentity:current});
|
||||
if(!current()){await acquired.release();return;}
|
||||
lease.current=acquired;port.current.hold(false);setError('');
|
||||
}catch(error){
|
||||
if(!current())return;
|
||||
port.current.hold(true);const held=lease.current;lease.current=undefined;await held?.release();
|
||||
// Without Web Locks this browser cannot upgrade, but existing legacy
|
||||
// editing remains supported unless there is an unresolved identity job.
|
||||
if(error instanceof Error&&error.message==='migration_lock_unavailable'){
|
||||
try{if(!migrationBlocksCanvas(read(),props.canvasId))port.current.hold(false);}catch{}
|
||||
}
|
||||
setError(message(error));
|
||||
}finally{if(current())setChecking(false);}
|
||||
}
|
||||
refresh.current=inspect;void inspect();
|
||||
const storage=(event:StorageEvent)=>{
|
||||
if(event.key!==key&&event.key!==null||!current()||attempt.current)return;
|
||||
// Never discard another view's in-memory content because a record vanished.
|
||||
if(previous.current?.phase==='pending'&&event.newValue===null){
|
||||
finished.current=true;port.current.hold(true);setChangedElsewhere(true);setError('升级进度已在其他页面改变。本页内容保留,可导出后重新打开。');
|
||||
const held=lease.current;lease.current=undefined;void held?.release();return;
|
||||
}
|
||||
void inspect();
|
||||
};
|
||||
window.addEventListener('storage',storage);
|
||||
return()=>{controller.abort();window.removeEventListener('storage',storage);const held=lease.current;lease.current=undefined;void held?.release();};
|
||||
// The port ref follows renders without re-acquiring a shared editing lease.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
},[key,props.canvasId]);
|
||||
async function upgrade(){
|
||||
const controller=life.current;if(!controller||controller.signal.aborted||attempt.current)return;
|
||||
attempt.current=true;setRunning(true);setError('');
|
||||
const current=()=>!controller.signal.aborted&&port.current.context.isCurrentIdentity();
|
||||
let failure='';
|
||||
try{
|
||||
const pending=readCanvasMigration(localStorage,scope);
|
||||
if(pending?.phase==='pending'&&!migrationBlocksCanvas(pending,props.canvasId))throw new Error('workspace_migration_pending');
|
||||
if(pending?.phase!=='pending')port.current.check();
|
||||
port.current.hold(true);
|
||||
// Drain while still holding our shared lease, so losing the subsequent
|
||||
// exclusive-lock race never abandons a dirty old writer between locks.
|
||||
if(pending?.phase!=='pending'&&props.context.rootId){await port.current.prepare(props.context.rootId);if(!current())return;}
|
||||
const held=lease.current;lease.current=undefined;await held?.release();if(!current())return;
|
||||
const migration=createBrowserCanvasMigration({scope,registry:props.registry,signal:controller.signal,isCurrentIdentity:current});
|
||||
const result=pending?.phase==='pending'?await migration.resume():props.context.rootId?await migration.start(props.context.rootId,async()=>{port.current.check();await port.current.prepare(props.context.rootId!);port.current.check();}):undefined;
|
||||
if(!current())return;
|
||||
if(result?.status==='applied'||result?.status==='already-migrated'){
|
||||
finished.current=true;port.current.context.onApplied();return;
|
||||
}
|
||||
if(result&&'error'in result){failure=message(result.error);setError(failure);}
|
||||
const next=migration.read();previous.current=next;setJob(next);
|
||||
}catch(error){if(current()){failure=message(error);setError(failure);}}
|
||||
finally{
|
||||
attempt.current=false;
|
||||
if(current()){
|
||||
setRunning(false);
|
||||
try{const next=readCanvasMigration(localStorage,scope);previous.current=next;setJob(next);if(!finished.current&&!migrationBlocksCanvas(next,props.canvasId))await refresh.current();}catch(error){port.current.hold(true);failure=message(error);}
|
||||
if(failure)setError(failure);
|
||||
}
|
||||
}
|
||||
}
|
||||
const pending=migrationBlocksCanvas(job,props.canvasId),other=job?.phase==='pending'&&!pending;
|
||||
if(!props.context.rootId&&!pending&&!error&&!props.held)return null;
|
||||
return <div className={styles.migrationPanel} aria-label="画布升级">
|
||||
{checking?<span role="status">正在检查画布编辑权限…</span>:<>
|
||||
{running?<span role="status">正在保存并升级画布…</span>:changedElsewhere?<button onClick={()=>{try{port.current.check();if(!port.current.canLeave())throw new Error('本页有未同步内容,请先导出备份并手动重新打开画布。');port.current.context.onApplied();}catch(error){setError(message(error));}}}>重新打开画布</button>:props.context.rootId||pending?<button disabled={other} title="保存内容并使用新版编辑器;旧撤销记录不会带入" onClick={()=>void upgrade()}>{pending?'继续查询升级':job?.phase==='rejected'?'重新准备升级':'升级画布'}</button>:props.held?<button onClick={()=>void refresh.current()}>重新检查</button>:null}
|
||||
{pending&&!running&&<span role="status">结果待确认,旧编辑已暂停。</span>}
|
||||
{error&&<span role="alert">{error}</span>}
|
||||
{props.held&&!running&&<button onClick={props.exportDraft}>导出本页备份</button>}
|
||||
</>}
|
||||
</div>;
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
"use client";
|
||||
|
||||
import {useEffect,useMemo,useRef} from 'react';
|
||||
import {Background,BackgroundVariant,ReactFlow,useReactFlow,type Viewport,type NodeTypes, type NodeProps,Handle,Position} from '@xyflow/react';
|
||||
import type {Registry} from '@/lib/canvas-core/types';
|
||||
import type {ModelPreview} from '@/lib/canvas-runtime/model-preview';
|
||||
import type {ReactRenderers} from '@/lib/canvas-bindings/react-flow';
|
||||
import {createLoadingRendererCatalog} from '@/lib/canvas-bindings/renderers';
|
||||
import {projectCanvas} from '@/lib/canvas-bindings/projection';
|
||||
import {CanvasRendererCommandsContext,type CanvasRendererCommands} from '@/lib/canvas-bindings/react-flow';
|
||||
import {UnsupportedNode,UnsupportedEdge} from './unsupported-elements';
|
||||
import styles from './canvas.module.css';
|
||||
|
||||
function PreparingNode({data}:NodeProps){return <div style={{width:'100%',height:'100%',padding:14,border:'1px solid #ded7ce',borderRadius:8,background:'#fffdfa'}} aria-label="正在准备元素"><span>{typeof data.title==='string'?data.title:'正在准备元素…'}</span>{(data.__previewHandles as {id?:string;type:'source'|'target'}[]??[]).map((handle,index)=><Handle key={index} type={handle.type} id={handle.id} position={handle.type==='source'?Position.Right:Position.Left} isConnectable={false}/>)}</div>;}
|
||||
const reject=()=>({status:'rejected' as const,code:'model_loading',targets:[]});
|
||||
const commands:CanvasRendererCommands=Object.freeze({dispatch:reject,transaction:reject});
|
||||
function InitialPreviewViewport({onViewport,nodeCount}:{onViewport(viewport:Viewport):void;nodeCount:number}){
|
||||
const flow=useReactFlow(),fitted=useRef(false),notify=useRef(onViewport);notify.current=onViewport;
|
||||
useEffect(()=>{if(!flow.viewportInitialized||fitted.current||!nodeCount)return;fitted.current=true;let active=true;void flow.fitView({padding:.2,maxZoom:1}).then(()=>{if(active)notify.current(flow.getViewport());});return()=>{active=false;};},[flow,nodeCount]);
|
||||
return null;
|
||||
}
|
||||
/** Render the validated read prefix directly. No temporary editor, ACK, ledger or
|
||||
* writable engine is constructed, and no partial model enters durable storage. */
|
||||
export default function LoadingGraphCanvas({preview,canvasId,registry,renderers,workspaceId,onViewport}: {preview:ModelPreview;canvasId:string;workspaceId:string;registry:Registry;renderers:ReactRenderers;onViewport(viewport:Viewport):void}){
|
||||
// Without a declared reference schema, use the existing opaque placeholder
|
||||
// until the complete document is available to the installed renderer.
|
||||
const catalog=useMemo(()=>createLoadingRendererCatalog(registry,renderers.catalog),[registry,renderers]);
|
||||
const projection=useMemo(()=>{
|
||||
const value=projectCanvas(preview.document,canvasId,registry,new Set(),()=>undefined,undefined,catalog);
|
||||
const handles=new Map<string,{id?:string;type:'source'|'target'}[]>();for(const edge of value.edges)for(const type of ['source','target'] as const){const id=edge[type],rows=handles.get(id)??[];rows.push({type,id:edge[type==='source'?'sourceHandle':'targetHandle']??undefined});handles.set(id,rows);}
|
||||
return{nodes:value.nodes.map(node=>({...node,draggable:false,selectable:false,focusable:false,connectable:false,style:{...node.style,pointerEvents:'none' as const},data:{...node.data,__previewHandles:handles.get(node.id)??[],...(registry.resolve(preview.document.elements.get(node.id)!)==='known'&&typeof node.data.assetId==='string'&®istry.components.get(preview.document.elements.get(node.id)!.componentId)?.resourceFields?.includes('assetId')?{src:`/api/foundry/workspaces/${encodeURIComponent(workspaceId)}/assets/${encodeURIComponent(node.data.assetId)}?variant=preview`}:{})}})),edges:value.edges.map(edge=>({...edge,selectable:false,focusable:false}))};
|
||||
},[preview.document,canvasId,workspaceId,registry,catalog]);
|
||||
const nodeTypes=useMemo<NodeTypes>(()=>{const result:NodeTypes={};for(const [id,Renderer]of Object.entries({...Object.fromEntries(Object.keys(renderers.nodeTypes).map(id=>[id,renderers.previewNodeTypes[id]??PreparingNode])),unsupported:UnsupportedNode}))result[id]=props=><div inert style={{width:'100%',height:'100%'}}><Renderer {...props}/></div>;return result;},[renderers]),edgeTypes=useMemo(()=>({...Object.fromEntries(Object.keys(renderers.edgeTypes).map(id=>[id,renderers.previewEdgeTypes[id]??UnsupportedEdge])),unsupported:UnsupportedEdge}),[renderers]);
|
||||
return <div className={styles.editor} data-canvas-loading-preview aria-label="正在加载的画布,只读预览">
|
||||
<CanvasRendererCommandsContext.Provider value={commands}>
|
||||
<ReactFlow nodes={projection.nodes} edges={projection.edges} nodeTypes={nodeTypes} edgeTypes={edgeTypes} nodesDraggable={false} nodesConnectable={false} elementsSelectable={false} nodesFocusable={false} edgesFocusable={false} panOnDrag panOnScroll zoomOnScroll={false} minZoom={.1} maxZoom={4} deleteKeyCode={null} onMoveEnd={(_,viewport)=>onViewport(viewport)}>
|
||||
<InitialPreviewViewport onViewport={onViewport} nodeCount={projection.nodes.length}/>
|
||||
<Background variant={BackgroundVariant.Dots} gap={24} size={1} />
|
||||
</ReactFlow>
|
||||
</CanvasRendererCommandsContext.Provider>
|
||||
<p role="status" style={{position:'absolute',left:'50%',transform:'translateX(-50%)',bottom:16,pointerEvents:'none'}}>正在加载… {preview.receivedElements}/{preview.totalElements},加载期间可浏览</p>
|
||||
</div>;
|
||||
}
|
||||
@@ -2,15 +2,16 @@
|
||||
import { CanvasImage } from "./canvas-image";
|
||||
import { managedImageSource } from "@/lib/canvas-v2";
|
||||
|
||||
import { useContext, useRef } from "react";
|
||||
import { useRef } from "react";
|
||||
import { Handle, Position, useViewport, type NodeProps } from "@xyflow/react";
|
||||
import { FileText, Image as ImageIcon, Type } from "lucide-react";
|
||||
import { elementCapabilities } from "@/lib/canvas-elements";
|
||||
import { nodeDirections, type CanvasNode } from "@/lib/canvas";
|
||||
import { NodeTitle } from "./node-title";
|
||||
import { CanvasTextEditor } from "./canvas-text-editor";
|
||||
import {useScopedNodeEditing} from "./node-editing";
|
||||
import styles from "./canvas.module.css";
|
||||
import { TransformableNode, NodeEditing } from "./transformable-node";
|
||||
import { TransformableNode } from "./transformable-node";
|
||||
export { NodeEditing } from "./transformable-node";
|
||||
|
||||
const positions = { top: Position.Top, right: Position.Right, bottom: Position.Bottom, left: Position.Left };
|
||||
@@ -18,14 +19,14 @@ const labels = { top: "上方", right: "右侧", bottom: "下方", left: "左侧
|
||||
|
||||
export function MaterialNode(props: NodeProps<CanvasNode>) {
|
||||
const { id, data, selected } = props;
|
||||
const editing = useContext(NodeEditing);
|
||||
const editing = useScopedNodeEditing(id,data.__headlessTarget);
|
||||
const { zoom } = useViewport();
|
||||
const preview = (props.width || 260) * zoom * (typeof window === "undefined" ? 1 : window.devicePixelRatio || 1) <= 1024;
|
||||
const pointerStart = useRef({ x: 0, y: 0 });
|
||||
const Icon = data.kind === "image" ? ImageIcon : data.kind === "script" ? FileText : Type;
|
||||
return (
|
||||
<TransformableNode {...props} className={`${styles.node} ${selected ? styles.selectedNode : ""}`}>
|
||||
<header className={styles.nodeHeading}><Icon size={13} /><NodeTitle id={id} title={data.title} selected={selected} /></header>
|
||||
<header className={styles.nodeHeading}><Icon size={13} /><NodeTitle id={id} renderedTarget={data.__headlessTarget} title={data.title} selected={selected} /></header>
|
||||
<Handle type="target" position={Position.Left} className={styles.legacyHandle} isConnectable={false} />
|
||||
{data.kind === "image" ? <CanvasImage id={id} className={styles.image} src={managedImageSource(data.src, preview)} title={data.title} /> : (
|
||||
<CanvasTextEditor id={id} data={data} selected={selected} />
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
"use client";
|
||||
import {createContext,useContext,useMemo} from 'react';
|
||||
import type {XYPosition} from '@xyflow/react';
|
||||
import type {NodeDirection} from '@/lib/canvas';
|
||||
import type {TextStyle} from '@/lib/canvas-elements';
|
||||
import type {NodeTransform} from '@/lib/canvas-geometry';
|
||||
import type {EditingLease} from './editing-lease';
|
||||
import type {RenderedTarget} from '@/lib/canvas-bindings/compat-types';
|
||||
import {CanvasOverlays} from './canvas-overlays';
|
||||
const defaultEditing = {
|
||||
beginTransform: undefined as undefined | ((id: string) => boolean | void | EditingLease<NodeTransform>), finishTransform: undefined as undefined | (() => void), cancelTransform: undefined as undefined | (() => void),
|
||||
endEditing: undefined as undefined | ((id: string) => void),
|
||||
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; 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 GestureHooks = 'beginTransform' | 'finishTransform' | 'cancelTransform' | 'endEditing';
|
||||
export const NodeEditing = createContext<Omit<typeof defaultEditing, GestureHooks> & Partial<Pick<typeof defaultEditing, GestureHooks>>>(defaultEditing);
|
||||
|
||||
/** Bind legacy renderer actions to the lifetime and placement that rendered them. */
|
||||
export function useScopedNodeEditing(id:string,rendered?:RenderedTarget){
|
||||
const editing=useContext(NodeEditing),scope=useContext(CanvasOverlays),incarnation=rendered?.id===id?rendered.incarnation:undefined,owner=rendered?.ownerCanvasId,canvas=rendered?.canvasId;
|
||||
return useMemo(()=>{if(!scope)return editing;
|
||||
const current=(candidate:string|null|undefined=id)=>{const durability=scope.engine.getDurability(),view=scope.engine.sessions.getSnapshot().views.get(scope.index.viewId),record=scope.engine.getSnapshot().document.elements.get(id);return !durability.closing&&!durability.closed&&!!view&&view.canvasId===canvas&&incarnation!==undefined&&record?.incarnation===incarnation&&record.ownerCanvasId===owner&&(candidate===undefined||candidate===null||candidate===id);};
|
||||
const ownGesture=()=>scope.engine.sessions.getSnapshot().gesture?.viewId===scope.index.viewId&&scope.engine.sessions.getSnapshot().gesture?.targets.some(target=>target.id===id&&target.incarnation===incarnation);
|
||||
const scoped:typeof editing={...editing,
|
||||
begin:candidate=>{if(current(candidate))editing.begin(candidate??id);},select:candidate=>{if(current(candidate))editing.select(candidate);},
|
||||
edit:(candidate,text)=>{if(current(candidate))editing.edit(candidate,text);},rename:(candidate,title)=>{if(current(candidate))editing.rename(candidate,title);},
|
||||
style:(candidate,style,size)=>{if(current(candidate))editing.style(candidate,style,size);},frame:(candidate,patch)=>{if(current(candidate))editing.frame(candidate,patch);},
|
||||
transform:(candidate,change)=>{if(current(candidate))editing.transform(candidate,change);},branch:(candidate,direction,point)=>{if(current(candidate))editing.branch(candidate,direction,point);},
|
||||
rotate:candidate=>{if(current(candidate)&&(candidate!==null||scope.engine.sessions.getSnapshot().views.get(scope.index.viewId)?.modes.get(id)==='rotate'))editing.rotate(candidate);},resize:candidate=>{if(current(candidate)&&(candidate!==null||scope.engine.sessions.getSnapshot().views.get(scope.index.viewId)?.modes.get(id)==='resize'))editing.resize(candidate);},
|
||||
};
|
||||
if(editing.beginTransform)scoped.beginTransform=candidate=>current(candidate)?editing.beginTransform!(candidate):false;
|
||||
if(editing.endEditing)scoped.endEditing=candidate=>{if(current(candidate))editing.endEditing!(candidate);};
|
||||
if(editing.finishTransform)scoped.finishTransform=()=>{if(current()&&ownGesture())editing.finishTransform!();};
|
||||
if(editing.cancelTransform)scoped.cancelTransform=()=>{if(current()&&ownGesture())editing.cancelTransform!();};
|
||||
return scoped;
|
||||
},[editing,scope,id,incarnation,owner,canvas]);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
"use client";
|
||||
|
||||
import {useCallback,useEffect,useRef} from 'react';
|
||||
import {useStoreApi,useUpdateNodeInternals} from '@xyflow/react';
|
||||
import {createNodeInternalsBatch} from '@/lib/canvas-bindings/node-internals';
|
||||
|
||||
// useStoreApi wraps the API per hook. Its shared getState function identifies the
|
||||
// actual store, separating canvases even when they render the same project.
|
||||
const batches=new WeakMap<object,ReturnType<typeof createNodeInternalsBatch>>();
|
||||
export function useNodeInternalsUpdate(id:string){
|
||||
const scope=useStoreApi().getState,update=useUpdateNodeInternals();
|
||||
const lease=useRef<ReturnType<ReturnType<typeof createNodeInternalsBatch>['acquire']>|undefined>(undefined);
|
||||
useEffect(()=>{
|
||||
let batch=batches.get(scope);if(!batch){batch=createNodeInternalsBatch(update);batches.set(scope,batch);}
|
||||
const owner=batch.acquire(id);lease.current=owner;
|
||||
return()=>{owner.dispose();if(lease.current===owner)lease.current=undefined;};
|
||||
},[scope,update,id]);
|
||||
return useCallback(()=>lease.current?.invalidate(),[]);
|
||||
}
|
||||
@@ -1,24 +1,32 @@
|
||||
"use client";
|
||||
|
||||
import { useContext } from "react";
|
||||
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
|
||||
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, ResizeToggle } from "./transformable-node";
|
||||
import { ResizeToggle } from "./transformable-node";
|
||||
import {useCanvasOverlay} from "./canvas-overlays";
|
||||
import {useScopedNodeEditing} from "./node-editing";
|
||||
import {canvasKeyContext} from "@/lib/canvas-bindings/input-router";
|
||||
import styles from "./canvas.module.css";
|
||||
|
||||
export function NodeStyleToolbar({ id, data, selected }: NodeProps<CanvasNode>) {
|
||||
const overlay=useCanvasOverlay(id,"node-style");
|
||||
const reduce = useReducedMotion();
|
||||
const editing = useContext(NodeEditing);
|
||||
const editing = useScopedNodeEditing(id,data.__headlessTarget);
|
||||
const style = data.textStyle || {};
|
||||
const text = data.kind === "text" || data.kind === "script" || data.kind === "plain-text";
|
||||
const patch = (value: TextStyle, fontSize?: number) => editing.style(id, value, fontSize);
|
||||
return <NodeToolbar style={{ zIndex: 100, pointerEvents: "auto" }} isVisible={true} position={Position.Top} offset={34}>
|
||||
<AnimatePresence>{selected && <motion.div initial={{ opacity: 0, y: reduce ? 0 : 5 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: reduce ? 0 : 5 }} transition={{ duration: reduce ? 0 : 0.14 }} className={`${styles.styleToolbar} nodrag nopan nowheel`} data-node-style={id} role="toolbar" aria-label="元素样式"
|
||||
return <AnimatePresence>{selected && <NodeToolbar style={{ zIndex: 100, pointerEvents: "auto" }} isVisible={true} position={Position.Top} offset={34}>
|
||||
<motion.div ref={overlay} initial={{ opacity: 0, y: reduce ? 0 : 5 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: reduce ? 0 : 5 }} transition={{ duration: reduce ? 0 : 0.14 }} className={`${styles.styleToolbar} nodrag nopan nowheel`} data-node-style={id} role="toolbar" aria-label="元素样式"
|
||||
onPointerDown={(event) => event.stopPropagation()} onClick={(event) => event.stopPropagation()} onDoubleClick={(event) => event.stopPropagation()}
|
||||
onKeyDown={(event) => { if (event.key !== "Escape") event.stopPropagation(); }}>
|
||||
onKeyDown={(event) => {
|
||||
if(event.key!=="Escape"){event.stopPropagation();return;}
|
||||
if(event.defaultPrevented||event.nativeEvent.isComposing||canvasKeyContext(event.nativeEvent).textEditing)return;
|
||||
// The registered overlay owns its mode dismissal; global shortcuts stay out.
|
||||
editing.rotate(null);editing.resize(null);event.preventDefault();event.stopPropagation();
|
||||
}}>
|
||||
{!(["image", "plain-image"].includes(data.kind)) && <label title="文字或图标颜色" className={styles.colorPicker}><span style={{ background: style.color || "#37352f" }} /><input aria-label="文字或图标颜色" type="color" value={style.color || "#37352f"} onChange={(event) => patch({ color: event.target.value })} /></label>}
|
||||
{text && <>
|
||||
<select aria-label="字体" value={style.fontFamily || "sans"} onChange={(event) => patch({ fontFamily: event.target.value as TextStyle["fontFamily"] })}><option value="sans">正文</option><option value="serif">衬线</option><option value="mono">等宽</option></select>
|
||||
@@ -29,8 +37,8 @@ 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} />}
|
||||
{data.kind !== "icon" && <ResizeToggle id={id} renderedTarget={data.__headlessTarget} />}
|
||||
<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>;
|
||||
</motion.div>
|
||||
</NodeToolbar>}</AnimatePresence>;
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
"use client";
|
||||
import { useContext, useEffect, useRef, useState } from "react";
|
||||
import { NodeEditing } from "./transformable-node";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import {useScopedNodeEditing} from "./node-editing";
|
||||
import styles from "./canvas.module.css";
|
||||
export function NodeTitle({ id, title, selected }: { id: string; title: string; selected: boolean }) {
|
||||
const actions = useContext(NodeEditing);
|
||||
export function NodeTitle({ id, title, selected,renderedTarget }: { id: string; title: string; selected: boolean;renderedTarget?:import("@/lib/canvas-bindings/compat-types").RenderedTarget }) {
|
||||
const actions = useScopedNodeEditing(id,renderedTarget);
|
||||
const [editing, setEditing] = useState(false), [value, setValue] = useState(title);
|
||||
const input = useRef<HTMLInputElement>(null), cancelled = useRef(false);
|
||||
useEffect(() => { if (!editing) setValue(title); }, [title, editing]);
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
/** Adapter-only flags consumed by the app's pinned React Flow compiler extension.
|
||||
* They never enter the document, history, persistence or renderer business data. */
|
||||
export function createRetainedProjection(){
|
||||
const tagged=new WeakMap<object,object>();
|
||||
let previous:{nodes:readonly object[];edges:readonly object[];ids:Set<string>;result:{nodes:object[];edges:object[]}}|undefined;
|
||||
return <N extends {id:string},E extends {id:string}>(nodes:N[],edges:E[],ids:ReadonlySet<string>):{nodes:N[];edges:E[]}=>{
|
||||
if(!ids.size){previous=undefined;return{nodes,edges};}
|
||||
if(previous?.nodes===nodes&&previous.edges===edges&&previous.ids.size===ids.size&&[...ids].every(id=>previous!.ids.has(id)))return previous.result as {nodes:N[];edges:E[]};
|
||||
const retain=<T extends {id:string}>(row:T):T=>{
|
||||
if(!ids.has(row.id))return row;
|
||||
let value=tagged.get(row);if(!value){value={...row,__endoleapRetain:true};tagged.set(row,value);}return value as T;
|
||||
};
|
||||
const result={nodes:nodes.map(retain),edges:edges.map(retain)};
|
||||
previous={nodes,edges,ids:new Set(ids),result};return result;
|
||||
};
|
||||
}
|
||||
@@ -1,82 +1,92 @@
|
||||
"use client";
|
||||
|
||||
import { createContext, useContext, useEffect, useRef, type PointerEvent, type ReactNode } from "react";
|
||||
import { useReactFlow, useUpdateNodeInternals, useViewport, type NodeProps, type XYPosition } from "@xyflow/react";
|
||||
import { useEffect, useRef, type PointerEvent, type ReactNode } from "react";
|
||||
import { useReactFlow, 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 { elementCapabilities } from "@/lib/canvas-elements";
|
||||
import type { CanvasNode } from "@/lib/canvas";
|
||||
import { resizeFromPointer, rotationFromPointer, type NodeTransform, type ResizeDirection } from "@/lib/canvas-geometry";
|
||||
import type { EditingLease } from "./editing-lease";
|
||||
import { NodeStyleToolbar } from "./node-style-toolbar";
|
||||
import { MembershipBadge } from "./frame-feedback";
|
||||
import { ContentHandles } from "./content-handles";
|
||||
import {ownsPointerCapture,acquirePointerCapture,releasePointerCapture} from "@/lib/canvas-bindings/pointer-capture";
|
||||
import {canvasKeyContext,routeCanvasKey} from "@/lib/canvas-bindings/input-router";
|
||||
import styles from "./canvas.module.css";
|
||||
import {useNodeInternalsUpdate} from "./node-internals";
|
||||
|
||||
export const NodeEditing = createContext({
|
||||
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; 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: ResizeDirection | "rotate"; target: HTMLElement; pointerId: number };
|
||||
export {NodeEditing} from "./node-editing";
|
||||
import {useScopedNodeEditing} from "./node-editing";
|
||||
type Gesture = { lease?: EditingLease<NodeTransform>; node: CanvasNode; start: XYPosition; direction: ResizeDirection | "rotate"; capture: 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 editing = useScopedNodeEditing(id,data.__headlessTarget);
|
||||
const flow = useReactFlow<CanvasNode>();
|
||||
const update = useUpdateNodeInternals();
|
||||
const update = useNodeInternalsUpdate(id);
|
||||
const { zoom } = useViewport();
|
||||
const frame = useRef<HTMLDivElement>(null);
|
||||
const gesture = useRef<Gesture | null>(null);
|
||||
const interrupt=useRef(()=>{});interrupt.current=()=>cancelGesture();
|
||||
useEffect(()=>{const blur=()=>interrupt.current();window.addEventListener('blur',blur);return()=>{window.removeEventListener('blur',blur);interrupt.current();};},[]);
|
||||
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(() => { update(); }, [id, rotation, width, height, selected, update]);
|
||||
useEffect(() => {
|
||||
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 + '"], [data-resize-toggle="' + id + '"]')) { editing.rotate(null); editing.resize(null); }
|
||||
};
|
||||
const escape = (event: KeyboardEvent) => { if (event.key === "Escape") { cancelGesture(); editing.rotate(null); editing.resize(null); } };
|
||||
const escape = (event: KeyboardEvent) => {
|
||||
if(event.key!=="Escape")return;
|
||||
const claim=routeCanvasKey({key:event.key,composing:event.isComposing,defaultPrevented:event.defaultPrevented,...canvasKeyContext(event)},{tool:()=>{cancelGesture();editing.rotate(null);editing.resize(null);return true;}});
|
||||
if(claim==='tool'){event.preventDefault();event.stopPropagation();}
|
||||
};
|
||||
window.addEventListener("pointerdown", outside, true);
|
||||
window.addEventListener("keydown", escape);
|
||||
return () => { window.removeEventListener("pointerdown", outside, true); window.removeEventListener("keydown", escape); };
|
||||
}, [rotating, resizing, id, editing]);
|
||||
|
||||
function start(event: PointerEvent<HTMLElement>, direction: Gesture["direction"]) {
|
||||
if (event.button !== 0) return;
|
||||
event.preventDefault(); event.stopPropagation();
|
||||
if (event.button !== 0||event.defaultPrevented) return;
|
||||
if(direction==='rotate'&&event.target instanceof Element&&event.target.closest('input,textarea,[contenteditable]:not([contenteditable="false"]),[role="textbox"]'))return;
|
||||
if(gesture.current){if(gesture.current.pointerId!==event.pointerId)cancelGesture();return;}
|
||||
if(event.isPrimary===false)return;
|
||||
const node = flow.getNode(id);
|
||||
if (!node) return;
|
||||
editing.begin(id);
|
||||
const lease = editing.beginTransform?.(id); if (lease === false) return;
|
||||
if (!editing.beginTransform) editing.begin(id);
|
||||
if (!node.selected) editing.select(id);
|
||||
gesture.current = { node, direction, target: event.currentTarget, pointerId: event.pointerId, start: flow.screenToFlowPosition({ x: event.clientX, y: event.clientY }) };
|
||||
event.currentTarget.setPointerCapture(event.pointerId);
|
||||
gesture.current = { lease: lease && typeof lease === 'object' ? lease : undefined, node, direction, capture: event.currentTarget, pointerId: event.pointerId, start: flow.screenToFlowPosition({ x: event.clientX, y: event.clientY }) };
|
||||
if(!acquirePointerCapture(event.currentTarget,event.pointerId)){cancelGesture();return;}
|
||||
event.preventDefault();event.stopPropagation();
|
||||
}
|
||||
function move(event: PointerEvent<HTMLElement>) {
|
||||
if (!gesture.current) return;
|
||||
event.stopPropagation();
|
||||
if(!gesture.current)return;event.stopPropagation();
|
||||
if (!ownsPointerCapture(gesture.current,event)) return;
|
||||
const current = flow.screenToFlowPosition({ x: event.clientX, y: event.clientY });
|
||||
const { node, start, direction } = gesture.current;
|
||||
editing.transform(id, direction === "rotate"
|
||||
const { node, start, direction } = gesture.current!;
|
||||
const change = direction === "rotate"
|
||||
? { rotation: rotationFromPointer(node, start, current, event.shiftKey) }
|
||||
: resizeFromPointer(node, direction, { x: current.x - start.x, y: current.y - start.y }, resizing ? event.shiftKey : undefined));
|
||||
: resizeFromPointer(node, direction, { x: current.x - start.x, y: current.y - start.y }, resizing ? event.shiftKey : undefined);
|
||||
if (gesture.current!.lease) gesture.current!.lease!.preview(change); else editing.transform(id, change);
|
||||
}
|
||||
function cancelGesture() {
|
||||
function cancelGesture(event?:{pointerId:number;type?:string;target?:unknown;stopPropagation?():void}) {
|
||||
if(event&&gesture.current)event.stopPropagation?.();
|
||||
if(event&&!ownsPointerCapture(gesture.current,event))return;
|
||||
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);
|
||||
if (current.lease) current.lease.cancel(); else if (editing.cancelTransform) editing.cancelTransform(); else editing.transform(current.node.id, current.direction === "rotate" ? { rotation: current.node.data.rotation || 0 } : { position: current.node.position, width: current.node.width, height: current.node.height });
|
||||
releasePointerCapture(current.capture,current.pointerId);
|
||||
}
|
||||
function finish(event: PointerEvent<HTMLElement>) {
|
||||
event.stopPropagation(); gesture.current = null;
|
||||
if (event.currentTarget.hasPointerCapture(event.pointerId)) event.currentTarget.releasePointerCapture(event.pointerId);
|
||||
if(!gesture.current)return;event.stopPropagation();
|
||||
if (!ownsPointerCapture(gesture.current,event)) return; const current = gesture.current!; move(event); gesture.current = null;
|
||||
if (current.lease) current.lease.commit(); else editing.finishTransform?.();
|
||||
releasePointerCapture(current.capture,current.pointerId);
|
||||
}
|
||||
return <>
|
||||
{!props.hideStyleToolbar && <NodeStyleToolbar {...props} />}
|
||||
@@ -101,8 +111,9 @@ export function TransformableNode(props: NodeProps<CanvasNode> & { children: Rea
|
||||
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));
|
||||
const lease = editing.beginTransform?.(id); if (lease === false) return; if (!editing.beginTransform) editing.begin(id); const scale = (data.viewScale || 1) * (event.ctrlKey || event.metaKey ? 10 : 1);
|
||||
const change = resizeFromPointer(node, direction, { x: delta[0] * scale, y: delta[1] * scale }, event.shiftKey);
|
||||
if (lease && typeof lease === 'object') { lease.preview(change); lease.commit(); } else { editing.transform(id, change); editing.finishTransform?.(); }
|
||||
}} />;
|
||||
})}
|
||||
{resizing && <span className={styles.rotationHint}>拖动八向手柄 · Shift 固定比例 · Esc 退出</span>}
|
||||
@@ -111,7 +122,7 @@ export function TransformableNode(props: NodeProps<CanvasNode> & { children: Rea
|
||||
</>;
|
||||
}
|
||||
|
||||
export function ResizeToggle({ id }: { id: string }) {
|
||||
const editing = useContext(NodeEditing);
|
||||
export function ResizeToggle({ id,renderedTarget }: { id: string;renderedTarget?:import("@/lib/canvas-bindings/compat-types").RenderedTarget }) {
|
||||
const editing = useScopedNodeEditing(id,renderedTarget);
|
||||
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,21 @@
|
||||
"use client";
|
||||
|
||||
import { BaseEdge, Handle, Position, getBezierPath, type EdgeProps, type NodeProps } from "@xyflow/react";
|
||||
import type {ReactFlowProjectionNode as ProjectionNode} from "@/lib/canvas-bindings/react-flow";
|
||||
import type { CanvasEdgeModel } from "@/lib/canvas-v2";
|
||||
import styles from "./canvas.module.css";
|
||||
const positions={top:Position.Top,right:Position.Right,bottom:Position.Bottom,left:Position.Left};
|
||||
|
||||
/** Render only the known envelope and derived endpoints, never interpret opaque payload fields. */
|
||||
export function UnsupportedNode({data,selected}:NodeProps<ProjectionNode>){
|
||||
const fallback=data.__headlessFallback;
|
||||
return <div className={`${styles.node} ${selected?styles.selectedNode:""}`} style={{width:"100%",height:"100%",padding:14,transform:`rotate(${data.rotation||0}deg)`}} role="note" aria-label="暂不支持编辑的元素">
|
||||
<span>{data.title}</span><small style={{display:"block"}}>此元素需要兼容的组件版本才能编辑</small>
|
||||
<small title={fallback?`${fallback.componentId} · v${fallback.dataVersion}`:undefined}>{fallback?.reason==='renderer-unavailable'?'当前视图暂无此类型的编辑器':'原始内容已保留'}</small>
|
||||
{fallback?.handles.map(handle=><Handle key={handle.id} id={handle.id} type="source" position={positions[handle.side]} className={styles.centerHandle} isConnectable={false} isConnectableStart={false} isConnectableEnd={false} aria-hidden />)}
|
||||
</div>;
|
||||
}
|
||||
export function UnsupportedEdge(props:EdgeProps<CanvasEdgeModel>){
|
||||
const [path]=getBezierPath(props);
|
||||
return <g role="img" aria-label="暂不支持编辑的连线"><title>原始连线内容已保留</title><BaseEdge id={props.id} path={path} style={{stroke:'#aa968b',strokeWidth:1.5}} /></g>;
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
"use client";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { EdgeChange, NodeChange } from "@xyflow/react";
|
||||
import { api, ApiError } from "@/lib/api";
|
||||
import { api as requestAPI, ApiError } from "@/lib/api";
|
||||
import { canvasReducer, decodeCanvas, type CanvasAction, type CanvasDocument, type CanvasHistory, type CanvasNode } from "@/lib/canvas";
|
||||
import { CanvasSync, canvasFingerprint, canvasPayload, canvasPath, decodeSnapshot, readLocalRecovery, type CanvasSnapshot } from "@/lib/canvas-sync";
|
||||
import { CanvasSync, canvasFingerprint, canvasPayload, canvasPath, decodeSnapshot, readLocalRecovery, type CanvasSnapshot, type CanvasRequest } from "@/lib/canvas-sync";
|
||||
import { CANVAS_ACCEPT, type EdgeData } from "@/lib/canvas-v2";
|
||||
import { subgraphMovePath, subgraphMoveParticipants } from "@/lib/canvas-subgraphs";
|
||||
import { mergeCanvasDocuments, projectCanvasTree, transformEdgeData, referenceAfterMoves } from "@/lib/canvas-tree";
|
||||
@@ -16,7 +16,19 @@ type Entry = LocalHistory | TransactionHistory;
|
||||
type MemberJob = { source: string; target: string; ids: string[]; parent: string; frame: string; participants: string[]; error?: string; refresh?: boolean; run?: () => Promise<void>; resolve: () => void };
|
||||
const history = (document: CanvasDocument): CanvasHistory => ({ past: [], present: document, future: [] });
|
||||
|
||||
export function useCanvasTree(root: string, workspace: string, initial: CanvasDocument, snapshot?: CanvasSnapshot, paused = false, parentBoardId?: string) {
|
||||
export function useCanvasTree(root: string, workspace: string, initial: CanvasDocument, snapshot?: CanvasSnapshot, paused = false, parentBoardId?: string, migration?: {accountId:string;isCurrentIdentity():boolean}) {
|
||||
const migrationGate=useRef(!!migration);
|
||||
const migrationFlush=useRef(false);
|
||||
const [migrationHeld,setMigrationState]=useState(!!migration);
|
||||
const migrationBlocked=()=>migrationGate.current||!!migration&&!migration.isCurrentIdentity();
|
||||
function setMigrationHeld(value:boolean){migrationGate.current=value;setMigrationState(value);}
|
||||
const api:CanvasRequest=async <T,>(path:string,options?:RequestInit)=>{
|
||||
if(migration&&!migration.isCurrentIdentity())throw new Error('identity_changed');
|
||||
if(migrationBlocked()&&options?.method&&options.method!=='GET'&&!(migrationFlush.current&&options.method==='PATCH'&&path.endsWith('/canvas')))throw new Error('canvas_busy');
|
||||
const headers=new Headers(options?.headers);if(migration)headers.set('X-Expected-Account',migration.accountId);
|
||||
const value=await requestAPI<T>(path,{...options,headers:Object.fromEntries(headers)});
|
||||
if(migration&&!migration.isCurrentIdentity())throw new Error('identity_changed');return value;
|
||||
};
|
||||
const [documents, setDocuments] = useState(new Map([[root, initial]]));
|
||||
const current = useRef(documents); current.current = documents;
|
||||
const [status, setStatus] = useState("saved");
|
||||
@@ -45,6 +57,7 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo
|
||||
if (local) { const next = new Map(pendingRecovery.current).set(boardId, local); pendingRecovery.current = next; setRecoveries(next); }
|
||||
}
|
||||
function resolveRecovery(boardId: string, restore: boolean) {
|
||||
if(migrationBlocked())return;
|
||||
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);
|
||||
@@ -68,7 +81,7 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo
|
||||
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;
|
||||
if (migrationBlocked() || locked.current || retryOperation.current) return;
|
||||
const before = current.current.get(boardId); if (!before) return;
|
||||
const result = canvasReducer(history(before), action);
|
||||
const protectedIDs = protectedNodes(boardId);
|
||||
@@ -84,7 +97,7 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo
|
||||
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;
|
||||
if (migrationBlocked() || 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) {
|
||||
@@ -180,8 +193,9 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [documents, retryLoad]);
|
||||
useEffect(() => {
|
||||
if (paused) return;
|
||||
if (paused || migrationBlocked()) return;
|
||||
const backup = setTimeout(() => {
|
||||
if(migrationBlocked())return;
|
||||
for (const [boardId, document] of current.current) try {
|
||||
if (pendingRecovery.current.has(boardId)) continue;
|
||||
const writer = writers.current.get(boardId);
|
||||
@@ -189,14 +203,14 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo
|
||||
} catch { setError("local_backup_failed"); }
|
||||
}, 250);
|
||||
const save = setTimeout(() => {
|
||||
if (locked.current || retryOperation.current || pause.current || members.current.length || dragging.current) return;
|
||||
if (migrationBlocked() || locked.current || retryOperation.current || pause.current || members.current.length || dragging.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, memberJobs]);
|
||||
}, [documents, paused, workspace, memberJobs, migrationHeld]);
|
||||
useEffect(() => {
|
||||
const backup = () => {
|
||||
if (pause.current) return;
|
||||
if (pause.current || migrationBlocked()) 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: canvasPayload(document), baseVersion: writer?.version || 1, dirty: writer ? writer.needsSave(document) : false })); } catch { /* Existing draft is retained if storage is full. */ }
|
||||
@@ -214,8 +228,35 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo
|
||||
await writer.enqueue(saving); await writer.settled(); if (writer.error) throw new Error(writer.error);
|
||||
}
|
||||
}
|
||||
async function prepareMigration(projectRoot:string){
|
||||
if(!migrationGate.current)throw new Error('migration_gate_required');
|
||||
if(locked.current||retryOperation.current||members.current.length||dragging.current||loading.current.size)throw new Error('canvas_busy');
|
||||
if(pause.current||pendingRecovery.current.size)throw new Error('draft_recovery_required');
|
||||
await Promise.all([...writers.current.values()].map(writer=>writer.settled()));
|
||||
await ensure(projectRoot,true);
|
||||
if(!mounted.current||migration&&!migration.isCurrentIdentity())throw new Error('identity_changed');
|
||||
if(pendingRecovery.current.size)throw new Error('draft_recovery_required');
|
||||
if([...current.current.values()].some(document=>document.nodes.some(node=>node.pendingUpload)))throw new Error('migration_pending_uploads');
|
||||
for(const[boardId,document]of current.current){
|
||||
const writer=writers.current.get(boardId);
|
||||
const local=readLocalRecovery(localStorage,`endoleap_canvas_v1:${workspace}:${boardId}`,writer?{version:writer.version,document:writer.document}:undefined);
|
||||
if(local&&canvasFingerprint(local,false)!==canvasFingerprint(document,false)){
|
||||
const next=new Map(pendingRecovery.current).set(boardId,local);pendingRecovery.current=next;setRecoveries(next);throw new Error('draft_recovery_required');
|
||||
}
|
||||
// Preserve the current view even if it closes while PATCH is in flight.
|
||||
localStorage.setItem(`endoleap_canvas_v1:${workspace}:${boardId}:recovery`,JSON.stringify({document:canvasPayload(document),baseVersion:writer?.version||1,dirty:writer?writer.needsSave(document):false}));
|
||||
}
|
||||
migrationFlush.current=true;
|
||||
try{await flush();}finally{migrationFlush.current=false;}
|
||||
if(!mounted.current||migration&&!migration.isCurrentIdentity())throw new Error('identity_changed');
|
||||
for(const[boardId,document]of current.current){
|
||||
const writer=writers.current.get(boardId);
|
||||
if(writer?.error||writer?.needsSave(document))throw new Error(writer.error||'canvas_busy');
|
||||
localStorage.setItem(`endoleap_canvas_v1:${workspace}:${boardId}:recovery`,JSON.stringify({document:canvasPayload(document),baseVersion:writer?.version||1,dirty:false}));
|
||||
}
|
||||
}
|
||||
async function exclusive<T>(operation: () => Promise<T>) {
|
||||
if (locked.current || pause.current || members.current.length) throw new Error("canvas_busy");
|
||||
if (migrationBlocked() || locked.current || pause.current || members.current.length) 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); }
|
||||
@@ -280,7 +321,7 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo
|
||||
// Ownership changes pause autosaves, not unrelated local editing. Server
|
||||
// acknowledgements merge against the persisted base, preserving intervening edits.
|
||||
async function runMembers() {
|
||||
if (memberRunning.current || !members.current.length) return;
|
||||
if (migrationBlocked() || memberRunning.current || !members.current.length) return;
|
||||
const job = members.current[0]; memberRunning.current = true; job.error = undefined; showMembers();
|
||||
try {
|
||||
if (!job.run) {
|
||||
@@ -345,12 +386,13 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo
|
||||
}
|
||||
}
|
||||
function moveMembers(parent: string, frame: string, source: string, target: string, ids: string[], participants: string[]) {
|
||||
if (locked.current || retryOperation.current || pause.current || members.current.some((job) => job.ids.some((id) => ids.includes(id)) && [job.source, job.target].includes(source))) return Promise.reject(new Error("canvas_busy"));
|
||||
if (migrationBlocked() || locked.current || retryOperation.current || pause.current || members.current.some((job) => job.ids.some((id) => ids.includes(id)) && [job.source, job.target].includes(source))) return Promise.reject(new Error("canvas_busy"));
|
||||
return new Promise<void>((resolve) => {
|
||||
members.current.push({ parent, frame, source, target, ids, participants, resolve }); showMembers(); void runMembers();
|
||||
});
|
||||
}
|
||||
function resolveImageUpload(id: string, asset?: { url: string; id: string }, size?: {width:number;height:number}) {
|
||||
if(migrationBlocked())return;
|
||||
const resolve = (document: CanvasDocument): CanvasDocument => ({ ...document, nodes: document.nodes.map((node) => {
|
||||
if (node.pendingUpload !== id) return node;
|
||||
const fitted=node.data.kind==="plain-image"&&node.width===320&&node.height===240?size:undefined;
|
||||
@@ -362,6 +404,7 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo
|
||||
publish(new Map([...current.current].map(([board,doc])=>[board,resolve(doc)])));
|
||||
}
|
||||
function movePendingImage(source: string, target: string, id: string) {
|
||||
if(migrationBlocked())throw new Error("canvas_busy");
|
||||
const from = current.current.get(source)!, to = current.current.get(target)!;
|
||||
const node = from.nodes.find((node)=>node.id===id); if (!node?.pendingUpload || to.nodes.length>=500 || to.nodes.some((item)=>item.id===id)) throw new Error("invalid_reference");
|
||||
const projected=projectCanvasTree(root,current.current), a=projected.boards.get(source)!,b=projected.boards.get(target)!;
|
||||
@@ -395,7 +438,7 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo
|
||||
}
|
||||
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 || members.current.length) return;
|
||||
const original = stack.at(-1); if (!original || migrationBlocked() || locked.current || retryOperation.current || members.current.length) 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.related?{related:new Map([...original.related].map(([id,doc])=>[alias(id),remapDocument(doc)]))}:{}) } : original;
|
||||
@@ -430,5 +473,5 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo
|
||||
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, resolveImageUpload, movePendingImage, memberJobs, protectedNodes, setDragging, moveMembers, retryMembers: () => void runMembers(), transactionBusy: !!memberJobs.length, 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: !memberJobs.length && !!undo.current.length, canRedo: !memberJobs.length && !!redo.current.length, writers, rootDocument: documents.get(root)!, setError };
|
||||
return { migrationHeld,setMigrationHeld,prepareMigration,isMigrationHeld:migrationBlocked, documents, current, views, resolveImageUpload, movePendingImage, memberJobs, protectedNodes, setDragging, moveMembers, retryMembers: () => void runMembers(), transactionBusy: !!memberJobs.length, 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 || migrationHeld, error, status, retry, undo: () => undoAction(), redo: () => undoAction(true), canUndo: !memberJobs.length && !!undo.current.length, canRedo: !memberJobs.length && !!redo.current.length, writers, rootDocument: documents.get(root)!, setError };
|
||||
}
|
||||
|
||||
@@ -43,5 +43,5 @@ export function useImageUploads(workspace: string, commit: (draft: ImageUploadDr
|
||||
void run(job);
|
||||
return job;
|
||||
}
|
||||
return { jobs, active: jobs.some((job) => job.status === "uploading"), start, retry: (id: string) => { const job = pending.current.get(id); if (job) void run(job); }, remove, loaded: (id: string) => { if (pending.current.get(id)?.status === "uploaded") remove(id); } };
|
||||
return { jobs, hasPending:()=>[...pending.current.values()].some(job=>job.status!=="uploaded"), active: jobs.some((job) => job.status === "uploading"), start, retry: (id: string) => { const job = pending.current.get(id); if (job) void run(job); }, remove, loaded: (id: string) => { if (pending.current.get(id)?.status === "uploaded") remove(id); } };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
"use client";
|
||||
|
||||
import { createContext, useContext, useEffect, useRef, useState, type ReactNode } from "react";
|
||||
import { defaultCanvasRenderers } from "@/components/canvas/canvas-renderers";
|
||||
import { createReactRenderers, type CanvasRuntimeExtension } from "@/lib/canvas-bindings/react-flow";
|
||||
import { notify } from "@/lib/notifications";
|
||||
import { errorText } from "@/lib/api";
|
||||
import { createRegistry, builtinComponents } from "@/lib/canvas-core/index";
|
||||
import { createRuntimeHost, createWorkspaceRuntime, type RuntimeHost } from "@/lib/canvas-runtime/index";
|
||||
import { createBrowserDigests, createBrowserImageDecoder, createBrowserStorage } from "@/lib/canvas-runtime/browser";
|
||||
import { createFoundryAssets, createFoundryPersistence, createFoundryTransport, createFoundryRuns,createFoundryModelLoader } from "@/lib/canvas-service/index";
|
||||
|
||||
const Context = createContext<RuntimeHost | null>(null);
|
||||
const Renderers=createContext(defaultCanvasRenderers);
|
||||
export function useCanvasRenderers(){return useContext(Renderers);}
|
||||
export function CanvasRuntimeHost({ children,extensions=[] }: { children: ReactNode;extensions?:readonly CanvasRuntimeExtension[] }) {
|
||||
const [{host,renderers}] = useState(() => {
|
||||
const registry = createRegistry([...builtinComponents(),...extensions.flatMap(extension=>extension.components??[])]),renderers=createReactRenderers([...defaultCanvasRenderers.definitions,...extensions.flatMap(extension=>extension.renderers??[])],registry);
|
||||
const host=createRuntimeHost({ registry,
|
||||
modelLoader(accountId,workspaceId,_authEpoch,current){return createFoundryModelLoader({workspaceId,registry,transport:createFoundryTransport({accountId,isCurrentIdentity:current})});},
|
||||
workspace(accountId, workspaceId, authEpoch, isCurrentIdentity) {
|
||||
return createWorkspaceRuntime({ accountId, workspaceId, authEpoch, isCurrentIdentity, registry, storage: createBrowserStorage(),digests:createBrowserDigests(),images:createBrowserImageDecoder(),
|
||||
adapters(rootId, current) {
|
||||
const transport = createFoundryTransport({ accountId, isCurrentIdentity: current });
|
||||
return { runs: createFoundryRuns({ workspaceId, rootId, registry, transport }), persistence: createFoundryPersistence({ workspaceId, rootId, registry, transport, resolvedReceiptPolicy:'summary' }), assets: createFoundryAssets({ workspaceId, transport, project: process.env.NEXT_PUBLIC_FOUNDRY_PROJECT || "endoleap", origin: process.env.NEXT_PUBLIC_FOUNDRY_API_ORIGIN }) };
|
||||
},
|
||||
onError: error => notify({ id: `canvas-runtime:${accountId}:${workspaceId}`, title: "画布同步需要处理", description: errorText(error), level: "error" }),
|
||||
});
|
||||
},
|
||||
onError: error => notify({ id: "canvas-host-close", title: "本地修改尚未保存成功", description: errorText(error), level: "error" }),
|
||||
});
|
||||
return {host,renderers};
|
||||
});
|
||||
const lifetime = useRef(0);
|
||||
useEffect(() => {
|
||||
const generation = ++lifetime.current;
|
||||
// Strict Mode replays effects synchronously; only a real detach retires the host.
|
||||
return () => { queueMicrotask(() => { if (lifetime.current === generation) void host.close().catch(error => console.error("[canvas-host]", error.message)); }); };
|
||||
}, [host]);
|
||||
return <Context.Provider value={host}><Renderers.Provider value={renderers}>{children}</Renderers.Provider></Context.Provider>;
|
||||
}
|
||||
export function useCanvasRuntimeHost() {
|
||||
const host = useContext(Context);
|
||||
if (!host) throw new Error("CanvasRuntimeHost is missing");
|
||||
return host;
|
||||
}
|
||||
@@ -1,30 +1,41 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import type {Viewport} from '@xyflow/react';
|
||||
import type {LiveModelPreview} from '@/lib/canvas-runtime/model-preview';
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { api, errorText } from "@/lib/api";
|
||||
import { readDemoBoards } from "@/lib/demo-workspace";
|
||||
import { matchesRef, type Board } from "@/lib/workspace";
|
||||
import { loadBoardCanvas } from "@/lib/canvas-loader";
|
||||
import { loadCanvasEntry } from "@/lib/canvas-entry";
|
||||
import {createBrowserCanvasMigration,type LegacyMigrationContext} from "@/lib/browser-canvas-migration";
|
||||
import {readCanvasMigration} from "@/lib/canvas-migration";
|
||||
import type { ProjectEngine } from "@/lib/canvas-runtime/workspace";
|
||||
import { useCanvasRuntimeHost, useCanvasRenderers } from "./canvas-runtime-host";
|
||||
import type { CanvasSnapshot } from "@/lib/canvas-sync";
|
||||
import styles from "./workspace.module.css";
|
||||
import { useActiveBoard } from "./active-board";
|
||||
import dynamic from "next/dynamic";
|
||||
const GraphCanvas = dynamic(() => import("@/components/canvas/graph-canvas"), { ssr: false, loading: () => <p role="status">正在准备画布…</p> });
|
||||
|
||||
type Loaded = { board: Board; snapshot?: CanvasSnapshot; route: string };
|
||||
const HeadlessGraphCanvas = dynamic(() => import("@/components/canvas/headless-graph-canvas"), { ssr: false, loading: () => <p role="status">正在准备画布…</p> });
|
||||
const LoadingGraphCanvas=dynamic(()=>import("@/components/canvas/loading-graph-canvas"),{ssr:false});
|
||||
type Loaded = { board: Board; snapshot?: CanvasSnapshot; route: string; engine?: ProjectEngine; migration?:LegacyMigrationContext };
|
||||
export default function GraphPage({ workspaceId, boardId }: { workspaceId: string; boardId: string }) {
|
||||
const renderers=useCanvasRenderers();
|
||||
const demo = useSearchParams().get("demo") === "1";
|
||||
const router = useRouter();
|
||||
const runtimeHost = useCanvasRuntimeHost();
|
||||
const route = workspaceId + ":" + boardId;
|
||||
const { board: activeBoard, setBoard: setActiveBoard } = useActiveBoard();
|
||||
const [loaded, setLoaded] = useState<Loaded | null>(null);
|
||||
const [preview,setPreview]=useState<{board:Board;value:LiveModelPreview;route:string}|null>(null);
|
||||
const previewViewport=useRef<Viewport|undefined>(undefined);
|
||||
const [error, setError] = useState("");
|
||||
const [retry, setRetry] = useState(0);
|
||||
const navigationGeneration=useRef(0);
|
||||
useEffect(() => {
|
||||
setLoaded(null); setError("");
|
||||
// Fetch data while the much larger editor chunk loads.
|
||||
void import("@/components/canvas/graph-canvas").catch(() => {});
|
||||
setLoaded(null);setPreview(null);previewViewport.current=undefined; setError("");
|
||||
if (demo) {
|
||||
const item = readDemoBoards(workspaceId).find((board) => matchesRef(board, boardId));
|
||||
if (item) setLoaded({ board: item, route });
|
||||
@@ -33,20 +44,48 @@ export default function GraphPage({ workspaceId, boardId }: { workspaceId: strin
|
||||
}
|
||||
let active = true;
|
||||
const controller = new AbortController();
|
||||
loadBoardCanvas(api, workspaceId, boardId, controller.signal)
|
||||
.then((result) => { if (active) setLoaded({ ...result, route }); })
|
||||
const startingIdentity=runtimeHost.identity();let currentIdentity:(()=>boolean)|undefined;
|
||||
let latest:{value:LiveModelPreview;board:Board;accountId:string}|undefined,pumping:Promise<void>|undefined,partialEngine:ProjectEngine|undefined,partialRuntime:ReturnType<typeof runtimeHost.workspace>|undefined;
|
||||
const reportBackground=(error:unknown)=>{if(active)setError(errorText(error));};
|
||||
const completeInBackground=()=>{if(partialEngine?.loading.getState()&¤tIdentity?.())void partialRuntime?.resumeLoading(partialEngine.getSnapshot().document.projectId).catch(reportBackground);};
|
||||
function pump(){
|
||||
if(pumping)return pumping;
|
||||
const task=(async()=>{while(latest){const next=latest;latest=undefined;if(!currentIdentity?.())return;const runtime=runtimeHost.workspace(next.accountId,next.board.workspace_id);partialRuntime=runtime;const engine=await runtime.openPartial(next.value.document,next.value.versions,next.value.loading);partialEngine=engine;if(active&¤tIdentity?.()){setLoaded(previous=>previous?.route===route&&previous.engine===engine?previous:{board:next.board,engine,route});setPreview(null);}else{completeInBackground();return;}await engine.persist();}})();
|
||||
pumping=task.finally(()=>{pumping=undefined;if(latest&&active)void pump().catch(reportBackground);});return pumping;
|
||||
}
|
||||
|
||||
loadCanvasEntry({ request: api, workspaceRef: workspaceId, boardRef: boardId, registry: runtimeHost.registry, signal: controller.signal,navigationGeneration:++navigationGeneration.current,
|
||||
onPreview:(value,board,profile)=>{if(active&¤tIdentity?.()&&value.document.canvases.has(board.id)){if(!partialEngine)setPreview({board,value,route});if(value.document.elements.size){latest={value,board,accountId:profile.id};void pump().catch(reportBackground);}}},
|
||||
readModel:input=>{const identity=runtimeHost.identity();if(identity.authEpoch!==startingIdentity.authEpoch&&identity.accountId!==input.accountId)throw new Error('identity_changed');currentIdentity=runtimeHost.bindAccount(input.accountId);return runtimeHost.loadModel(input.accountId,input.workspaceId,input.canvasId,{signal:input.signal,navigationGeneration:input.navigationGeneration,onPreview:input.onPreview});},
|
||||
}).then(async result => {
|
||||
if (!active||currentIdentity&&!currentIdentity()) return;
|
||||
if (result.mode === 'legacy') {
|
||||
const migration:LegacyMigrationContext={accountId:result.profile.id,rootId:result.migration?.rootId,registry:runtimeHost.registry,isCurrentIdentity:()=>!!currentIdentity?.(),onApplied:()=>{if(active&¤tIdentity?.())setRetry(value=>value+1);}};
|
||||
setLoaded({board:result.board,snapshot:result.snapshot,route,migration});setPreview(null);return;
|
||||
}
|
||||
const pending=readCanvasMigration(localStorage,{accountId:result.profile.id,workspaceId:result.board.workspace_id});
|
||||
if(pending?.phase==='pending'&&pending.rootId===result.model.document.projectId){
|
||||
try{await createBrowserCanvasMigration({scope:{accountId:result.profile.id,workspaceId:result.board.workspace_id},registry:runtimeHost.registry,isCurrentIdentity:()=>active&&!!currentIdentity?.(),signal:controller.signal}).resume();}
|
||||
catch(error){reportBackground(error);}
|
||||
}
|
||||
while(pumping)await pumping;
|
||||
if(!active||!currentIdentity?.())return;
|
||||
const engine = await runtimeHost.workspace(result.profile.id, result.board.workspace_id).open(result.model.document, result.model.versions,{sourceToken:result.model.sourceToken});
|
||||
if (active&&(!currentIdentity||currentIdentity())){setLoaded({ board: result.board, engine, route });setPreview(null);}
|
||||
})
|
||||
.catch((error) => { if (active) { setError(errorText(error)); if (error.message === "invalid_session") router.replace("/login"); } });
|
||||
return () => { active = false; controller.abort(); };
|
||||
}, [workspaceId, boardId, route, demo, retry, router]);
|
||||
return () => { active = false;latest=undefined; controller.abort();completeInBackground(); };
|
||||
}, [workspaceId, boardId, route, demo, retry, router, runtimeHost]);
|
||||
const current = loaded?.route === route ? loaded : null;
|
||||
const board = current?.board;
|
||||
const currentPreview=preview?.route===route?preview:null;
|
||||
const board = current?.board??currentPreview?.board;
|
||||
useEffect(() => {
|
||||
if (board) setActiveBoard({ id: board.id, slug: board.slug, workspace_id: board.workspace_id, workspace_ref: workspaceId, title: board.title });
|
||||
return () => setActiveBoard(null);
|
||||
}, [board, workspaceId, setActiveBoard]);
|
||||
const displayBoard = board && activeBoard?.id === board.id && activeBoard.workspace_id === board.workspace_id ? { ...board, title: activeBoard.title } : board;
|
||||
return <main className={styles.graph} aria-label={displayBoard ? displayBoard.title + " · 无限图谱" : "无限图谱"} aria-busy={!board && !error}>
|
||||
{displayBoard ? <GraphCanvas key={displayBoard.workspace_id + ":" + displayBoard.id} board={displayBoard} snapshot={current?.snapshot} /> : <div className={styles.graphSurface} aria-label="正在读取画布" />}
|
||||
return <main className={styles.graph} aria-label={displayBoard ? displayBoard.title + " · 无限图谱" : "无限图谱"} aria-busy={!current && !error}>
|
||||
{displayBoard && current?.engine ? <HeadlessGraphCanvas key={displayBoard.workspace_id + ":" + displayBoard.id} board={displayBoard} engine={current.engine} renderers={renderers} initialViewport={previewViewport.current} /> : displayBoard&¤tPreview ? <LoadingGraphCanvas key={displayBoard.workspace_id+":"+displayBoard.id} preview={currentPreview.value} canvasId={displayBoard.id} workspaceId={displayBoard.workspace_id} registry={runtimeHost.registry} renderers={renderers} onViewport={viewport=>{previewViewport.current=viewport;}} /> : displayBoard ? <GraphCanvas key={(current?.migration?.accountId??"demo")+":"+displayBoard.workspace_id+":"+displayBoard.id} board={displayBoard} snapshot={current?.snapshot} migration={current?.migration} /> : <div className={styles.graphSurface} aria-label="正在读取画布" />}
|
||||
{error ? <div className={styles.graphStatus} role="alert">{error} <button onClick={() => setRetry((value) => value + 1)}>重新加载</button></div> : !board && <p className={styles.graphStatus} role="status">正在打开图谱…</p>}
|
||||
</main>;
|
||||
}
|
||||
|
||||
@@ -94,7 +94,8 @@
|
||||
.newWorkspace { color: #2383e2; }
|
||||
.logout { color: #78746d; font-size: 12px; }
|
||||
.separator { height: 1px; margin: 6px 7px; background: #eeece8; }
|
||||
.floating { position: fixed; left: 12px; top: 12px; max-width: calc(100vw - 24px); z-index: 40; }
|
||||
.floating { --floating-left: 12px; position: fixed; left: var(--floating-left); top: 12px; max-width: calc(100vw - var(--floating-left) - 12px); z-index: 40; transition: left 240ms cubic-bezier(0.22, 1, 0.36, 1), max-width 240ms cubic-bezier(0.22, 1, 0.36, 1); }
|
||||
.floating[data-sidebar-collapsed="false"], .accountPopup[data-sidebar-collapsed="false"] { --floating-left: calc(min(var(--sidebar-width), calc(100vw - 40px)) + 12px); }
|
||||
.floatingError { position: absolute; left: 0; top: 42px; width: 240px; padding: 10px; background: white; border: 1px solid #e7e2db; border-radius: 8px; color: #b14b3c; font-size: 12px; }
|
||||
.settingsPanel { color: #37352f; border-color: #e6e2dc; border-radius: 12px; }
|
||||
.settingsForm { display: flex; flex-direction: column; gap: 16px; padding-top: 4px; }
|
||||
@@ -119,11 +120,17 @@
|
||||
|
||||
.floatingAvatarButton { display: grid; place-items: center; flex: 0 0 auto; box-sizing: border-box; width: var(--toolbar-control-width, 32px); height: var(--toolbar-control-height, 32px); border-radius: 5px; }
|
||||
.floatingAvatarButton:hover { background: #e9e7e3; }
|
||||
.accountPopup[data-floating="true"] { inset: 62px auto auto 12px; max-height: calc(100dvh - 76px); transform-origin: top left; }
|
||||
.accountPopup[data-floating="true"] { inset: 62px auto auto clamp(8px, var(--floating-left, 12px), max(8px, calc(100vw - 308px))); max-height: calc(100dvh - 76px); transform-origin: top left; }
|
||||
|
||||
.floatingAvatarButton .avatar { flex-basis: var(--toolbar-icon-size, 20px); width: var(--toolbar-icon-size, 20px); height: var(--toolbar-icon-size, 20px); font-size: 14px; }
|
||||
.floatingTitle { flex: 1 1 auto; min-width: 0; max-width: 240px; margin: 0; color: #37352f; font-size: 14px; line-height: var(--toolbar-control-height, 32px); font-weight: 500; overflow: visible; }
|
||||
@media (max-width: 700px) { .floatingTitle { max-width: 180px; } }
|
||||
@media (max-width: 420px) {
|
||||
.floating[data-sidebar-collapsed="false"] .logoLink,
|
||||
.floating[data-sidebar-collapsed="false"] .floatingTitle,
|
||||
.floating[data-sidebar-collapsed="false"] > span[aria-hidden="true"] { display: none; }
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) { .floating { transition: none; } }
|
||||
|
||||
.directoryList { list-style: none; padding: 0; margin: 0; }
|
||||
.directoryRow { display: flex; align-items: center; min-width: 0; }
|
||||
|
||||
@@ -59,6 +59,34 @@ export async function startOAuth(provider: "google" | "slack") {
|
||||
export const errorText = (error: unknown) => {
|
||||
const code = error instanceof Error ? error.message : "";
|
||||
const messages: Record<string, string> = {
|
||||
invalid_model_stream: "画布返回的数据无法解析,请重新加载。",
|
||||
incomplete_model_stream: "画布数据未加载完整,请重新加载。",
|
||||
unsupported_model_stream: "当前画布加载格式暂不受支持,请更新后重试。",
|
||||
model_stream_budget: "画布数据量超过当前加载上限,请联系支持。",
|
||||
load_snapshot_changed: "加载期间画布快照发生变化,请重新加载。",
|
||||
invalid_model_identity: "画布的元素身份清单不一致,请重新加载。",
|
||||
loading_reference_incomplete: "相关引用尚未完整加载,暂时无法修改。",
|
||||
invalid_model_coverage: "画布的加载范围信息不一致,请重新加载。",
|
||||
loading_waiting_for_receipts: "正在确认已发送的修改,请稍后重试。",
|
||||
invalid_loading_asset: "图片任务的加载范围记录不一致,已保留本地内容并停止同步。",
|
||||
loading_asset_interpreter_unavailable: "此图片任务需要更新版本后才能恢复。",
|
||||
asset_source_changed: "图片资源的地址规则发生变化,请使用原配置恢复后重试。",
|
||||
invalid_model_topology: "画布的分区关系不一致,请重新加载。",
|
||||
loading_scope_incomplete: "所需分区或上级引用尚未完整加载,请稍后重试。",
|
||||
invalid_loading_import: "导入任务的加载范围记录不完整,任务已停止。",
|
||||
loading_copy_scope_changed: "复制源的范围校验失败,请重新复制。",
|
||||
loading_import_interpreter_unavailable: "此导入任务需要更新版本后才能恢复。",
|
||||
model_loading: "画布尚在加载,请等待加载完成后编辑。",
|
||||
identity_changed: "账户已变化,当前画布已停止同步,请重新打开。",
|
||||
engine_closing: "画布正在关闭,本地修改会保留用于恢复。",
|
||||
storage_scope_in_use: "这张图谱已在另一个标签页中打开,请先关闭那里的编辑器。",
|
||||
persistent_storage_unavailable: "浏览器无法保存本地恢复记录,请检查存储权限后重试。",
|
||||
workspace_close_failed: "本地修改尚未保存成功,请释放浏览器存储空间后重试退出。",
|
||||
host_close_failed: "部分本地修改尚未保存成功,请释放浏览器存储空间后重试退出。",
|
||||
resource_bytes_unavailable: "此图片的本地文件暂不可用,相关修改已保留,请重新选择图片。",
|
||||
recovery_checksum_mismatch: "本地恢复记录校验失败,原记录已保留,请先备份再处理。",
|
||||
recovery_journal_gap: "本地恢复记录不完整,原记录已保留,请先备份再处理。",
|
||||
unsupported_component: "这个元素需要更新版本或安装对应功能后才能编辑。",
|
||||
invalid_credentials: "邮箱或密码不正确,或邮箱尚未验证。",
|
||||
rate_limited: "请求太频繁,请稍后再试。",
|
||||
invalid_session: "登录已过期,请重新登录。",
|
||||
@@ -72,6 +100,8 @@ export const errorText = (error: unknown) => {
|
||||
invalid_canvas: "画布数据未通过服务端校验,请导出备份后检查节点内容。",
|
||||
response_interrupted: "画布数据传输中断或超时,请重新加载;当前内容未被覆盖。",
|
||||
invalid_api_response: "接口返回了不完整或无法解析的数据,请重新加载。",
|
||||
asset_target_changed: "目标分区已变化,请重新选择添加位置。",
|
||||
asset_authorization_failed: "暂时无法读取这张图片,请确认仍有原画布的访问权限。",
|
||||
invalid_asset: "图片格式无法识别,请选择有效的 PNG、JPEG、WebP 或 GIF。",
|
||||
asset_too_large: "图片超出上传限制,请选择小于 20 MiB 的文件。",
|
||||
invalid_asset_response: "图片上传结果无效,画布内容未替换,请重试。",
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import type {Registry} from './canvas-core/types.ts';
|
||||
import {fault} from './canvas-core/values.ts';
|
||||
import {createFoundryTransport} from './canvas-service/foundry.ts';
|
||||
import {canvasMigrationStorageKey,createCanvasMigration,migrationBlocksCanvas,readCanvasMigration,type CanvasMigrationScope} from './canvas-migration.ts';
|
||||
|
||||
export type LegacyMigrationContext={accountId:string;rootId?:string;registry:Registry;isCurrentIdentity():boolean;onApplied():void};
|
||||
type BrowserScope={scope:CanvasMigrationScope;isCurrentIdentity():boolean;signal:AbortSignal;locks?:LockManager;storage?:Storage};
|
||||
const lockKey=(scope:CanvasMigrationScope)=>canvasMigrationStorageKey(scope)+':editing';
|
||||
function ports(options:BrowserScope){
|
||||
const locks=options.locks??globalThis.navigator?.locks;
|
||||
if(!locks)throw fault('migration_lock_unavailable');
|
||||
return{locks,storage:options.storage??globalThis.localStorage};
|
||||
}
|
||||
const check=(options:BrowserScope)=>{if(!options.isCurrentIdentity())throw fault('identity_changed');options.signal.throwIfAborted();};
|
||||
|
||||
/** Held for the entire lifetime of an editable legacy view, not just each PATCH. */
|
||||
export async function claimLegacyCanvasEditing(options:BrowserScope&{canvasId:string}){
|
||||
check(options);const{locks,storage}=ports(options);
|
||||
let resolve!:(value:{release():Promise<void>})=>void,reject!:(error:unknown)=>void;
|
||||
const ready=new Promise<{release():Promise<void>}>((yes,no)=>{resolve=yes;reject=no;});
|
||||
const request=locks.request(lockKey(options.scope),{mode:'shared',ifAvailable:true},async lock=>{
|
||||
try{
|
||||
check(options);if(!lock)throw fault('migration_busy');
|
||||
if(migrationBlocksCanvas(readCanvasMigration(storage,options.scope),options.canvasId))throw fault('migration_response_unknown');
|
||||
check(options);
|
||||
let unlock!:()=>void;const released=new Promise<void>(yes=>{unlock=yes;});
|
||||
const release=async()=>{unlock();await request;};
|
||||
const abort=()=>{void release().catch(()=>{});};options.signal.addEventListener('abort',abort,{once:true});
|
||||
resolve({release});
|
||||
try{await released;}finally{options.signal.removeEventListener('abort',abort);}
|
||||
}catch(error){reject(error);}
|
||||
});
|
||||
void request.catch(reject);return ready;
|
||||
}
|
||||
|
||||
export function createBrowserCanvasMigration(options:BrowserScope&{registry:Registry}){
|
||||
check(options);const{locks,storage}=ports(options);
|
||||
return createCanvasMigration({scope:options.scope,registry:options.registry,storage,
|
||||
transport:createFoundryTransport({accountId:options.scope.accountId,isCurrentIdentity:options.isCurrentIdentity}),
|
||||
isCurrentIdentity:options.isCurrentIdentity,signal:options.signal,createId:()=>crypto.randomUUID(),
|
||||
lock:work=>locks.request(lockKey(options.scope),{mode:'exclusive',ifAvailable:true},async lock=>{
|
||||
check(options);if(!lock)throw fault('migration_busy');return work();
|
||||
}),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
type Point={x:number;y:number};
|
||||
|
||||
/** Compatibility renderer data only; never the authoritative element schema. */
|
||||
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 type ElementKind = "text" | "image" | "script" | "plain-text" | "icon" | "frame" | "plain-image";
|
||||
export type FrameLabelMode = "hidden" | "fixed" | "scaled";
|
||||
export type TextStyle = { color?: string; background?: string; bold?: boolean; italic?: boolean; underline?: boolean; align?: "left" | "center" | "right"; fontFamily?: "sans" | "serif" | "mono"; lineHeight?: number };
|
||||
export type RenderedTarget={readonly id:string;readonly incarnation:number;readonly ownerCanvasId:string;readonly canvasId:string};
|
||||
export type CanvasNodeData = {
|
||||
__headlessTarget?:RenderedTarget;
|
||||
kind: ElementKind;
|
||||
title: string;
|
||||
text: string;
|
||||
src?: string;
|
||||
rotation?: number;
|
||||
fontSize?: number;
|
||||
textStyle?: TextStyle;
|
||||
icon?: string;
|
||||
frameId?: string;
|
||||
labelMode?: FrameLabelMode;
|
||||
ai?: AIConfig;
|
||||
assetId?: string;
|
||||
subgraph?: { boardId: string };
|
||||
ports?: FramePort[];
|
||||
presentation?: FramePresentation;
|
||||
viewScale?: number;
|
||||
};
|
||||
|
||||
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: Point; in: Point; out: Point; 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?: Point; endControl?: Point; knots: BezierKnot[] };
|
||||
execution?: { enabled?: boolean };
|
||||
};
|
||||
|
||||
/** Geometry input consumed by the compatibility layout, independent of UI nodes. */
|
||||
export type CanvasGeometryNode={position:Point;width?:number;height?:number;style?:{width?:unknown;height?:unknown};measured?:{width?:number;height?:number};data:{kind:string;rotation?:number;fontSize?:number;viewScale?:number}};
|
||||
@@ -0,0 +1,46 @@
|
||||
import type {Registry} from '../canvas-core/types.ts';
|
||||
import type {projectCanvas} from './projection.ts';
|
||||
import type {HitServices} from './hit-query.ts';
|
||||
import {createPaintIndex,type PaintRegion} from './paint-index.ts';
|
||||
import {equal} from '../canvas-core/values.ts';
|
||||
import {compatConnector} from './connectors.ts';
|
||||
/** Conservative cubic control hull + half stroke, in the displayed scene space.
|
||||
* Missing/custom geometry stays unknown so a host cannot interpret it as invisible. */
|
||||
function connectionPaint(projection:ReturnType<typeof projectCanvas>,registry:Registry,services:HitServices={},maxEdges=4096,maxSegments=4096,cache?:Map<string,{signature:unknown;segments:number;region?:PaintRegion}>){
|
||||
if(![maxEdges,maxSegments].every(n=>Number.isSafeInteger(n)&&n>0&&n<=1000000))throw Error('invalid_paint_budget');
|
||||
const regions:PaintRegion[]=[],unknown:string[]=[],nodes=new Map<string,typeof projection.nodes[number]>(),endpoints=new Set<string>();
|
||||
// Only inspected edges need node entries. Preserve last-ID-wins behavior without
|
||||
// allocating an entry and pair array for every unrelated projected node.
|
||||
for(let i=0;i<Math.min(maxEdges,projection.edges.length);i++){const edge=projection.edges[i];endpoints.add(edge.source);endpoints.add(edge.target);}
|
||||
if(endpoints.size)for(const node of projection.nodes)if(endpoints.has(node.id))nodes.set(node.id,node);
|
||||
let remainingSegments=maxSegments,computed=0;
|
||||
for(let index=0;index<projection.edges.length;index++){
|
||||
if(index>=maxEdges){unknown.push('paint_budget');break;}const edge=projection.edges[index];
|
||||
try{const record=projection.document.elements.get(edge.id),source=nodes.get(edge.source),target=nodes.get(edge.target);if(!record||record.structure!=='connection'||!source||!target)throw Error();
|
||||
const anchor=(node:typeof source,handle:string|null|undefined,end:'source'|'target')=>services.anchor?services.anchor(node,handle,end,edge):node.data.__headlessRecord?undefined:compatConnector(node,handle,end==='source'?'right':'left');
|
||||
const from=anchor(source,edge.sourceHandle,'source'),to=anchor(target,edge.targetHandle,'target'),transform=projection.transforms.get(record.ownerCanvasId);if(!from||!to||!transform||![from.point,from.normal,to.point,to.normal].every(point=>point&&Number.isFinite(point.x)&&Number.isFinite(point.y))||![transform.x,transform.y,transform.scale,transform.rotation].every(Number.isFinite)||transform.scale<=0)throw Error();
|
||||
const protocol=edge.type!=='unsupported'&®istry.resolve(record)==='known'?registry.components.get(record.componentId)?.geometry:undefined;
|
||||
const signature={record,edge,from:{point:{...from.point},normal:{...from.normal}},to:{point:{...to.point},normal:{...to.normal}},transform:{...transform},source:{position:{...source.position},width:source.width,height:source.height},target:{position:{...target.position},width:target.width,height:target.height}},old=cache?.get(edge.id);
|
||||
if(old&&equal(old.signature,signature)){if(old.segments>remainingSegments){unknown.push('paint_budget');break;}remainingSegments-=old.segments;if(old.region)regions.push(old.region);continue;}
|
||||
computed++;
|
||||
const segments=services.edgeGeometry?.(edge,from,to)??(protocol?.kind==='connection'?protocol.segments({element:record,source:from,target:to,displayPath:edge.data?.__headlessRecord?undefined:edge.data?.path??null,transform}):undefined),stroke=services.strokeWidth?.(edge)??(protocol?.kind==='connection'?protocol.strokeWidth({element:record,transform}):undefined);
|
||||
if(!segments||!Array.isArray(segments)||segments.length>maxSegments||typeof stroke!=='number'||!Number.isFinite(stroke)||stroke<0)throw Error();if(!segments.length){cache?.set(edge.id,{signature,segments:0});continue;}if(segments.length>remainingSegments){unknown.push('paint_budget');break;}remainingSegments-=segments.length;
|
||||
let left=Infinity,top=Infinity,right=-Infinity,bottom=-Infinity;
|
||||
for(const segment of segments){if(!Array.isArray(segment)||segment.length!==4)throw Error();for(const point of segment){if(!point||!Number.isFinite(point.x)||!Number.isFinite(point.y))throw Error();left=Math.min(left,point.x);top=Math.min(top,point.y);right=Math.max(right,point.x);bottom=Math.max(bottom,point.y);}}
|
||||
const half=stroke/2,x=left-half,y=top-half,width=right-left+stroke,height=bottom-top+stroke;if(![x,y,width,height,x+width,y+height].every(Number.isFinite))throw Error();
|
||||
const native={left:Math.min(source.position.x,target.position.x),top:Math.min(source.position.y,target.position.y),right:Math.max(source.position.x+source.width!,target.position.x+target.width!),bottom:Math.max(source.position.y+source.height!,target.position.y+target.height!)};
|
||||
if(!Object.values(native).every(Number.isFinite))throw Error();const region=x<native.left||y<native.top||x+width>native.right||y+height>native.bottom?{id:edge.id,bounds:{x,y,width,height}}:undefined;if(region)regions.push(region);cache?.set(edge.id,{signature,segments:segments.length,region});
|
||||
}catch{cache?.delete(edge.id);unknown.push(edge.id);}
|
||||
}
|
||||
return{regions,unknown,computed};
|
||||
}
|
||||
|
||||
export function projectedConnectionPaintOverflow(projection:ReturnType<typeof projectCanvas>,registry:Registry,services:HitServices={},maxEdges=4096,maxSegments=4096){return connectionPaint(projection,registry,services,maxEdges,maxSegments);}
|
||||
/** Host revision invalidates geometry services whose external configuration changes. */
|
||||
export function createConnectionPaintIndex(){
|
||||
const cache=new Map<string,{signature:unknown;segments:number;region?:PaintRegion}>(),index=createPaintIndex();let owner:Registry|undefined,project:string|undefined,canvas:string|undefined,revision:unknown,computed=0;
|
||||
return Object.freeze({sync(projection:ReturnType<typeof projectCanvas>,registry:Registry,services:HitServices={},geometryRevision?:unknown,budget:{maxEdges?:number;maxSegments?:number}={}){
|
||||
if(owner!==registry||project!==projection.document.projectId||canvas!==projection.canvasId||!Object.is(revision,geometryRevision)){cache.clear();index.clear();owner=registry;project=projection.document.projectId;canvas=projection.canvasId;revision=geometryRevision;}
|
||||
const result=connectionPaint(projection,registry,services,budget.maxEdges??4096,budget.maxSegments??4096,cache),seen=new Set(projection.edges.map(edge=>edge.id));for(const id of cache.keys())if(!seen.has(id))cache.delete(id);index.sync(result.regions);computed+=result.computed;return Object.freeze({query:index.query,unknown:Object.freeze(result.unknown)});
|
||||
},stats:()=>Object.freeze({computed,entries:cache.size}),clear(){cache.clear();index.clear();owner=undefined;project=undefined;canvas=undefined;revision=undefined;}});
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import type {CanvasGeometryNode} from './compat-types.ts';
|
||||
import type {ProjectionNode} from './projection.ts';
|
||||
import {nodeConnector,type Connector,type ConnectorSide} from '../canvas-geometry.ts';
|
||||
type CompatNode=CanvasGeometryNode&{data:ProjectionNode['data']};
|
||||
/** Same card-border and port layout used by CanvasEdge. Never infer it for record-v1 DOM. */
|
||||
export function compatConnector(node:CompatNode,handle:string|null|undefined,fallback:ConnectorSide):Connector{
|
||||
const port=Array.isArray(node.data.ports)?node.data.ports.find(port=>port&&`port:${port.id}`===handle):undefined,hints=(node.data as ProjectionNode['data']&{__headlessFallback?:{handles?:{id:string;side:ConnectorSide}[]}}).__headlessFallback?.handles,projected=Array.isArray(hints)?hints.find(hint=>hint?.id===handle&&['top','right','bottom','left'].includes(hint.side))?.side:undefined,side=projected??(['top','right','bottom','left'].includes(handle??'')?handle as ConnectorSide:port?.direction==='input'?'left':port?'right':fallback),result=nodeConnector(node,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 type MeasuredNode={readonly internals:{readonly positionAbsolute:{x:number;y:number};readonly handleBounds?:{readonly source?:readonly MeasuredHandle[]|null;readonly target?:readonly MeasuredHandle[]|null}}};
|
||||
type MeasuredHandle={readonly id?:string|null;readonly position:ConnectorSide;readonly x:number;readonly y:number;readonly width:number;readonly height:number};
|
||||
/** Native Handle outer edge, matching the installed React Flow loose connection mode. */
|
||||
export function measuredConnector(node:MeasuredNode|undefined,handleId:string|null|undefined,end:'source'|'target'):Connector|undefined{
|
||||
const bounds=node?.internals.handleBounds;if(!node||!bounds)return;const handles=end==='source'?bounds.source??[]:[...(bounds.target??[]),...(bounds.source??[])],handle=handleId?handles.find(handle=>handle.id===handleId):handles[0];if(!handle)return;
|
||||
const {x,y}=node.internals.positionAbsolute,side=handle.position,normals={top:{x:0,y:-1},right:{x:1,y:0},bottom:{x:0,y:1},left:{x:-1,y:0}},point={x:x+handle.x+(side==='left'?0:side==='right'?handle.width:handle.width/2),y:y+handle.y+(side==='top'?0:side==='bottom'?handle.height:handle.height/2)};return{point,normal:normals[side]};
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import type {Commit,Id} from '../canvas-core/types.ts';
|
||||
import type {ElementLookup} from '../canvas-core/queries.ts';
|
||||
import type {EngineSnapshot} from '../canvas-runtime/contracts.ts';
|
||||
import {equal} from '../canvas-core/values.ts';
|
||||
import type {ExternalSource} from './selectors.ts';
|
||||
|
||||
export type ElementSourcePorts={
|
||||
getSnapshot():EngineSnapshot;
|
||||
subscribe(listener:(commit:Commit,snapshot:EngineSnapshot)=>void):()=>void;
|
||||
lookup(snapshot:EngineSnapshot,id:Id):ElementLookup;
|
||||
};
|
||||
/** One upstream subscription, ID buckets and cached lookup values, never another document.
|
||||
* Explicit changes use ID routing; replacement/coverage cuts retain a checked fallback. */
|
||||
export function createElementSources(ports:ElementSourcePorts){
|
||||
type Bucket={value:ElementLookup;listeners:Set<()=>void>;failed?:boolean};
|
||||
const buckets=new Map<Id,Bucket>();let stop:(()=>void)|undefined,previous:EngineSnapshot|undefined;
|
||||
function changed(commit:Commit,next:EngineSnapshot){
|
||||
// The editor snapshots its listeners; this callback can already be unsubscribed.
|
||||
if(!previous||!buckets.size)return;
|
||||
const before=previous!;previous=next;
|
||||
const ids=new Set<Id>();
|
||||
if(before.document!==next.document){
|
||||
if(commit.changes.length){for(const change of commit.changes)if(change.kind==='field')ids.add(change.target.id);else if(change.kind==='element')ids.add(change.id);}
|
||||
else for(const id of buckets.keys())ids.add(id);
|
||||
}
|
||||
const coverageChanged=before.runtime.loading!==next.runtime.loading||!!next.runtime.loading&&(before.runtime.loadingRead?.document??before.runtime.acknowledged)!==(next.runtime.loadingRead?.document??next.runtime.acknowledged);
|
||||
if(coverageChanged)for(const[id,bucket]of buckets)if(bucket.value.status!=='present')ids.add(id);
|
||||
const notifications=new Set<()=>void>();
|
||||
for(const id of ids){
|
||||
const bucket=buckets.get(id);if(!bucket)continue;
|
||||
try{const value=ports.lookup(next,id);if(!bucket.failed&&equal(bucket.value,value))continue;bucket.value=value;bucket.failed=false;}catch{bucket.failed=true;/* Snapshot readers retry/raise through their own boundary. */}
|
||||
for(const notify of bucket.listeners)notifications.add(notify);
|
||||
}
|
||||
// All bucket values advance before observers run; a failing observer cannot starve others.
|
||||
let failed=false,error:unknown;
|
||||
for(const notify of notifications)try{notify();}catch(caught){if(!failed){failed=true;error=caught;}}
|
||||
if(failed)throw error;
|
||||
}
|
||||
return Object.freeze({forElement(id:Id):ExternalSource<ElementLookup>{
|
||||
let value:ElementLookup|undefined;
|
||||
return Object.freeze({
|
||||
getSnapshot(){const next=ports.lookup(ports.getSnapshot(),id);if(!value||!equal(value,next))value=next;return value;},
|
||||
subscribe(notify:()=>void){
|
||||
let bucket=buckets.get(id);if(!bucket){bucket={value:ports.lookup(ports.getSnapshot(),id),listeners:new Set()};buckets.set(id,bucket);}
|
||||
// Distinct subscriptions may intentionally share the same callback identity.
|
||||
const listener=()=>notify();bucket.listeners.add(listener);
|
||||
if(!stop){previous=ports.getSnapshot();try{stop=ports.subscribe(changed);}catch(error){bucket.listeners.delete(listener);if(!bucket.listeners.size)buckets.delete(id);if(!buckets.size)previous=undefined;throw error;}}
|
||||
let active=true;return()=>{if(!active)return;active=false;bucket!.listeners.delete(listener);if(!bucket!.listeners.size)buckets.delete(id);if(!buckets.size){stop?.();stop=undefined;previous=undefined;}};
|
||||
},
|
||||
});
|
||||
}});
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import type {ExternalSource} from './selectors.ts';
|
||||
/** Presentation invalidations only. Commands, history and network work never wait here. */
|
||||
export type FrameClock={request(callback:()=>void):unknown;cancel(handle:unknown):void};
|
||||
export function createFrameQueue<K>(clock:FrameClock,publish:(dirty:ReadonlySet<K>)=>void){
|
||||
let dirty=new Set<K>(),scheduled:{handle?:unknown}|undefined,disposed=false;
|
||||
function flush(){if(disposed)return;const pending=scheduled;scheduled=undefined;if(pending)clock.cancel(pending.handle);if(!dirty.size)return;const batch=dirty;dirty=new Set();publish(batch);}
|
||||
return Object.freeze({
|
||||
invalidate(keys:Iterable<K>){if(disposed)return;for(const key of keys)dirty.add(key);if(!dirty.size||scheduled)return;const pending: {handle?:unknown}={};scheduled=pending;try{pending.handle=clock.request(()=>{if(scheduled!==pending)return;scheduled=undefined;flush();});}catch(error){scheduled=undefined;throw error;}},
|
||||
flush,
|
||||
dispose(){if(disposed)return;disposed=true;if(scheduled)clock.cancel(scheduled.handle);scheduled=undefined;dirty.clear();},
|
||||
});
|
||||
}
|
||||
|
||||
/** Coalesce presentation notifications; immediate invalidations flush pending work synchronously. */
|
||||
export function createFrameSource<S>(source:{getSnapshot():S;subscribe(notify:(immediate?:boolean)=>void):()=>void},clock:FrameClock):ExternalSource<S>{
|
||||
return Object.freeze({getSnapshot:()=>source.getSnapshot(),subscribe(notify:()=>void){
|
||||
const queue=createFrameQueue<number>(clock,()=>notify());
|
||||
let stop:()=>void;
|
||||
try{stop=source.subscribe(immediate=>{queue.invalidate([0]);if(immediate)queue.flush();});}catch(error){queue.dispose();throw error;}
|
||||
return()=>{try{stop();}finally{queue.dispose();}};
|
||||
}});
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import type {Geometry,Id,Json} from '../canvas-core/types.ts';
|
||||
import {childCanvasTransform,toWorld,transformPath,type Transform} from '../canvas-core/coordinates.ts';
|
||||
import {equal,clone,freeze} from '../canvas-core/values.ts';
|
||||
const sameTransform=(a:Transform,b:Transform)=>a.x===b.x&&a.y===b.y&&a.scale===b.scale&&a.rotation===b.rotation;
|
||||
const sameGeometry=(a:Geometry,b:Geometry)=>a.x===b.x&&a.y===b.y&&a.width===b.width&&a.height===b.height&&a.rotation===b.rotation;
|
||||
const units=(value:Json|undefined)=>value&&typeof value==='object'&&!Array.isArray(value)?[value.contentScale,value.unitScale]:[];
|
||||
/** View-local numeric/path projection only. No editable records, history, visibility or task facts. */
|
||||
export function createGeometryProjectionCache(){
|
||||
let owner:object|undefined,project:Id|undefined,canvas:Id|undefined,nodesComputed=0,childrenComputed=0,edgesComputed=0;
|
||||
type NodeResult={position:{x:number;y:number};width:number;height:number;rotation:number};
|
||||
const nodes=new Map<Id,{incarnation:number;geometry:Geometry;transform:Transform;value:NodeResult}>(),children=new Map<Id,{incarnation:number;geometry:Geometry;transform:Transform;units:unknown;value:Transform}>(),edges=new Map<Id,{incarnation:number;path:Json;transform:Transform;value:Json}>();let seenNodes=new Set<Id>(),seenChildren=new Set<Id>(),seenEdges=new Set<Id>();
|
||||
return Object.freeze({
|
||||
begin(scope:object,projectId:Id,canvasId:Id){if(owner!==scope||project!==projectId||canvas!==canvasId){nodes.clear();children.clear();edges.clear();owner=scope;project=projectId;canvas=canvasId;}seenNodes=new Set();seenChildren=new Set();seenEdges=new Set();},
|
||||
node(id:Id,incarnation:number,g:Geometry,t:Transform):NodeResult{seenNodes.add(id);const before=nodes.get(id);if(before&&before.incarnation===incarnation&&sameGeometry(before.geometry,g)&&sameTransform(before.transform,t))return before.value;const center=toWorld(t,{x:g.x+g.width/2,y:g.y+g.height/2}),width=g.width*t.scale,height=g.height*t.scale,value=freeze({position:{x:center.x-width/2,y:center.y-height/2},width,height,rotation:(g.rotation+t.rotation)%360});nodes.set(id,{incarnation,geometry:{...g},transform:{...t},value});nodesComputed++;return value;},
|
||||
child(id:Id,incarnation:number,g:Geometry,t:Transform,presentation:Json|undefined):Transform{seenChildren.add(id);const before=children.get(id),currentUnits=units(presentation);if(before&&before.incarnation===incarnation&&sameGeometry(before.geometry,g)&&sameTransform(before.transform,t)&&equal(before.units,currentUnits))return before.value;const value=freeze(childCanvasTransform(t,g,presentation));children.set(id,{incarnation,geometry:{...g},transform:{...t},units:currentUnits,value});childrenComputed++;return value;},
|
||||
edge(id:Id,incarnation:number,path:Json,t:Transform):Json{seenEdges.add(id);const before=edges.get(id);if(before&&before.incarnation===incarnation&&equal(before.path,path)&&sameTransform(before.transform,t))return before.value;const captured=freeze(clone(path)),value=freeze(transformPath(captured,t));edges.set(id,{incarnation,path:captured,transform:{...t},value});edgesComputed++;return value;},
|
||||
end(){for(const id of nodes.keys())if(!seenNodes.has(id))nodes.delete(id);for(const id of children.keys())if(!seenChildren.has(id))children.delete(id);for(const id of edges.keys())if(!seenEdges.has(id))edges.delete(id);},
|
||||
stats:()=>Object.freeze({nodesComputed,childrenComputed,edgesComputed,nodes:nodes.size,children:children.size,edges:edges.size}),
|
||||
});
|
||||
}
|
||||
export type GeometryProjectionCache=ReturnType<typeof createGeometryProjectionCache>;
|
||||
@@ -0,0 +1,41 @@
|
||||
import type {DocumentQueries} from '../canvas-core/queries.ts';
|
||||
import type {ElementRecord,Geometry,Id,Point,Registry,Target} from '../canvas-core/types.ts';
|
||||
import type {Connector,Cubic} from '../canvas-core/geometry.ts';
|
||||
import {rectangleGeometry} from '../canvas-core/geometry.ts';
|
||||
import {hitCubic} from '../canvas-core/curve-hit.ts';
|
||||
import {toLocal,type Transform} from '../canvas-core/coordinates.ts';
|
||||
import {fault,freeze} from '../canvas-core/values.ts';
|
||||
import {compatConnector} from './connectors.ts';
|
||||
import type {projectCanvas,ProjectionNode,ProjectionEdge} from './projection.ts';
|
||||
type Projection=ReturnType<typeof projectCanvas>;
|
||||
export type SceneHit={readonly target:Target;readonly kind:'node'|'connection';readonly ownerCanvasId:Id;readonly displayPath:{readonly viewSessionId:Id;readonly canvasIds:readonly Id[];readonly entryIds:readonly Id[]};readonly layer:number;readonly certainty:'confirmed'|'possible';readonly distanceScene?:number;readonly errorScene?:number;readonly displayedEndpoints?:{readonly source:Id;readonly target:Id}};
|
||||
export type HitServices={readonly anchor?:(node:ProjectionNode,handle:string|null|undefined,end:'source'|'target',edge:ProjectionEdge)=>Connector|undefined;readonly edgeGeometry?:(edge:ProjectionEdge,source:Connector,target:Connector)=>readonly Cubic[]|undefined;readonly strokeWidth?:(edge:ProjectionEdge)=>number|undefined;readonly layer?:(id:Id,kind:'node'|'connection')=>number|undefined;readonly previewIds?:ReadonlySet<Id>};
|
||||
export type HitBudget={readonly maxCandidates?:number;readonly maxCurveSteps?:number;readonly maxCurveDepth?:number;readonly maxSegments?:number;readonly errorPx?:number};
|
||||
export type HitPoint=({readonly space:'scene';readonly point:Point;readonly zoom:number}|{readonly space:'screen';readonly point:Point;readonly viewport:{readonly x:number;readonly y:number;readonly zoom:number;readonly origin:Point}})&{readonly tolerancePx?:number};
|
||||
const finitePoint=(point:Point)=>!!point&&Number.isFinite(point.x)&&Number.isFinite(point.y);
|
||||
function geometry(node:ProjectionNode,t:Transform):Geometry{const width=node.width!/t.scale,height=node.height!/t.scale,center=toLocal(t,{x:node.position.x+node.width!/2,y:node.position.y+node.height!/2});return{x:center.x-width/2,y:center.y-height/2,width,height,rotation:((node.data.rotation??0)-t.rotation+360)%360};}
|
||||
/** Query the captured display frame. It neither edits nor replaces the native input router. */
|
||||
export function hitProjectedCanvas(projection:Projection,queries:DocumentQueries,registry:Registry,viewSessionId:Id,query:HitPoint,services:HitServices={},budget:HitBudget={}){
|
||||
if(projection.document!==queries.document||!viewSessionId)throw fault('stale_query_snapshot');if(queries.registry!==registry)throw fault('query_registry_changed');const zoom=query.space==='scene'?query.zoom:query.viewport.zoom,tolerancePx=query.tolerancePx??6,errorPx=budget.errorPx??.25,maxCandidates=budget.maxCandidates??4096,maxSteps=budget.maxCurveSteps??8192,maxDepth=budget.maxCurveDepth??20,maxSegments=budget.maxSegments??4096;
|
||||
if(!finitePoint(query.point)||!Number.isFinite(zoom)||zoom<=0||!Number.isFinite(tolerancePx)||tolerancePx<0||!Number.isFinite(errorPx)||errorPx<=0||![maxCandidates,maxSteps,maxDepth,maxSegments].every(n=>Number.isSafeInteger(n)&&n>=1&&n<=1000000)||maxDepth>40)throw fault('invalid_hit_query');
|
||||
if(query.space==='screen'&&(!finitePoint(query.viewport.origin)||!Number.isFinite(query.viewport.x)||!Number.isFinite(query.viewport.y)))throw fault('invalid_hit_query');
|
||||
const point=query.space==='scene'?query.point:{x:(query.point.x-query.viewport.origin.x-query.viewport.x)/zoom,y:(query.point.y-query.viewport.origin.y-query.viewport.y)/zoom},tolerance=tolerancePx/zoom,document=projection.document,nodes=new Map(projection.nodes.map(node=>[node.id,node])),rank=new Map(queries.orderedElements([...nodes.keys(),...projection.edges.map(edge=>edge.id)]).map((id,index)=>[id,index])),candidates=new Set<Id>(services.previewIds??[]),hits:SceneHit[]=[],diagnostics:{target?:Id;code:string}[]=[];let work=0,remaining=maxSteps;
|
||||
if(!finitePoint(point)||!Number.isFinite(tolerance)||!Number.isFinite(errorPx/zoom))throw fault('invalid_hit_query');
|
||||
for(const[id,t]of projection.transforms){if(!Number.isFinite(t.scale)||t.scale<=0)throw fault('invalid_hit_transform');const p=toLocal(t,point),r=tolerance/t.scale;for(const candidate of queries.candidateNodes(id,{x:p.x-r,y:p.y-r,width:2*r,height:2*r}))candidates.add(candidate);}
|
||||
function path(owner:Id){const canvases:Id[]=[],entries:Id[]=[],seen=new Set<Id>();let id=owner;while(id!==projection.canvasId){if(seen.has(id))throw fault('canvas_cycle');seen.add(id);const parent=document.canvases.get(id)?.parent;if(!parent)throw fault('invalid_display_path');canvases.push(id);entries.push(parent.entryElementId);id=parent.canvasId;}return{viewSessionId,canvasIds:[projection.canvasId,...canvases.reverse()],entryIds:entries.reverse()};}
|
||||
const layer=(id:Id,kind:'node'|'connection',fallback:number)=>{const value=services.layer?.(id,kind)??fallback;if(!Number.isFinite(value))throw fault('invalid_hit_layer');return value;};
|
||||
for(const id of candidates){const node=nodes.get(id);if(!node)continue;if(++work>maxCandidates){diagnostics.push({code:'geometry_query_budget'});break;}try{const record=document.elements.get(id);if(!record||record.structure!=='node')throw fault('invalid_hit_projection');const t=projection.transforms.get(record.ownerCanvasId);if(!t)throw fault('invalid_hit_transform');const g=geometry(node,t),resolved=!node.data.__headlessFallback&®istry.resolve(record)==='known'?registry.components.get(record.componentId)?.geometry:undefined,protocol=resolved?.kind==='node'?resolved:rectangleGeometry,p=toLocal(t,point),localTolerance=tolerance/t.scale;
|
||||
const bounds=protocol.bounds({element:record,geometry:g,role:'hit'});if(![g.x,g.y,g.width,g.height,g.rotation,bounds.x,bounds.y,bounds.width,bounds.height].every(Number.isFinite)||g.width<0||g.height<0||bounds.width<0||bounds.height<0)throw fault('invalid_geometry_bounds');
|
||||
const result=protocol.hit({element:record,geometry:g,point:p,tolerance:localTolerance});if(typeof result!=='boolean')throw fault('invalid_geometry_response');if(result)hits.push({target:{id,incarnation:record.incarnation},kind:'node',ownerCanvasId:record.ownerCanvasId,displayPath:path(record.ownerCanvasId),layer:layer(id,'node',node.zIndex??0),certainty:'confirmed'});
|
||||
}catch(error){diagnostics.push({target:id,code:(error as{code?:string}).code??'geometry_handler_failed'});}}
|
||||
const anchor=(node:ProjectionNode,handle:string|null|undefined,end:'source'|'target',edge:ProjectionEdge)=>services.anchor?services.anchor(node,handle,end,edge):node.data.__headlessRecord?undefined:compatConnector(node,handle,end==='source'?'right':'left');
|
||||
for(const edge of projection.edges){if(++work>maxCandidates){diagnostics.push({code:'geometry_query_budget'});break;}try{const record=document.elements.get(edge.id);if(!record||record.structure!=='connection')throw fault('invalid_hit_projection');const source=nodes.get(edge.source),target=nodes.get(edge.target);if(!source||!target)throw fault('geometry_anchor_unavailable');const from=anchor(source,edge.sourceHandle,'source',edge),to=anchor(target,edge.targetHandle,'target',edge);if(!from||!to||![from.point,from.normal,to.point,to.normal].every(finitePoint))throw fault('geometry_anchor_unavailable');
|
||||
const transform=projection.transforms.get(record.ownerCanvasId);if(!transform)throw fault('invalid_hit_transform');const protocol=edge.type!=='unsupported'&®istry.resolve(record)==='known'?registry.components.get(record.componentId)?.geometry:undefined,context={element:record,source:from,target:to,displayPath:edge.data?.__headlessRecord?undefined:edge.data?.path??null,transform};
|
||||
const segments=services.edgeGeometry?.(edge,from,to)??(protocol?.kind==='connection'?protocol.segments(context):undefined);if(!segments)throw fault('geometry_unavailable');if(!Array.isArray(segments)||segments.length>maxSegments||segments.some(segment=>!Array.isArray(segment)||segment.length!==4||!segment.every(finitePoint)))throw fault('invalid_geometry_response');
|
||||
const width=services.strokeWidth?.(edge)??(protocol?.kind==='connection'?protocol.strokeWidth({element:record,transform}):undefined);if(typeof width!=='number'||!Number.isFinite(width)||width<0)throw fault('invalid_geometry_paint');let possible:ReturnType<typeof hitCubic>|undefined,confirmed:ReturnType<typeof hitCubic>|undefined;
|
||||
for(const segment of segments){if(remaining<=0){possible??={hit:true,certainty:'possible',steps:0,distance:Infinity,error:Infinity,budget:true};break;}const result=hitCubic(segment,point,tolerance+width/2,{error:errorPx/zoom,maxDepth,maxSteps:remaining});remaining-=result.steps;if(result.hit){if(result.certainty==='confirmed'){confirmed=result;break;}possible=result;}}
|
||||
const result=confirmed??possible;if(result?.hit){if(!confirmed)diagnostics.push({target:edge.id,code:result.budget?'geometry_query_budget':'geometry_precision'});hits.push({target:{id:edge.id,incarnation:record.incarnation},kind:'connection',ownerCanvasId:record.ownerCanvasId,displayPath:path(record.ownerCanvasId),displayedEndpoints:{source:edge.source,target:edge.target},layer:layer(edge.id,'connection',edge.zIndex??0),certainty:confirmed?'confirmed':'possible',...(Number.isFinite(result.distance)?{distanceScene:result.distance}:{}),...(Number.isFinite(result.error)?{errorScene:result.error}:{})});}
|
||||
}catch(error){diagnostics.push({target:edge.id,code:(error as{code?:string}).code??'geometry_handler_failed'});}}
|
||||
if([...projection.transforms.keys()].some(id=>!queries.coverage.completeCanvasIds.includes(id)))diagnostics.push({code:'model_scope_incomplete'});
|
||||
hits.sort((a,b)=>b.layer-a.layer||(a.kind===b.kind?0:a.kind==='node'?-1:1)||(rank.get(b.target.id)??0)-(rank.get(a.target.id)??0));return freeze({projectId:document.projectId,revision:queries.revision,viewSessionId,pointScene:{...point},toleranceScene:tolerance,hits,complete:diagnostics.length===0,diagnostics});
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
/** Portable scene projection and hit queries: no React or React Flow imports. */
|
||||
export * from './projection.ts';
|
||||
export * from './geometry-cache.ts';
|
||||
export * from './projection-cache.ts';
|
||||
export * from './hit-query.ts';
|
||||
export * from './renderers.ts';
|
||||
export * from './connectors.ts';
|
||||
export * from './resource-view.ts';
|
||||
export type * from './compat-types.ts';
|
||||
|
||||
export {createSelectorStore} from './selectors.ts';
|
||||
export type {ExternalSource} from './selectors.ts';
|
||||
export {routeCanvasKey,canvasKeyContext} from './input-router.ts';
|
||||
export type {CanvasKeyInput,InputClaim,KeyHandler} from './input-router.ts';
|
||||
export {createFrameQueue,createFrameSource} from './frame-queue.ts';
|
||||
export type {FrameClock} from './frame-queue.ts';
|
||||
export {createSessionFrameSource} from './session-frames.ts';
|
||||
export {createPaintIndex} from './paint-index.ts';
|
||||
export type {PaintRegion} from './paint-index.ts';
|
||||
export {projectedPaintOverflow,createProjectedPaintIndex} from './projected-paint.ts';
|
||||
export {projectedConnectionPaintOverflow,createConnectionPaintIndex} from './connection-paint.ts';
|
||||
export {createOverlayIndex} from './overlays.ts';
|
||||
export type {OverlayEntry,OverlayIndex} from './overlays.ts';
|
||||
export {ownsPointerCapture,acquirePointerCapture,releasePointerCapture} from './pointer-capture.ts';
|
||||
export type {PointerCapturePort} from './pointer-capture.ts';
|
||||
@@ -0,0 +1,25 @@
|
||||
/** Host normalizes DOM/native input; only the winning layer consumes an event. */
|
||||
export type CanvasKeyInput={readonly key:string;readonly composing?:boolean;readonly textEditing?:boolean;readonly overlay?:boolean;readonly defaultPrevented?:boolean;readonly ctrl?:boolean;readonly meta?:boolean;readonly alt?:boolean;readonly shift?:boolean};
|
||||
export type InputClaim='text'|'overlay'|'gesture'|'tool'|'shortcut'|'unclaimed';
|
||||
export type KeyHandler=(input:CanvasKeyInput)=>boolean;
|
||||
const textTarget = 'input,textarea,select,[contenteditable]:not([contenteditable="false"]),[role="textbox"]';
|
||||
const overlayTarget = '[data-canvas-overlay-view],[role="dialog"],[role="menu"],[role="listbox"],[role="combobox"]';
|
||||
/** Inspect the composed path as well as the retargeted host (portals/shadow DOM).
|
||||
* This uses a structural DOM port so pure consumers need no browser globals. */
|
||||
export function canvasKeyContext(event:{readonly target?:unknown;composedPath?:()=>readonly unknown[]}):Pick<CanvasKeyInput,'textEditing'|'overlay'>{
|
||||
let textEditing=false,overlay=false;
|
||||
for(const target of [event.target,...(event.composedPath?.()??[])]){
|
||||
if(!target||typeof (target as {closest?:unknown}).closest!=='function')continue;
|
||||
const element=target as {closest(selector:string):unknown};
|
||||
textEditing ||= !!element.closest(textTarget);
|
||||
overlay ||= !!element.closest(overlayTarget);
|
||||
}
|
||||
return {textEditing,overlay};
|
||||
}
|
||||
export function routeCanvasKey(input:CanvasKeyInput,handlers:{gesture?:KeyHandler;tool?:KeyHandler;shortcut?:KeyHandler}):InputClaim{
|
||||
if(input.defaultPrevented)return 'unclaimed';
|
||||
if(input.composing||input.textEditing)return 'text';
|
||||
if(input.overlay)return 'overlay';
|
||||
for(const layer of ['gesture','tool','shortcut'] as const)if(handlers[layer]?.(input))return layer;
|
||||
return 'unclaimed';
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
/** One renderer store's transient measurement requests. Not model or history state. */
|
||||
export function createNodeInternalsBatch(update:(ids:string[])=>void,defer:(flush:()=>void)=>void=queueMicrotask){
|
||||
const owners=new Map<symbol,string>(),dirty=new Set<symbol>();let queued=false;
|
||||
function flush(){
|
||||
queued=false;const ids=new Set<string>();for(const owner of dirty){const id=owners.get(owner);if(id!==undefined)ids.add(id);}dirty.clear();
|
||||
if(ids.size)update([...ids]);
|
||||
}
|
||||
return Object.freeze({
|
||||
acquire(id:string){
|
||||
const owner=Symbol(id);owners.set(owner,id);
|
||||
return Object.freeze({
|
||||
invalidate(){if(!owners.has(owner))return;dirty.add(owner);if(!queued){queued=true;defer(flush);}},
|
||||
dispose(){owners.delete(owner);dirty.delete(owner);},
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import type {Target} from '../canvas-core/types.ts';
|
||||
import type {Rect} from '../canvas-core/geometry.ts';
|
||||
import {createPaintIndex} from './paint-index.ts';
|
||||
import {fault} from '../canvas-core/values.ts';
|
||||
export type OverlayEntry={readonly id:string;readonly viewId:string;readonly placement:string;readonly target:Target;readonly bounds:Rect;readonly focused:boolean;readonly composing:boolean};
|
||||
/** Ephemeral screen-space placements. A lease prevents late cleanup from removing a replacement. */
|
||||
export function createOverlayIndex(viewId:string){
|
||||
if(typeof viewId!=='string'||!viewId)throw fault('view_session_required');let serial=0,snapshot:readonly OverlayEntry[]=Object.freeze([]);const entries=new Map<string,{token:number;entry:OverlayEntry}>(),listeners=new Set<()=>void>(),index=createPaintIndex();
|
||||
function publish(){snapshot=Object.freeze([...entries.values()].map(row=>row.entry));index.sync(snapshot.map(entry=>({id:entry.id,bounds:entry.bounds})));for(const listener of [...listeners])listener();}
|
||||
function capture(id:string,value:Omit<OverlayEntry,'id'|'viewId'>):OverlayEntry{if(typeof id!=='string'||!id||typeof value.placement!=='string'||!value.placement||typeof value.target.id!=='string'||!value.target.id||!Number.isSafeInteger(value.target.incarnation)||value.target.incarnation<1)throw fault('invalid_overlay');const entry=Object.freeze({...value,id,viewId,target:Object.freeze({...value.target}),bounds:Object.freeze({...value.bounds})});const check=createPaintIndex();check.sync([{id,bounds:entry.bounds}]);return entry;}
|
||||
return Object.freeze({viewId,getSnapshot:()=>snapshot,subscribe(listener:()=>void){listeners.add(listener);return()=>{listeners.delete(listener);};},
|
||||
register(id:string,value:Omit<OverlayEntry,'id'|'viewId'>){const token=++serial,entry=capture(id,value);entries.set(id,{token,entry});publish();return Object.freeze({update(value:Omit<OverlayEntry,'id'|'viewId'>){if(entries.get(id)?.token!==token)return;const entry=capture(id,value),old=entries.get(id)!.entry;if(JSON.stringify(old)===JSON.stringify(entry))return;entries.set(id,{token,entry});publish();},release(){if(entries.get(id)?.token!==token)return;entries.delete(id);publish();}});},
|
||||
visible(bounds:Rect,current:(target:Target)=>boolean){const ids=index.query(bounds);return snapshot.filter(entry=>current(entry.target)&&(entry.focused||entry.composing||ids.has(entry.id)));},
|
||||
clear(){entries.clear();publish();},
|
||||
});
|
||||
}
|
||||
export type OverlayIndex=ReturnType<typeof createOverlayIndex>;
|
||||
@@ -0,0 +1,18 @@
|
||||
import type {Id} from '../canvas-core/types.ts';
|
||||
import type {Rect} from '../canvas-core/geometry.ts';
|
||||
import {fault} from '../canvas-core/values.ts';
|
||||
export type PaintRegion={readonly id:Id;readonly bounds:Rect};
|
||||
const valid=(r:Rect)=>r&&[r.x,r.y,r.width,r.height,r.x+r.width,r.y+r.height].every(Number.isFinite)&&r.width>=0&&r.height>=0;
|
||||
const intersects=(a:Rect,b:Rect)=>a.x<=b.x+b.width&&b.x<=a.x+a.width&&a.y<=b.y+b.height&&b.y<=a.y+a.height;
|
||||
/** Rebuildable view-space grid. Oversized regions/queries fall back without false negatives. */
|
||||
export function createPaintIndex(cellSize=512,maxCells=256){
|
||||
if(!Number.isFinite(cellSize)||cellSize<=0||!Number.isSafeInteger(maxCells)||maxCells<1)throw fault('invalid_paint_index');
|
||||
const rows=new Map<Id,Rect>(),cells=new Map<string,Set<Id>>(),members=new Map<Id,readonly string[]>(),overflow=new Set<Id>();
|
||||
function keys(r:Rect){const x=Math.floor(r.x/cellSize),y=Math.floor(r.y/cellSize),right=Math.floor((r.x+r.width)/cellSize),bottom=Math.floor((r.y+r.height)/cellSize);if(![x,y,right,bottom].every(Number.isSafeInteger)||(right-x+1)*(bottom-y+1)>maxCells)return;const result:string[]=[];for(let a=x;a<=right;a++)for(let b=y;b<=bottom;b++)result.push(`${a},${b}`);return result;}
|
||||
function remove(id:Id){for(const key of members.get(id)??[]){const bucket=cells.get(key)!;bucket.delete(id);if(!bucket.size)cells.delete(key);}members.delete(id);overflow.delete(id);rows.delete(id);}
|
||||
return Object.freeze({
|
||||
sync(regions:readonly PaintRegion[]){const incoming=new Map<Id,Rect>();for(const region of regions){if(!region.id||incoming.has(region.id)||!valid(region.bounds))throw fault('invalid_paint_region');incoming.set(region.id,{...region.bounds});}for(const id of rows.keys())if(!incoming.has(id))remove(id);for(const [id,r]of incoming){const old=rows.get(id);if(old&&old.x===r.x&&old.y===r.y&&old.width===r.width&&old.height===r.height)continue;remove(id);rows.set(id,r);const list=keys(r);if(!list){overflow.add(id);continue;}members.set(id,list);for(const key of list){let bucket=cells.get(key);if(!bucket)cells.set(key,bucket=new Set());bucket.add(id);}}},
|
||||
query(region:Rect):ReadonlySet<Id>{if(!valid(region))throw fault('invalid_paint_region');const list=keys(region),candidates=list?new Set([...overflow,...list.flatMap(key=>[...cells.get(key)??[]])]):new Set(rows.keys());return new Set([...candidates].filter(id=>intersects(rows.get(id)!,region)));},
|
||||
clear(){rows.clear();cells.clear();members.clear();overflow.clear();},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
/** Transport ownership only: document geometry and gesture tokens stay in the editor. */
|
||||
export type PointerCapturePort={setPointerCapture(id:number):void;hasPointerCapture(id:number):boolean;releasePointerCapture(id:number):void};
|
||||
export function ownsPointerCapture(owner:{pointerId:number;capture:unknown}|null|undefined,input:{pointerId:number;type?:string;target?:unknown}){if(!owner||owner.pointerId!==input.pointerId)return false;if(input.type!=='lostpointercapture')return true;if(input.target!==owner.capture)return false;const capture=owner.capture as Partial<PointerCapturePort>|undefined;try{if(capture?.hasPointerCapture?.(owner.pointerId))return false;}catch{/* A detached matching target may still need cancellation. */}return true;}
|
||||
export function acquirePointerCapture(target:PointerCapturePort,id:number){try{target.setPointerCapture(id);return true;}catch{return false;}}
|
||||
export function releasePointerCapture(target:PointerCapturePort,id:number){try{if(target.hasPointerCapture(id))target.releasePointerCapture(id);}catch{/* Detached DOM or an already released platform capture has no editor authority. */}}
|
||||
@@ -0,0 +1,60 @@
|
||||
import {clone,equal} from '../canvas-core/values.ts';
|
||||
import {rectangleGeometry} from '../canvas-core/geometry.ts';
|
||||
import {toLocal,toWorld,type Transform} from '../canvas-core/coordinates.ts';
|
||||
import type {ElementRecord,Geometry,GeometryProtocol,Registry} from '../canvas-core/types.ts';
|
||||
import type {projectCanvas,ProjectionNode} from './projection.ts';
|
||||
import {createPaintIndex,type PaintRegion} from './paint-index.ts';
|
||||
/** Extra node paint extents beyond native unrotated React Flow bounds. */
|
||||
export function projectedPaintOverflow(projection:ReturnType<typeof projectCanvas>,registry:Registry){
|
||||
const regions:PaintRegion[]=[],unknown:string[]=[];
|
||||
for(const node of projection.nodes)try{
|
||||
const element=projection.document.elements.get(node.id),transform=element&&projection.transforms.get(element.ownerCanvasId);if(!element||element.structure!=='node'||!transform)throw Error('missing_geometry');
|
||||
const width=node.width!/transform.scale,height=node.height!/transform.scale,center=toLocal(transform,{x:node.position.x+node.width!/2,y:node.position.y+node.height!/2}),geometry={x:center.x-width/2,y:center.y-height/2,width,height,rotation:((node.data.rotation??0)-transform.rotation+360)%360};
|
||||
const definition=!node.data.__headlessFallback&®istry.resolve(element)==='known'?registry.components.get(element.componentId)?.geometry:undefined,protocol=definition?.kind==='node'?definition:rectangleGeometry;
|
||||
const local=protocol.bounds({element,geometry,role:'paint'});if(![local.x,local.y,local.width,local.height].every(Number.isFinite)||local.width<0||local.height<0)throw Error('invalid_paint_bounds');
|
||||
const points=[{x:local.x,y:local.y},{x:local.x+local.width,y:local.y},{x:local.x,y:local.y+local.height},{x:local.x+local.width,y:local.y+local.height}].map(p=>toWorld(transform,p)),x=Math.min(...points.map(p=>p.x)),y=Math.min(...points.map(p=>p.y)),right=Math.max(...points.map(p=>p.x)),bottom=Math.max(...points.map(p=>p.y));
|
||||
if(![x,y,right,bottom,right-x,bottom-y].every(Number.isFinite))throw Error('invalid_paint_bounds');
|
||||
if(x<node.position.x||y<node.position.y||right>node.position.x+node.width!||bottom>node.position.y+node.height!)regions.push({id:node.id,bounds:{x,y,width:right-x,height:bottom-y}});
|
||||
}catch{unknown.push(node.id);}
|
||||
return{regions,unknown};
|
||||
}
|
||||
|
||||
type NodeProtocol=Extract<GeometryProtocol,{kind:'node'}>;
|
||||
type PaintSignature={incarnation?:number;owner?:string;component?:string;version?:number;geometry?:Geometry;transform?:Transform;x:number;y:number;width?:number;height?:number;rotation:unknown;protocol:NodeProtocol;data:unknown};
|
||||
const sameGeometry=(a:Geometry|undefined,b:Geometry|undefined)=>a===undefined?b===undefined:b!==undefined&&a.x===b.x&&a.y===b.y&&a.width===b.width&&a.height===b.height&&a.rotation===b.rotation;
|
||||
const sameTransform=(a:Transform|undefined,b:Transform|undefined)=>a===undefined?b===undefined:b!==undefined&&a.x===b.x&&a.y===b.y&&a.scale===b.scale&&a.rotation===b.rotation;
|
||||
function sameSignature(old:PaintSignature,node:ProjectionNode,element:ElementRecord|undefined,t:Transform|undefined,protocol:NodeProtocol){
|
||||
if(old.incarnation!==element?.incarnation||old.owner!==element?.ownerCanvasId||old.component!==element?.componentId||old.version!==element?.dataVersion||old.protocol!==protocol||!sameGeometry(old.geometry,element?.structure==='node'?element.geometry:undefined)||!sameTransform(old.transform,t)||old.x!==node.position.x||old.y!==node.position.y||old.width!==node.width||old.height!==node.height||old.rotation!==node.data.rotation)return false;
|
||||
const dependencies=protocol.dataDependencies;
|
||||
if(dependencies===undefined)return equal(old.data,element?.data);
|
||||
const values=old.data as unknown[];
|
||||
for(let i=0;i<dependencies.length;i++)if(!equal(values[i],element?.data[dependencies[i]]))return false;
|
||||
return true;
|
||||
}
|
||||
function captureSignature(node:ProjectionNode,element:ElementRecord|undefined,t:Transform|undefined,protocol:NodeProtocol):PaintSignature{
|
||||
const dependencies=protocol.dataDependencies;
|
||||
return{incarnation:element?.incarnation,owner:element?.ownerCanvasId,component:element?.componentId,version:element?.dataVersion,geometry:element?.structure==='node'?{...element.geometry}:undefined,transform:t?{...t}:undefined,x:node.position.x,y:node.position.y,width:node.width,height:node.height,rotation:node.data.rotation,protocol,data:dependencies===undefined?clone(element?.data):dependencies.map(key=>clone(element?.data[key]))};
|
||||
}
|
||||
/** View-owned incremental paint grid; unchanged rows retain their captured dependencies. */
|
||||
export function createProjectedPaintIndex(){
|
||||
let registryOwner:Registry|undefined,canvas:string|undefined,project:string|undefined,computed=0,captured=0;
|
||||
type Projection=ReturnType<typeof projectCanvas>;
|
||||
type Entry={signature:PaintSignature;result:ReturnType<typeof projectedPaintOverflow>};
|
||||
const entries=new Map<string,Entry>(),index=createPaintIndex();
|
||||
return Object.freeze({
|
||||
sync(projection:Projection,registry:Registry){
|
||||
if(registryOwner!==registry||canvas!==projection.canvasId||project!==projection.document.projectId){entries.clear();index.clear();registryOwner=registry;canvas=projection.canvasId;project=projection.document.projectId;}
|
||||
const seen=new Set<string>(),regions:PaintRegion[]=[],unknown:string[]=[];
|
||||
for(const node of projection.nodes){seen.add(node.id);const element=projection.document.elements.get(node.id),t=element&&projection.transforms.get(element.ownerCanvasId),old=entries.get(node.id),declared=element&&!node.data.__headlessFallback&®istry.resolve(element)==='known'?registry.components.get(element.componentId)?.geometry:undefined,protocol=declared?.kind==='node'?declared:rectangleGeometry;
|
||||
let entry=old;
|
||||
if(!entry||!sameSignature(entry.signature,node,element,t,protocol)){
|
||||
const signature=captureSignature(node,element,t,protocol),result=projectedPaintOverflow({...projection,nodes:[node]},registry);captured++;computed++;entry={signature,result};entries.set(node.id,entry);
|
||||
}
|
||||
regions.push(...entry.result.regions);unknown.push(...entry.result.unknown);
|
||||
}
|
||||
for(const id of entries.keys())if(!seen.has(id))entries.delete(id);index.sync(regions);return Object.freeze({query:index.query,unknown:Object.freeze(unknown)});
|
||||
},
|
||||
stats:()=>Object.freeze({computed,captured,entries:entries.size}),
|
||||
clear(){entries.clear();index.clear();registryOwner=undefined;canvas=undefined;project=undefined;},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import type {Document,Id} from '../canvas-core/types.ts';
|
||||
import {equal,fault} from '../canvas-core/values.ts';
|
||||
import type {projectCanvas} from './projection.ts';
|
||||
|
||||
type Projection=ReturnType<typeof projectCanvas>;
|
||||
type Retained<T>={incarnation:number;ownerCanvasId:Id;value:T};
|
||||
/** View-owned rendering cache, never a document store. Keep unchanged React Flow input identities. */
|
||||
export function createProjectionCache(){
|
||||
let scope:object|undefined,projectId:Id|undefined,canvasId:Id|undefined;
|
||||
let nodeArray:Projection['nodes']|undefined,edgeArray:Projection['edges']|undefined;
|
||||
const stable=<T>(previous:T[]|undefined,next:T[])=>previous?.length===next.length&&next.every((value,index)=>value===previous[index])?previous:next;
|
||||
let nodes=new Map<Id,Retained<Projection['nodes'][number]>>(),edges=new Map<Id,Retained<Projection['edges'][number]>>();
|
||||
function retain<T extends{id:Id}>(document:Document,values:T[],previous:Map<Id,Retained<T>>){
|
||||
const next=new Map<Id,Retained<T>>();
|
||||
for(let index=0;index<values.length;index++){
|
||||
const value=values[index],record=document.elements.get(value.id);if(!record)throw fault('projection_identity_missing',[value.id]);
|
||||
const old=previous.get(value.id),sameLifetime=old&&old.incarnation===record.incarnation&&old.ownerCanvasId===record.ownerCanvasId;
|
||||
if(sameLifetime&&old.value===value){next.set(value.id,old);continue;}
|
||||
let shared=value;
|
||||
if(sameLifetime)for(const key of ['data','style','position'] as const){const incoming=(shared as Record<string,unknown>)[key],before=(old.value as Record<string,unknown>)[key];if(Object.hasOwn(shared,key)&&Object.hasOwn(old.value,key)&&!Object.is(incoming,before)&&equal(incoming,before))shared={...shared,[key]:before};}
|
||||
const retained=sameLifetime&&equal(old.value,shared)?old.value:shared;
|
||||
values[index]=retained;next.set(value.id,{incarnation:record.incarnation,ownerCanvasId:record.ownerCanvasId,value:retained});
|
||||
}
|
||||
return next;
|
||||
}
|
||||
return Object.freeze({
|
||||
reuse(owner:object,document:Document,viewCanvasId:Id,projection:Projection):Projection{
|
||||
if(scope!==owner||projectId!==document.projectId||canvasId!==viewCanvasId){nodes.clear();edges.clear();nodeArray=undefined;edgeArray=undefined;scope=owner;projectId=document.projectId;canvasId=viewCanvasId;}
|
||||
const nextNodes=retain(document,projection.nodes,nodes),nextEdges=retain(document,projection.edges,edges);
|
||||
nodes=nextNodes;edges=nextEdges;nodeArray=stable(nodeArray,projection.nodes);edgeArray=stable(edgeArray,projection.edges);
|
||||
return{...projection,nodes:nodeArray,edges:edgeArray};
|
||||
},
|
||||
getNode(id:Id){return nodes.get(id)?.value;},
|
||||
canRetainMeasurement(owner:object,document:Document,viewCanvasId:Id,value:Projection['nodes'][number]){
|
||||
const old=nodes.get(value.id),record=document.elements.get(value.id);
|
||||
return scope===owner&&projectId===document.projectId&&canvasId===viewCanvasId&&!!old&&!!record&&old.incarnation===record.incarnation&&old.ownerCanvasId===record.ownerCanvasId&&old.value.type===value.type&&old.value.width===value.width&&old.value.height===value.height&&equal(old.value.data,value.data);
|
||||
},
|
||||
clear(){nodes.clear();edges.clear();nodeArray=undefined;edgeArray=undefined;scope=undefined;projectId=undefined;canvasId=undefined;},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import type {DocumentQueries} from '../canvas-core/queries.ts';
|
||||
import type {GeometryProjectionCache} from './geometry-cache.ts';
|
||||
import type { Document, ElementRecord, Geometry, Id, Json, Point, Registry } from '../canvas-core/types.ts';
|
||||
import { builtinRendererCatalog, type RendererCatalog } from './renderers.ts';
|
||||
import { connectionPathValid } from '../canvas-core/features.ts';
|
||||
import { unitScale } from '../canvas-core/units.ts';
|
||||
import { fault } from '../canvas-core/values.ts';
|
||||
import type {CanvasNodeData,EdgeData} from './compat-types.ts';
|
||||
|
||||
import { childCanvasTransform, toWorld, transformPath, type Transform } from '../canvas-core/coordinates.ts';
|
||||
export { toWorld, toLocal, transformPath, type Transform } from '../canvas-core/coordinates.ts';
|
||||
const origin:Transform={x:0,y:0,scale:1,rotation:0};
|
||||
export type FallbackProjection = {reason:'unknown-component'|'unsupported-version'|'renderer-unavailable';componentId:string;dataVersion:number;handles:{id:string;side:'top'|'right'|'bottom'|'left'}[]};
|
||||
export type ProjectionNode = {id:Id;type:string;position:Point;width?:number;height?:number;selected?:boolean;draggable?:boolean;zIndex?:number;measured?:{width?:number;height?:number};style?:{width?:number|string;height?:number|string;pointerEvents?:'none'|'auto'};data:CanvasNodeData&{__headlessFallback?:FallbackProjection;__headlessRecord?:ElementRecord;__headlessTransform?:Transform}};
|
||||
|
||||
export type ProjectionEdge={id:Id;type?:string;source:Id;target:Id;sourceHandle?:string|null;targetHandle?:string|null;selected?:boolean;zIndex?:number;data:EdgeData&{__headlessRecord?:ElementRecord;__headlessTransform?:Transform}};
|
||||
const object = (value: Json | undefined) => value && typeof value === 'object' && !Array.isArray(value) ? value : {};
|
||||
|
||||
/** Construct one node. Scene updates and full traversal share exactly the same projection rules. */
|
||||
export function projectNode(element:Extract<ElementRecord,{structure:'node'}>,canvasId:Id,registry:Registry,selected:{has(id:Id):boolean},geometry:Geometry|undefined,transform:Transform,depth:number,child:Id|undefined,renderers:RendererCatalog,cache?:GeometryProjectionCache):ProjectionNode{
|
||||
const g = geometry ?? element.geometry, projectedGeometry=cache?.node(element.id,element.incarnation,g,transform),center = projectedGeometry?undefined:toWorld(transform, { x: g.x + g.width / 2, y: g.y + g.height / 2 });
|
||||
const width = projectedGeometry?.width??g.width * transform.scale, height = projectedGeometry?.height??g.height * transform.scale, resolved=registry.resolve(element),known=resolved==='known';
|
||||
const renderer=renderers.resolve(element);
|
||||
const fallback:FallbackProjection|undefined=!known||!renderer?{reason:resolved==='known'?'renderer-unavailable':resolved,componentId:element.componentId,dataVersion:element.dataVersion,handles:[]}:undefined;
|
||||
const type = fallback ? 'unsupported' : renderer!.rendererId;
|
||||
const data = element.data;
|
||||
const projected: ProjectionNode = { id: element.id, type, position: projectedGeometry?{...projectedGeometry.position}:{ x: center!.x - width / 2, y: center!.y - height / 2 }, width, height, selected: selected.has(element.id), draggable: known && registry.supports(element, 'geometry.move'), zIndex: element.componentId === 'frame' ? depth - 100 : depth * 2,
|
||||
style: { width, height, ...(element.componentId === 'frame'&&!fallback&&renderer?.projection==='compat-v1' ? { pointerEvents: 'none' as const } : {}) },
|
||||
data: { ...(fallback?{}:data), __headlessTarget:Object.freeze({id:element.id,incarnation:element.incarnation,ownerCanvasId:element.ownerCanvasId,canvasId:canvasId}), kind: element.componentId as CanvasNodeData['kind'], title: typeof data.title === 'string' ? data.title : element.componentId, text: !fallback&&typeof data.text === 'string' ? data.text : '', __headlessFallback:fallback, __headlessRecord:!fallback&&renderer?.projection==='record-v1'?element:undefined,__headlessTransform:!fallback&&renderer?.projection==='record-v1'?transform:undefined, rotation: projectedGeometry?.rotation??(g.rotation + transform.rotation) % 360, viewScale: transform.scale*(!fallback?unitScale(data.presentation):1), ...(child ? { subgraph: { boardId: child } } : {}) } as CanvasNodeData,
|
||||
};
|
||||
return projected;
|
||||
}
|
||||
|
||||
/** Endpoints are already resolved to their displayed node IDs by the caller. */
|
||||
export function projectEdge(element:Extract<ElementRecord,{structure:'connection'}>,source:Id,target:Id,registry:Registry,selected:{has(id:Id):boolean},transform:Transform,edgePreview:{target:{id:Id};path:Json}|null|undefined,renderers:RendererCatalog,cache?:GeometryProjectionCache):ProjectionEdge{
|
||||
const renderer=renderers.resolve(element),fallbackEdge=registry.resolve(element)!=='known'||!renderer;
|
||||
const path = fallbackEdge||renderer?.projection==='record-v1' ? undefined : (cache?cache.edge(element.id,element.incarnation,edgePreview?.target.id===element.id?edgePreview.path:element.path,transform):transformPath(edgePreview?.target.id === element.id ? edgePreview.path : element.path, transform));
|
||||
const renderPath = path!==undefined&&connectionPathValid(path) ? path : undefined;
|
||||
const { path: unusedPath, ...edgeData } = element.data; void unusedPath;
|
||||
return { id: element.id, ...(fallbackEdge?{type:'unsupported'}:renderer!.rendererId==='default'?{}:{type:renderer!.rendererId}), source, target, sourceHandle: source === element.source.elementId ? element.source.portId : undefined, targetHandle: target === element.target.elementId ? element.target.portId : undefined, selected: selected.has(element.id), data: fallbackEdge?{}:renderer?.projection==='record-v1'?{__headlessRecord:element,__headlessTransform:transform}:{ ...edgeData, ...(renderPath ? { path: renderPath } : {}) } as EdgeData };
|
||||
}
|
||||
|
||||
/** Renderer projection retains identity/owner in the model, including nested and collapsed canvases. */
|
||||
export function projectCanvas(document: Document, canvasId: Id, registry: Registry, selected: ReadonlySet<Id> | { has(id: Id): boolean }, geometry: (id: Id) => Geometry | undefined, edgePreview?: { target: { id: Id }; path: Json } | null, renderers:RendererCatalog=builtinRendererCatalog,services:{queries?:DocumentQueries;geometry?:GeometryProjectionCache;owner?:object}={}) {
|
||||
if (!document.canvases.has(canvasId)) throw fault('canvas_not_loaded', [canvasId]);
|
||||
const queries=services.queries,cache=services.geometry;if(queries&&queries.document!==document)throw fault('stale_query_snapshot');if(queries&&queries.registry!==registry)throw fault('query_registry_changed');cache?.begin(services.owner??cache,document.projectId,canvasId);
|
||||
const transforms = new Map<Id, Transform>([[canvasId, origin]]), hiddenBy = new Map<Id, Id>(), childByEntry = new Map<Id, Id>();
|
||||
if(!queries)for (const canvas of document.canvases.values()) if (canvas.parent) childByEntry.set(canvas.parent.entryElementId, canvas.id);
|
||||
const members = new Map<Id, ElementRecord[]>();
|
||||
if(!queries)for (const element of document.elements.values()) { const list = members.get(element.ownerCanvasId) ?? []; list.push(element); members.set(element.ownerCanvasId, list); }
|
||||
const getMembers=(id:Id)=>queries?queries.members(id).map(id=>document.elements.get(id)!):members.get(id)??[];
|
||||
const nodes: ProjectionNode[] = [], visible = new Set<Id>();
|
||||
const visit = (id: Id, depth: number) => {
|
||||
const transform = transforms.get(id)!;
|
||||
for (const element of getMembers(id)) if (element.structure === 'node') {
|
||||
const g=geometry(element.id)??element.geometry,child=queries?queries.relations.childCanvasByEntry.get(element.id):childByEntry.get(element.id),data=element.data;
|
||||
const projected=projectNode(element,canvasId,registry,selected,g,transform,depth,child,renderers,cache),fallback=projected.data.__headlessFallback;
|
||||
nodes.push(projected); visible.add(element.id);
|
||||
if (child) {
|
||||
const presentation = object(data.presentation);
|
||||
if (!!fallback || presentation.collapsed === true) {
|
||||
const stack = [child]; while (stack.length) { const hidden = stack.pop()!; hiddenBy.set(hidden, element.id); if(queries)stack.push(...queries.childCanvases(hidden));else for (const canvas of document.canvases.values()) if (canvas.parent?.canvasId === hidden) stack.push(canvas.id); }
|
||||
} else {
|
||||
transforms.set(child, cache?cache.child(child,element.incarnation,g,transform,data.presentation):childCanvasTransform(transform,g,data.presentation)); visit(child, depth + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
visit(canvasId, 0);
|
||||
const edges: ProjectionEdge[] = [];
|
||||
for (const element of queries?queries.orderedElements([...transforms.keys()].flatMap(id=>queries.members(id).filter(id=>document.elements.get(id)?.structure==='connection'))).map(id=>document.elements.get(id)!):document.elements.values()) if (element.structure === 'connection' && transforms.has(element.ownerCanvasId)) {
|
||||
const endpoint = (id: Id) => visible.has(id) ? id : hiddenBy.get(document.elements.get(id)?.ownerCanvasId ?? '');
|
||||
const source = endpoint(element.source.elementId), target = endpoint(element.target.elementId);
|
||||
if (!source || !target || source === target) continue;
|
||||
edges.push(projectEdge(element,source,target,registry,selected,transforms.get(element.ownerCanvasId)!,edgePreview,renderers,cache));
|
||||
}
|
||||
// Handles come from known envelope endpoints, never from opaque component payloads.
|
||||
const nodesById=new Map(nodes.map(node=>[node.id,node]));
|
||||
const fallbackNodes=new Map(nodes.filter(node=>node.data.__headlessFallback).map(node=>[node.id,node.data.__headlessFallback!]));
|
||||
const handleIds=new Map<Id,Set<string>>();
|
||||
for(const [id,fallback] of fallbackNodes){fallback.handles=(['top','right','bottom','left'] as const).map(side=>({id:side,side}));handleIds.set(id,new Set(fallback.handles.map(handle=>handle.id)));}
|
||||
for(const edge of edges)for(const end of ['source','target'] as const){
|
||||
const fallback=fallbackNodes.get(edge[end]);
|
||||
if(!fallback){
|
||||
if(edge.type==='unsupported'&&!nodesById.get(edge[end])?.data.__headlessRecord){
|
||||
const node=nodesById.get(edge[end]),field=end==='source'?'sourceHandle':'targetHandle',handle=edge[field];
|
||||
if(!handleIds.has(edge[end])){const ids=new Set(['top','right','bottom','left']);if(Array.isArray(node?.data.ports))for(const port of node.data.ports)if(port&&typeof port.id==='string')ids.add(`port:${port.id}`);handleIds.set(edge[end],ids);}
|
||||
if(handle&&!handleIds.get(edge[end])!.has(handle))edge[field]=undefined;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
const field=end==='source'?'sourceHandle':'targetHandle',handle=edge[field]|| (end==='source'?'right':'left');edge[field]=handle;
|
||||
const seen=handleIds.get(edge[end])!;if(!seen.has(handle)){seen.add(handle);fallback.handles.push({id:handle,side:end==='source'?'right':'left'});}
|
||||
}
|
||||
cache?.end();
|
||||
return { document,canvasId,nodes, edges, transforms };
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
"use client";
|
||||
/** Explicit framework boundary; consumers install the declared optional peers. */
|
||||
export * from './react-renderers.ts';
|
||||
export * from './renderer-context.ts';
|
||||
export * from './react.ts';
|
||||
|
||||
import type {Node,Edge} from '@xyflow/react';
|
||||
import type {ProjectionNode,ProjectionEdge} from './projection.ts';
|
||||
/** Structural adapter types add framework-owned optional metadata, never model state. */
|
||||
export type ReactFlowProjectionNode=Node<ProjectionNode['data'],string>&ProjectionNode;
|
||||
export type ReactFlowProjectionEdge=Edge<ProjectionEdge['data']>&ProjectionEdge;
|
||||
@@ -0,0 +1,27 @@
|
||||
import {memo} from 'react';
|
||||
import type { NodeTypes, EdgeTypes } from '@xyflow/react';
|
||||
import type { ComponentDefinition, Registry } from '../canvas-core/types.ts';
|
||||
import { fault } from '../canvas-core/values.ts';
|
||||
import { createRendererCatalog, type RendererDescriptor, type RendererCatalog } from './renderers.ts';
|
||||
export type ReactRendererDefinition=RendererDescriptor&({readonly structure:'node';readonly render:NodeTypes[string];readonly preview?:NodeTypes[string]}|{readonly structure:'connection';readonly render:EdgeTypes[string];readonly preview?:EdgeTypes[string]});
|
||||
export type ReactRenderers={readonly catalog:RendererCatalog;readonly definitions:readonly ReactRendererDefinition[];readonly nodeTypes:NodeTypes;readonly edgeTypes:EdgeTypes;readonly previewNodeTypes:NodeTypes;readonly previewEdgeTypes:EdgeTypes};
|
||||
export type CanvasRuntimeExtension={readonly components?:readonly ComponentDefinition[];readonly renderers?:readonly ReactRendererDefinition[]};
|
||||
const memoizedRenderers=new WeakMap<object,NodeTypes[string]|EdgeTypes[string]>();
|
||||
function compiledRenderer(render:NodeTypes[string]|EdgeTypes[string]):NodeTypes[string]|EdgeTypes[string]{
|
||||
if((render as {$$typeof?:symbol}).$$typeof===Symbol.for('react.memo'))return render;
|
||||
const key=render as object,previous=memoizedRenderers.get(key);if(previous)return previous;
|
||||
const compiled=memo(render as NodeTypes[string]);memoizedRenderers.set(key,compiled);return compiled;
|
||||
}
|
||||
export function createReactRenderers(definitions:readonly ReactRendererDefinition[],registry?:Registry):ReactRenderers{
|
||||
const catalog=createRendererCatalog(definitions),nodes:NodeTypes=Object.create(null),edges:EdgeTypes=Object.create(null),previewNodes:NodeTypes=Object.create(null),previewEdges:EdgeTypes=Object.create(null),captured:ReactRendererDefinition[]=[];
|
||||
for(let index=0;index<definitions.length;index++){
|
||||
const definition=definitions[index],descriptor=catalog.definitions[index],render=definition.render,preview=definition.preview,tag=render&&typeof render==='object'?(render as {$$typeof?:symbol}).$$typeof:undefined;
|
||||
if(typeof render!=='function'&&![Symbol.for('react.memo'),Symbol.for('react.forward_ref'),Symbol.for('react.lazy')].includes(tag as symbol))throw fault('invalid_react_renderer');
|
||||
const previewTag=preview&&typeof preview==='object'?(preview as {$$typeof?:symbol}).$$typeof:undefined;if(preview!==undefined&&typeof preview!=='function'&&![Symbol.for('react.memo'),Symbol.for('react.forward_ref'),Symbol.for('react.lazy')].includes(previewTag as symbol))throw fault('invalid_react_preview');
|
||||
if(registry){const model=registry.components.get(descriptor.componentId);if(!model||model.dataVersion!==descriptor.dataVersion||model.structure!==descriptor.structure)throw fault('renderer_model_mismatch',[descriptor.componentId]);}
|
||||
const table=descriptor.structure==='node'?nodes:edges,previews=descriptor.structure==='node'?previewNodes:previewEdges,compiled=compiledRenderer(render),compiledPreview=preview?compiledRenderer(preview):undefined;
|
||||
if(Object.hasOwn(table,descriptor.rendererId)&&(table[descriptor.rendererId]!==compiled||previews[descriptor.rendererId]!==compiledPreview))throw fault('renderer_id_conflict',[descriptor.rendererId]);
|
||||
Object.defineProperty(table,descriptor.rendererId,{value:compiled,enumerable:true,configurable:true});if(compiledPreview)Object.defineProperty(previews,descriptor.rendererId,{value:compiledPreview,enumerable:true,configurable:true});captured.push(Object.freeze({...descriptor,render,...(preview?{preview}:{})}) as ReactRendererDefinition);
|
||||
}
|
||||
return Object.freeze({catalog,definitions:Object.freeze(captured),nodeTypes:Object.freeze(nodes),edgeTypes:Object.freeze(edges),previewNodeTypes:Object.freeze(previewNodes),previewEdgeTypes:Object.freeze(previewEdges)});
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
"use client";
|
||||
import {createContext,createElement,useCallback,useContext,useEffect,useMemo,useRef,useState,useSyncExternalStore,type ReactNode} from 'react';
|
||||
import {createSelectorStore,type ExternalSource} from './selectors.ts';
|
||||
import {createElementSources} from './element-sources.ts';
|
||||
import {createSessionSources,type GestureSelection} from './session-sources.ts';
|
||||
import {createRuntimeSources,type OperationView} from './runtime-sources.ts';
|
||||
export type {OperationView} from './runtime-sources.ts';
|
||||
import type {ProjectEngine} from '../canvas-runtime/workspace.ts';
|
||||
import type {EngineSnapshot} from '../canvas-runtime/contracts.ts';
|
||||
import type {Target,Id,Data,Geometry,ElementRecord,Availability,Result} from '../canvas-core/types.ts';
|
||||
import type {ElementLookup} from '../canvas-core/queries.ts';
|
||||
import {equal,fault} from '../canvas-core/values.ts';
|
||||
import type {DisplayHandle} from '../canvas-runtime/resources.ts';
|
||||
|
||||
export type CanvasBinding={readonly engine:ProjectEngine;readonly viewId?:Id};
|
||||
const CanvasContext=createContext<CanvasBinding|null>(null);
|
||||
/** Borrows the engine and an already attached view. Unmount never closes the project. */
|
||||
export function CanvasProvider({engine,viewId,children}:{engine:ProjectEngine;viewId?:Id;children?:ReactNode}){const value=useMemo(()=>Object.freeze({engine,viewId}),[engine,viewId]);return createElement(CanvasContext.Provider,{value},children);}
|
||||
export function useCanvasBinding(){const binding=useContext(CanvasContext);if(!binding)throw fault('canvas_provider_missing');return binding;}
|
||||
function useSelected<S,T>(source:ExternalSource<S>,selector:(snapshot:S)=>T,isEqual:(a:T,b:T)=>boolean=Object.is):T{
|
||||
const committed=useRef<{value:T}|null>(null);
|
||||
const store=useMemo(()=>createSelectorStore(source,(snapshot:S)=>{const selected=selector(snapshot);return committed.current&&isEqual(committed.current.value,selected)?committed.current.value:selected;},isEqual),[source,selector,isEqual]);
|
||||
const value=useSyncExternalStore(store.subscribe,store.getSnapshot,store.getSnapshot);
|
||||
useEffect(()=>{committed.current={value};},[value]);return value;
|
||||
}
|
||||
function useRootSource(){const {engine}=useCanvasBinding();return useMemo(()=>({getSnapshot:engine.getSnapshot,subscribe:(notify:()=>void)=>engine.getDurability().closed?()=>{}:engine.editor.subscribe(notify)}),[engine]);}
|
||||
export function useCanvas<T>(selector:(snapshot:EngineSnapshot)=>T,isEqual:(a:T,b:T)=>boolean=Object.is):T{return useSelected(useRootSource(),selector,isEqual);}
|
||||
export type ElementView=ElementLookup|{readonly status:'stale';readonly element:ElementRecord};
|
||||
const elementSources=new WeakMap<ProjectEngine,ReturnType<typeof createElementSources>>();
|
||||
function elementSourcesFor(engine:ProjectEngine){let sources=elementSources.get(engine);if(!sources){sources=createElementSources({getSnapshot:engine.getSnapshot,subscribe:notify=>engine.getDurability().closed?()=>{}:engine.editor.subscribe(notify),lookup:(root,id)=>engine.editor.getQueries(root.document).lookupElement(id)});elementSources.set(engine,sources);}return sources;}
|
||||
export function useElement(ref:Id|Target):ElementView;
|
||||
export function useElement<T>(ref:Id|Target,selector:(view:ElementView)=>T,isEqual?:(a:T,b:T)=>boolean):T;
|
||||
export function useElement<T>(ref:Id|Target,selector?:(view:ElementView)=>T,isEqual?:(a:T,b:T)=>boolean):T|ElementView{
|
||||
const {engine}=useCanvasBinding(),id=typeof ref==='string'?ref:ref.id,incarnation=typeof ref==='string'?undefined:ref.incarnation;
|
||||
const source=useMemo(()=>elementSourcesFor(engine).forElement(id),[engine,id]);
|
||||
return useSelected(source,useCallback((found:ElementLookup)=>{const view=found.status==='present'&&incarnation!==undefined&&found.element.incarnation!==incarnation?{status:'stale' as const,element:found.element}:found;return selector?selector(view):view;},[incarnation,selector]),(isEqual as ((a:T|ElementView,b:T|ElementView)=>boolean)|undefined)??(selector?Object.is:equal));
|
||||
}
|
||||
export type ActionOptions=Parameters<ProjectEngine['editor']['dispatch']>[1];
|
||||
export function useActions(target:Target){const {engine}=useCanvasBinding(),{id,incarnation}=target;return useMemo(()=>{const ref=Object.freeze({id,incarnation});return Object.freeze({
|
||||
rename:(title:string,options?:ActionOptions)=>engine.editor.dispatch({type:'element.data',target:ref,patch:{title}},options),
|
||||
setData:(patch:Data,options?:ActionOptions)=>engine.editor.dispatch({type:'element.data',target:ref,patch},options),
|
||||
resize:(patch:Partial<Geometry>,options?:ActionOptions)=>engine.editor.dispatch({type:'element.geometry',target:ref,patch},options),
|
||||
remove:(options?:ActionOptions)=>engine.editor.dispatch({type:'element.remove',target:ref},options),
|
||||
moveTo:(canvasId:Id,geometry?:Geometry,options?:ActionOptions)=>engine.editor.dispatch({type:'element.move',target:ref,canvasId,...(geometry?{geometry}:{})},options),
|
||||
});},[engine,id,incarnation]);}
|
||||
const sessionSources=new WeakMap<ProjectEngine,ReturnType<typeof createSessionSources>>();
|
||||
function sessionSourcesFor(engine:ProjectEngine){let sources=sessionSources.get(engine);if(!sources){sources=createSessionSources({getSnapshot:engine.sessions.getSnapshot,subscribe:notify=>engine.getDurability().closed?()=>{}:engine.sessions.subscribe(notify)});sessionSources.set(engine,sources);}return sources;}
|
||||
function pairedSource<A,B>(left:ExternalSource<A>,right:ExternalSource<B>):ExternalSource<readonly[A,B]>{
|
||||
let previous:readonly[A,B]|undefined;
|
||||
return{getSnapshot(){const a=left.getSnapshot(),b=right.getSnapshot();if(!previous||!Object.is(previous[0],a)||!Object.is(previous[1],b))previous=Object.freeze([a,b] as const);return previous;},subscribe(notify){const a=left.subscribe(notify);try{const b=right.subscribe(notify);return()=>{a();b();};}catch(error){a();throw error;}}};
|
||||
}
|
||||
function useView(){const binding=useCanvasBinding();if(!binding.viewId)throw fault('view_session_required');return {...binding,viewId:binding.viewId};}
|
||||
function available(error:unknown):Availability{return{enabled:false,code:(error as {code?:string}).code??'binding_failed',targets:(error as {targets?:readonly Id[]}).targets??[]};}
|
||||
export function useMode(target:Target){
|
||||
const {engine,viewId}=useView(),{id,incarnation}=target;
|
||||
const source=useMemo(()=>pairedSource(elementSourcesFor(engine).forElement(id),sessionSourcesFor(engine).mode(viewId,id)),[engine,viewId,id]);
|
||||
const mode=useSelected(source,useCallback(([found,mode]:readonly[ElementLookup,string|null])=>found.status==='present'&&found.element.incarnation===incarnation?mode:null,[incarnation]));
|
||||
const setMode=useCallback((mode:string|null):Availability=>{try{if(engine.getSnapshot().document.elements.get(id)?.incarnation!==incarnation)throw fault('stale_incarnation',[id]);engine.sessions.mode(viewId,id,mode);return{enabled:true};}catch(error){return available(error);}},[engine,viewId,id,incarnation]);
|
||||
return useMemo(()=>Object.freeze({mode,setMode}),[mode,setMode]);
|
||||
}
|
||||
export function useGestureBindings(target:Target){
|
||||
const {engine,viewId}=useView(),{id,incarnation}=target;
|
||||
const source=useMemo(()=>pairedSource(elementSourcesFor(engine).forElement(id),sessionSourcesFor(engine).gesture(viewId,{id,incarnation})),[engine,viewId,id,incarnation]);
|
||||
const state=useSelected(source,useCallback(([found,transient]:readonly[ElementLookup,GestureSelection])=>({geometry:found.status==='present'&&found.element.incarnation===incarnation&&found.element.structure==='node'?transient.preview??found.element.geometry:undefined,active:transient.active}),[incarnation]),equal);
|
||||
const controls=useMemo(()=>{
|
||||
let lease:number|null=null;
|
||||
const owned=()=>lease!==null&&engine.sessions.getSnapshot().gesture?.token===lease&&engine.sessions.getSnapshot().gesture?.viewId===viewId&&engine.sessions.getSnapshot().gesture?.phase==='active';
|
||||
return Object.freeze({begin():Availability{const result=engine.sessions.begin(viewId,[{id,incarnation}]);if(result.enabled)lease=engine.sessions.getSnapshot().gesture!.token;return result;},update(patch:Partial<Geometry>):Availability{return owned()?engine.sessions.update(viewId,{id,incarnation},patch):{enabled:false,code:'gesture_not_owned',targets:[id]};},commit():Result{if(!owned())return{status:'rejected',code:'gesture_not_owned',targets:[id]};try{return engine.sessions.commit(viewId);}finally{lease=null;}},cancel(){if(owned())engine.sessions.cancel('binding_canceled');lease=null;}});
|
||||
},[engine,viewId,id,incarnation]);
|
||||
useEffect(()=>()=>controls.cancel(),[controls]);return useMemo(()=>Object.freeze({...state,...controls}),[state,controls]);
|
||||
}
|
||||
const runtimeSources=new WeakMap<ProjectEngine,ReturnType<typeof createRuntimeSources>>();
|
||||
function runtimeSourcesFor(engine:ProjectEngine){let sources=runtimeSources.get(engine);if(!sources){sources=createRuntimeSources({getSnapshot:engine.getSnapshot,subscribe:notify=>engine.getDurability().closed?()=>{}:engine.editor.subscribe(notify)});runtimeSources.set(engine,sources);}return sources;}
|
||||
export function useOperation(id:Id):OperationView{const {engine}=useCanvasBinding(),source=useMemo(()=>runtimeSourcesFor(engine).operation(id),[engine,id]);return useSyncExternalStore(source.subscribe,source.getSnapshot,source.getSnapshot);}
|
||||
type Durability=ReturnType<ProjectEngine['getDurability']>;
|
||||
const syncDurability=(value:Durability)=>({dirty:value.rootRevision>value.durableRootRevision,error:value.error,closing:value.closing,closed:value.closed});
|
||||
const inactiveOwner=(value:Durability)=>value.closing||value.closed;
|
||||
function useDurability<T>(selector:(value:Durability)=>T,isEqual:(a:T,b:T)=>boolean=Object.is){const {engine}=useCanvasBinding();const source=useMemo(()=>({getSnapshot:engine.getDurability,subscribe:engine.subscribeDurability}),[engine]);return useSelected(source,selector,isEqual);}
|
||||
export function useSyncStatus(canvasId?:Id){
|
||||
const {engine}=useCanvasBinding(),source=useMemo(()=>runtimeSourcesFor(engine).counts(canvasId),[engine,canvasId]);
|
||||
const durable=useDurability(syncDurability,equal),counts=useSyncExternalStore(source.subscribe,source.getSnapshot,source.getSnapshot);
|
||||
const dirty=durable.dirty;
|
||||
return useMemo(()=>Object.freeze({...counts,dirty,error:durable.error,status:durable.closed?'closed' as const:durable.error||counts.blocked?'blocked' as const:durable.closing?'closing' as const:dirty?'saving' as const:counts.pending?'syncing' as const:counts.loading?'loading' as const:'synced' as const}),[counts,dirty,durable.error,durable.closing,durable.closed]);
|
||||
}
|
||||
export function useAsset(id:Id){
|
||||
const {engine}=useCanvasBinding(),inactive=useDurability(inactiveOwner);
|
||||
const source=useMemo(()=>runtimeSourcesFor(engine).asset(id),[engine,id]),metadata=useSyncExternalStore(source.subscribe,source.getSnapshot,source.getSnapshot);
|
||||
const key=JSON.stringify([metadata.record?.operationId,metadata.record?.retirement?.purgedAt,metadata.binding?.authority,metadata.binding?.digest]);
|
||||
const [attempt,setAttempt]=useState(0),[state,setState]=useState<{engine:ProjectEngine;id:Id;key:string;status:'loading'|'ready'|'error';url?:string;error?:unknown}>();
|
||||
useEffect(()=>{if(inactive){setState({engine,id,key,status:'error',error:fault('engine_closing')});return;}const controller=new AbortController();let handle:DisplayHandle|undefined;setState({engine,id,key,status:'loading'});void engine.displayResource(id,{signal:controller.signal}).then(value=>{if(controller.signal.aborted){value.release();return;}handle=value;setState({engine,id,key,status:'ready',url:value.url});},error=>{if(!controller.signal.aborted)setState({engine,id,key,status:'error',error});});return()=>{controller.abort();handle?.release();};},[engine,id,key,attempt,inactive]);
|
||||
const retry=useCallback(()=>setAttempt(value=>value+1),[]);
|
||||
return useMemo(()=>Object.freeze({...metadata,...(inactive?{status:'error' as const,url:undefined,error:fault('engine_closing')}:state?.engine===engine&&state.id===id&&state.key===key?{status:state.status,url:state.url,error:state.error}:{status:'loading' as const,url:undefined,error:undefined}),retry}),[metadata,state,engine,id,key,retry,inactive]);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
"use client";
|
||||
import { createContext, useContext } from 'react';
|
||||
import type { Transaction } from '../canvas-core/editor.ts';
|
||||
import type { Command, Result } from '../canvas-core/types.ts';
|
||||
export type RendererCommandOptions={label?:string;groupId?:string};
|
||||
export type CanvasRendererCommands={dispatch(command:Command,options?:RendererCommandOptions):Result;transaction(callback:(transaction:Transaction)=>unknown,options?:RendererCommandOptions):Result};
|
||||
export const CanvasRendererCommandsContext=createContext<CanvasRendererCommands|null>(null);
|
||||
/** Trusted installed renderers submit normal validated commands, never write a second model. */
|
||||
export function useCanvasRendererCommands(){const commands=useContext(CanvasRendererCommandsContext);if(!commands)throw Error('Canvas renderer commands are unavailable');return commands;}
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { ElementRecord,Registry } from '../canvas-core/types.ts';
|
||||
import { fault } from '../canvas-core/values.ts';
|
||||
export type RendererDescriptor={readonly componentId:string;readonly dataVersion:number;readonly structure:'node'|'connection';readonly rendererId:string;readonly projection?:'compat-v1'|'record-v1'};
|
||||
export type RendererCatalog={readonly definitions:readonly RendererDescriptor[];resolve(element:Pick<ElementRecord,'componentId'|'dataVersion'|'structure'>):RendererDescriptor|undefined};
|
||||
const key=(value:Pick<RendererDescriptor,'componentId'|'dataVersion'|'structure'>)=>JSON.stringify([value.componentId,value.dataVersion,value.structure]);
|
||||
export function createRendererCatalog(definitions:readonly RendererDescriptor[]):RendererCatalog{
|
||||
const entries=new Map<string,RendererDescriptor>();
|
||||
for(const value of definitions){
|
||||
if(!value||typeof value.componentId!=='string'||!value.componentId||!Number.isSafeInteger(value.dataVersion)||value.dataVersion<1||!['node','connection'].includes(value.structure)||typeof value.rendererId!=='string'||!value.rendererId||['unsupported','__proto__','prototype','constructor'].includes(value.rendererId)||value.projection!==undefined&&!['compat-v1','record-v1'].includes(value.projection))throw fault('invalid_renderer_definition');
|
||||
const id=key(value);if(entries.has(id))throw fault('duplicate_renderer_definition',[value.componentId]);entries.set(id,Object.freeze({componentId:value.componentId,dataVersion:value.dataVersion,structure:value.structure,rendererId:value.rendererId,projection:value.projection??'record-v1'}));
|
||||
}
|
||||
return Object.freeze({definitions:Object.freeze([...entries.values()]),resolve:(element:Pick<ElementRecord,'componentId'|'dataVersion'|'structure'>)=>entries.get(key(element))});
|
||||
}
|
||||
export const builtinRendererDefinitions:readonly RendererDescriptor[]=Object.freeze([
|
||||
...['text','image','script'].map(componentId=>Object.freeze({componentId,dataVersion:1,structure:'node' as const,rendererId:'material',projection:'compat-v1' as const})),
|
||||
...Object.entries({'plain-text':'plainText','plain-image':'plainImage',frame:'frame',icon:'icon'}).map(([componentId,rendererId])=>Object.freeze({componentId,dataVersion:1,structure:'node' as const,rendererId,projection:'compat-v1' as const})),
|
||||
Object.freeze({componentId:'connection',dataVersion:1,structure:'connection',rendererId:'default',projection:'compat-v1'}),
|
||||
]);
|
||||
export const builtinRendererCatalog=createRendererCatalog(builtinRendererDefinitions);
|
||||
|
||||
/** Installed renderers without a reference declaration wait for full context. */
|
||||
export function createLoadingRendererCatalog(registry:Registry,catalog:RendererCatalog):RendererCatalog{return Object.freeze({definitions:catalog.definitions,resolve:(element:Parameters<RendererCatalog['resolve']>[0])=>registry.components.get(element.componentId)?.copy?catalog.resolve(element):undefined});}
|
||||
@@ -0,0 +1,49 @@
|
||||
import type { Id } from '../canvas-core/types.ts';
|
||||
import { fault } from '../canvas-core/values.ts';
|
||||
import type { DisplayHandle } from '../canvas-runtime/resources.ts';
|
||||
|
||||
type Record = { readonly resource: Id; readonly controller: AbortController; handle?: DisplayHandle; failed: boolean };
|
||||
export type ResourceViewSource = { readonly resourceId: Id; readonly url: string };
|
||||
/** A view owns subscriptions/leases, never a resource or document store. Retain
|
||||
* unchanged nodes, publish each ready source independently, and fence late work.
|
||||
*/
|
||||
export function createResourceView(options: {
|
||||
resolve(resource: Id, signal: AbortSignal): Promise<DisplayHandle>;
|
||||
onChange(sources: Map<Id, ResourceViewSource>, failures: number): void;
|
||||
onError?(error: unknown): void;
|
||||
}) {
|
||||
const records = new Map<Id, Record>(); let targets = new Map<Id, Id>(), disposed = false;
|
||||
const report = (error: unknown) => { try { options.onError?.(error); } catch { /* Observer errors do not own leases. */ } };
|
||||
const publish = () => { try { options.onChange(new Map([...records].flatMap(([id, record]) => record.handle ? [[id, {resourceId:record.resource,url:record.handle.url}] as [Id, ResourceViewSource]] : [])), [...records.values()].filter(record => record.failed).length); } catch (error) { report(error); } };
|
||||
const releaseHandle = (handle: DisplayHandle) => { try { handle.release(); } catch (error) { report(error); } };
|
||||
const release = (record: Record) => { record.controller.abort(); if(record.handle)releaseHandle(record.handle); };
|
||||
function sync(next: readonly (readonly [Id, Id])[]) {
|
||||
if (disposed) throw fault('resource_view_disposed');
|
||||
const desired = new Map<Id, Id>();
|
||||
for (const [id, resource] of next) {
|
||||
if (typeof id !== 'string' || !id || typeof resource !== 'string' || !resource || desired.has(id)) throw fault('invalid_resource_view');
|
||||
desired.set(id, resource);
|
||||
}
|
||||
targets = desired; let changed = false;
|
||||
for (const [id, record] of records) if (desired.get(id) !== record.resource) { records.delete(id); release(record); changed = true; }
|
||||
for (const [id, resource] of desired) if (!records.has(id)) {
|
||||
const record: Record = { resource, controller: new AbortController(), failed: false }; records.set(id, record); changed = true;
|
||||
void Promise.resolve().then(() => {
|
||||
if (disposed || records.get(id) !== record) throw fault('display_aborted');
|
||||
return options.resolve(resource, record.controller.signal);
|
||||
}).then(handle => {
|
||||
if (disposed || records.get(id) !== record) { releaseHandle(handle); return; }
|
||||
record.handle = handle; publish();
|
||||
}, error => {
|
||||
if (disposed || records.get(id) !== record || record.controller.signal.aborted) return;
|
||||
record.failed = true; publish(); report(error);
|
||||
});
|
||||
}
|
||||
if (changed) publish();
|
||||
}
|
||||
return Object.freeze({
|
||||
sync,
|
||||
retry() { if (disposed) throw fault('resource_view_disposed'); for (const [id, record] of records) if (record.failed) { records.delete(id); release(record); } sync([...targets]); },
|
||||
dispose() { if (disposed) return; disposed = true; for (const record of records.values()) release(record); records.clear(); targets.clear(); publish(); },
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import type {Id} from '../canvas-core/types.ts';
|
||||
import type {EngineSnapshot} from '../canvas-runtime/contracts.ts';
|
||||
import type {OperationRecord,FrozenRequest} from '../canvas-runtime/ledger.ts';
|
||||
import type {ResourceRecord} from '../canvas-runtime/resources.ts';
|
||||
import type {SharedAssetBinding} from '../canvas-runtime/asset-depot.ts';
|
||||
import {equal} from '../canvas-core/values.ts';
|
||||
import type {ExternalSource} from './selectors.ts';
|
||||
|
||||
export type OperationView={readonly operation:OperationRecord;readonly request?:FrozenRequest}|undefined;
|
||||
export type AssetMetadata={readonly record:ResourceRecord|undefined;readonly binding:SharedAssetBinding|undefined};
|
||||
export type SyncCounts={readonly pending:number;readonly blocked:number;readonly unknown:number;readonly loading:boolean};
|
||||
type Dependencies=readonly(object|boolean|undefined)[];
|
||||
|
||||
/** Read-only table projections: one upstream listener, no per-hook Root/table snapshots. */
|
||||
export function createRuntimeSources(source:ExternalSource<EngineSnapshot>){
|
||||
type Group={update(root:EngineSnapshot,notifications:Set<()=>void>):void};
|
||||
const active=new Set<Group>(),identities=new WeakMap<object,number>();let nextIdentity=0,stop:(()=>void)|undefined;
|
||||
function signature(values:Dependencies){return values.map(value=>{if(value===undefined)return'u';if(typeof value==='boolean')return value?'t':'f';let id=identities.get(value);if(id===undefined){id=++nextIdentity;identities.set(value,id);}return`o${id}`;}).join(':');}
|
||||
function changed(){
|
||||
if(!active.size)return;const root=source.getSnapshot(),notifications=new Set<()=>void>();
|
||||
for(const group of active)group.update(root,notifications);
|
||||
let failed=false,error:unknown;for(const notify of notifications)try{notify();}catch(caught){if(!failed){failed=true;error=caught;}}if(failed)throw error;
|
||||
}
|
||||
function group<K,V>(dependencies:(root:EngineSnapshot)=>Dependencies,read:(root:EngineSnapshot,key:K)=>V){
|
||||
// Direct reads may precede notification, so read caches cannot replace observed baselines.
|
||||
type Bucket={cached:V;cachedSignature:string;observed:V;listeners:Set<()=>void>};
|
||||
const buckets=new Map<K,Bucket>(),failed=new Set<K>();let observedSignature:string|undefined;
|
||||
function readCached(root:EngineSnapshot,key:K,bucket:Bucket,stamp:string){
|
||||
if(bucket.cachedSignature!==stamp){const value=read(root,key);if(!equal(bucket.cached,value))bucket.cached=value;bucket.cachedSignature=stamp;}
|
||||
return bucket.cached;
|
||||
}
|
||||
const owner:Group={update(root,notifications){
|
||||
const stamp=signature(dependencies(root)),all=stamp!==observedSignature;if(!all&&!failed.size)return;observedSignature=stamp;
|
||||
for(const key of all?buckets.keys():[...failed]){
|
||||
const bucket=buckets.get(key);if(!bucket)continue;
|
||||
try{const value=readCached(root,key,bucket,stamp);if(!failed.has(key)&&equal(bucket.observed,value))continue;bucket.observed=value;failed.delete(key);}catch{failed.add(key);}
|
||||
for(const notify of bucket.listeners)notifications.add(notify);
|
||||
}
|
||||
}};
|
||||
return{forKey(key:K):ExternalSource<V>{
|
||||
let initialized=false,local:V,localSignature:string;
|
||||
function getSnapshot(){
|
||||
const root=source.getSnapshot(),stamp=signature(dependencies(root)),bucket=buckets.get(key);if(bucket)return readCached(root,key,bucket,stamp);
|
||||
if(!initialized||stamp!==localSignature){const value=read(root,key);if(!initialized||!equal(local,value))local=value;localSignature=stamp;initialized=true;}
|
||||
return local;
|
||||
}
|
||||
return Object.freeze({getSnapshot,subscribe(notify:()=>void){
|
||||
let bucket=buckets.get(key);if(!bucket){const value=getSnapshot();if(!buckets.size)observedSignature=signature(dependencies(source.getSnapshot()));bucket={cached:value,cachedSignature:signature(dependencies(source.getSnapshot())),observed:value,listeners:new Set()};buckets.set(key,bucket);active.add(owner);}
|
||||
const listener=()=>notify();bucket.listeners.add(listener);
|
||||
const remove=()=>{bucket!.listeners.delete(listener);if(!bucket!.listeners.size){buckets.delete(key);failed.delete(key);}if(!buckets.size){active.delete(owner);observedSignature=undefined;}};
|
||||
if(!stop)try{stop=source.subscribe(changed);}catch(error){remove();throw error;}
|
||||
let live=true;return()=>{if(!live)return;live=false;remove();if(!active.size){stop?.();stop=undefined;}};
|
||||
}});
|
||||
}};
|
||||
}
|
||||
const operations=group<Id,OperationView>(root=>[root.runtime.ledger.operations,root.runtime.ledger.requests],(root,id)=>{const operation=root.runtime.ledger.operations.get(id);return operation?Object.freeze({operation,...(operation.requestId?{request:root.runtime.ledger.requests.get(operation.requestId)}:{})}):undefined;});
|
||||
const assets=group<Id,AssetMetadata>(root=>[root.runtime.resources,root.runtime.assetBindings],(root,id)=>Object.freeze({record:root.runtime.resources.get(id),binding:root.runtime.assetBindings?.get(id)}));
|
||||
let countStamp:string|undefined,projectCounts:SyncCounts,emptyCounts:SyncCounts,canvasCounts=new Map<Id,SyncCounts>();
|
||||
const countDependencies=(root:EngineSnapshot):Dependencies=>[root.runtime.ledger.operations,root.runtime.ledger.requests,!!root.runtime.loading];
|
||||
const counts=group<Id|undefined,SyncCounts>(countDependencies,(root,canvasId)=>{
|
||||
const stamp=signature(countDependencies(root));
|
||||
if(stamp!==countStamp){
|
||||
const loading=!!root.runtime.loading,empty=()=>({pending:0,blocked:0,unknown:0,loading}),project=empty(),byCanvas=new Map<Id,ReturnType<typeof empty>>();
|
||||
const scope=(id:Id)=>{let row=byCanvas.get(id);if(!row){row=empty();byCanvas.set(id,row);}return row;};
|
||||
for(const operation of root.runtime.ledger.operations.values())if(operation.phase!=='resolved'){
|
||||
const ids=operation.intent.canvasIds.length>1?new Set(operation.intent.canvasIds):operation.intent.canvasIds,blocked=operation.phase==='blocked'&&operation.blockers.some(reason=>reason!=='waiting_digest');project.pending++;if(blocked)project.blocked++;
|
||||
for(const id of ids){const row=scope(id);row.pending++;if(blocked)row.blocked++;}
|
||||
}
|
||||
for(const request of root.runtime.ledger.requests.values())if(request.phase!=='resolved'){
|
||||
let matched=false;const ids=new Set<Id>();for(const id of request.operationIds){const operation=root.runtime.ledger.operations.get(id);if(operation&&operation.phase!=='resolved'){matched=true;for(const canvas of operation.intent.canvasIds)ids.add(canvas);}}
|
||||
if(!matched)continue;const blocked=!!request.error&&request.retryAt===undefined,unknown=request.outcome==='unresolved'&&['dispatch-marked','waiting'].includes(request.phase);
|
||||
if(blocked)project.blocked++;if(unknown)project.unknown++;for(const id of ids){const row=scope(id);if(blocked)row.blocked++;if(unknown)row.unknown++;}
|
||||
}
|
||||
projectCounts=Object.freeze(project);emptyCounts=Object.freeze(empty());canvasCounts=new Map([...byCanvas].map(([id,row])=>[id,Object.freeze(row)]));countStamp=stamp;
|
||||
}
|
||||
return canvasId===undefined?projectCounts:canvasCounts.get(canvasId)??emptyCounts;
|
||||
});
|
||||
return Object.freeze({operation:operations.forKey,asset:assets.forKey,counts:(canvasId?:Id)=>counts.forKey(canvasId)});
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import type {DocumentQueries} from '../canvas-core/queries.ts';
|
||||
import type {Document,Id,Registry} from '../canvas-core/types.ts';
|
||||
import type {SessionSnapshot,ViewState} from '../canvas-core/gestures.ts';
|
||||
import {equal} from '../canvas-core/values.ts';
|
||||
import {projectNode,projectEdge,type projectCanvas} from './projection.ts';
|
||||
import type {RendererCatalog} from './renderers.ts';
|
||||
|
||||
type Projection=ReturnType<typeof projectCanvas>;
|
||||
export type SceneChangeHint={from:Document;to:Document;ids:Set<Id>;topology:boolean};
|
||||
type Preview=NonNullable<SessionSnapshot['gesture']>['preview']|undefined;
|
||||
export type SceneInputs={owner:object;document:Document;registry:Registry;catalog:RendererCatalog;canvasId:Id;selection:ViewState['selection'];preview:Preview;edgeId:Id|undefined;path:NonNullable<SessionSnapshot['connectionGesture']>['path']|undefined};
|
||||
export function indexScene(projection:Projection){
|
||||
const nodes=new Map(projection.nodes.map((node,index)=>[node.id,index])),edges=new Map(projection.edges.map((edge,index)=>[edge.id,index])),entries=new Map<Id,Id>(),depths=new Map<Id,number>([[projection.canvasId,0]]);
|
||||
for(const canvas of projection.document.canvases.values())if(canvas.parent)entries.set(canvas.parent.entryElementId,canvas.id);
|
||||
for(const id of projection.transforms.keys())if(id!==projection.canvasId){const parent=projection.document.canvases.get(id)?.parent;if(parent&&depths.has(parent.canvasId))depths.set(id,depths.get(parent.canvasId)!+1);}
|
||||
return{nodes,edges,entries,depths,unsupported:projection.nodes.some(node=>!!node.data.__headlessFallback)||projection.edges.some(edge=>edge.type==='unsupported')};
|
||||
}
|
||||
/** Reuse existing projections for checked field edits and ordinary node insertions. */
|
||||
export function patchScene(base:Projection,layout:ReturnType<typeof indexScene>,next:SceneInputs,previous:SceneInputs,connection:SessionSnapshot['connectionGesture'],hint?:SceneChangeHint,queries?:DocumentQueries):Projection|undefined{
|
||||
const {document,selection,preview,registry,catalog,canvasId}=next;
|
||||
const {selection:oldSelection,preview:oldPreview,edgeId:oldEdgeId,path:oldPath}=previous;
|
||||
if(layout.unsupported||document.projectId!==base.document.projectId)return;
|
||||
const dirty=new Set<Id>(),created=new Set<Id>();
|
||||
if(document!==base.document){
|
||||
if(hint?.from===base.document&&hint.to===document){if(hint.topology)return;for(const id of hint.ids)dirty.add(id);}
|
||||
else{
|
||||
// Canonical replacement and missed publications carry no trustworthy write set.
|
||||
if(document.canvases.size!==base.document.canvases.size)return;
|
||||
const canvasOrder=base.document.canvases.keys();
|
||||
for(const[id,canvas]of document.canvases)if(id!==canvasOrder.next().value||!equal(canvas,base.document.canvases.get(id)))return;
|
||||
if(document.elements.size!==base.document.elements.size)return;
|
||||
const elementOrder=base.document.elements.keys();
|
||||
for(const[id,record]of document.elements){if(id!==elementOrder.next().value)return;if(record!==base.document.elements.get(id)&&!equal(record,base.document.elements.get(id)))dirty.add(id);}
|
||||
}
|
||||
}
|
||||
if(selection!==oldSelection){for(const id of oldSelection)if(!selection.has(id))dirty.add(id);for(const id of selection)if(!oldSelection.has(id))dirty.add(id);}
|
||||
if(preview!==oldPreview){for(const[id,g]of oldPreview??[])if(!equal(g,preview?.get(id)))dirty.add(id);for(const[id,g]of preview??[])if(!equal(g,oldPreview?.get(id)))dirty.add(id);}
|
||||
if(connection?.target.id!==oldEdgeId||connection?.path!==oldPath){if(oldEdgeId)dirty.add(oldEdgeId);if(connection)dirty.add(connection.target.id);}
|
||||
// Validate the whole dirty footprint before constructing or publishing any replacement.
|
||||
for(const id of dirty){const before=base.document.elements.get(id),after=document.elements.get(id);
|
||||
if(!before&&after?.structure==='node'){created.add(id);continue;}
|
||||
if(!before||!after||before.incarnation!==after.incarnation||before.structure!==after.structure||before.componentId!==after.componentId||before.dataVersion!==after.dataVersion||before.ownerCanvasId!==after.ownerCanvasId)return;
|
||||
if(before.structure==='connection'&&after.structure==='connection'&&(!equal(before.source,after.source)||!equal(before.target,after.target)))return;
|
||||
if(before.structure==='node'&&after.structure==='node'&&layout.entries.has(id)&&(!equal(before.geometry,after.geometry)||!equal(before.data.presentation,after.data.presentation)||!equal(oldPreview?.get(id),preview?.get(id))))return;
|
||||
}
|
||||
let nodes=base.nodes,edges=base.edges;const inserted=new Map<Id,Projection['nodes'][number]>();
|
||||
for(const id of dirty){const element=document.elements.get(id)!;
|
||||
if(element.structure==='node'){
|
||||
const index=layout.nodes.get(id);if(index===undefined&&!created.has(id))continue;
|
||||
if(!base.transforms.has(element.ownerCanvasId))continue;
|
||||
const transform=base.transforms.get(element.ownerCanvasId)!,depth=layout.depths.get(element.ownerCanvasId)!;
|
||||
const node=projectNode(element,canvasId,registry,selection,preview?.get(id),transform,depth,layout.entries.get(id),catalog);
|
||||
if(created.has(id)){if(node.data.__headlessFallback)return;inserted.set(id,node);}
|
||||
else{if(nodes===base.nodes)nodes=[...nodes];nodes[index!]=node;}
|
||||
}else{
|
||||
const index=layout.edges.get(id);if(index===undefined)continue;const prior=base.edges[index];
|
||||
const edge=projectEdge(element,prior.source,prior.target,registry,selection,base.transforms.get(element.ownerCanvasId)!,connection,catalog);
|
||||
if(edges===base.edges)edges=[...edges];edges[index]=edge;
|
||||
}
|
||||
}
|
||||
if(inserted.size){
|
||||
// Membership order is depth-first, not global insertion order. Reorder references
|
||||
// without reconstructing unaffected nodes, transforms or edge projections.
|
||||
const members=new Map<Id,Id[]>();if(!queries)for(const element of document.elements.values()){const list=members.get(element.ownerCanvasId)??[];list.push(element.id);members.set(element.ownerCanvasId,list);}
|
||||
const ordered:Projection['nodes']=[];let complete=true;
|
||||
const visit=(owner:Id)=>{for(const id of queries?queries.members(owner):members.get(owner)??[]){const element=document.elements.get(id)!;if(element.structure!=='node')continue;const projected=inserted.get(id)??nodes[layout.nodes.get(id)!];if(!projected){complete=false;return;}ordered.push(projected);const child=layout.entries.get(id);if(child&&base.transforms.has(child))visit(child);}};
|
||||
visit(canvasId);if(!complete)return;nodes=ordered;
|
||||
}
|
||||
return nodes===base.nodes&&edges===base.edges&&document===base.document?base:{...base,document,nodes,edges};
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import {fault} from '../canvas-core/values.ts';
|
||||
import type {Change,Document,Registry} from '../canvas-core/types.ts';
|
||||
import type {SessionSnapshot,ViewState} from '../canvas-core/gestures.ts';
|
||||
import type {DocumentQueries} from '../canvas-core/queries.ts';
|
||||
import {createGeometryProjectionCache} from './geometry-cache.ts';
|
||||
import {projectCanvas} from './projection.ts';
|
||||
import {indexScene,patchScene,type SceneChangeHint,type SceneInputs} from './scene-changes.ts';
|
||||
import type {RendererCatalog} from './renderers.ts';
|
||||
|
||||
/** One view's derived scene. Viewport/tool/mode and task status do not change scene inputs. */
|
||||
export function createSceneProjection(){
|
||||
let geometry=createGeometryProjectionCache();
|
||||
|
||||
let previous:SceneInputs|undefined,value:ReturnType<typeof projectCanvas>|undefined,layout:ReturnType<typeof indexScene>|undefined,pending:SceneChangeHint|undefined;
|
||||
return Object.freeze({
|
||||
advance(before:Document,after:Document,changes:readonly Change[]){
|
||||
if(before===after)return;
|
||||
if(!previous||previous.document===after){pending=undefined;return;}
|
||||
if(pending?.to!==before){pending=previous.document===before?{from:before,to:after,ids:new Set(),topology:false}:undefined;}
|
||||
if(!pending)return;pending.to=after;
|
||||
if(!changes.length){pending=undefined;return;}
|
||||
for(const change of changes){if(change.kind==='canvas'){pending.topology=true;break;}pending.ids.add(change.kind==='element'?change.id:change.target.id);if(pending.ids.size>16384){pending.topology=true;break;}}
|
||||
if(pending.topology)pending.ids.clear();
|
||||
},
|
||||
project(owner:object,document:Document,view:ViewState,sessions:SessionSnapshot,registry:Registry,catalog:RendererCatalog,queries?:DocumentQueries){
|
||||
if(queries&&queries.document!==document)throw fault('stale_query_snapshot');
|
||||
if(queries&&queries.registry!==registry)throw fault('query_registry_changed');
|
||||
const preview=sessions.gesture?.preview,connection=sessions.connectionGesture;
|
||||
const next:SceneInputs={owner,document,registry,catalog,canvasId:view.canvasId,selection:view.selection,preview:preview?.size?preview:undefined,edgeId:connection?.target.id,path:connection?.path};
|
||||
if(previous&&Object.keys(next).every(key=>Object.is(next[key as keyof SceneInputs],previous![key as keyof SceneInputs])))return value!;
|
||||
// Read the captured session, not a newer live gesture during an older React render.
|
||||
const compatible=previous&&value&&layout&&previous.owner===owner&&previous.registry===registry&&previous.catalog===catalog&&previous.canvasId===view.canvasId;
|
||||
const patched=compatible?patchScene(value!,layout!,next,previous!,connection,pending,queries):undefined;
|
||||
const result=patched??projectCanvas(document,view.canvasId,registry,view.selection,id=>next.preview?.get(id),connection,catalog,{queries,geometry,owner});
|
||||
if(!patched||result.nodes.length!==value!.nodes.length)layout=indexScene(result);
|
||||
previous=next;value=result;pending=undefined;return result;
|
||||
},
|
||||
clear(){previous=undefined;value=undefined;layout=undefined;pending=undefined;geometry=createGeometryProjectionCache();},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
/** One immutable source, one cached selection; no second document or operation state. */
|
||||
export type ExternalSource<S>={getSnapshot():S;subscribe(notify:()=>void):()=>void};
|
||||
export function createSelectorStore<S,T>(source:ExternalSource<S>,selector:(snapshot:S)=>T,isEqual:(a:T,b:T)=>boolean=Object.is){
|
||||
let initialized=false,snapshot:S,selection:T;
|
||||
function getSnapshot():T{
|
||||
const current=source.getSnapshot();if(initialized&&Object.is(snapshot,current))return selection;
|
||||
const next=selector(current),same=initialized&&isEqual(selection,next);
|
||||
snapshot=current;if(!same)selection=next;initialized=true;return selection;
|
||||
}
|
||||
return Object.freeze({getSnapshot,subscribe(notify:()=>void){let observed=getSnapshot();return source.subscribe(()=>{let next:T;try{next=getSnapshot();}catch{notify();return;}if(!Object.is(observed,next)){observed=next;notify();}});}});
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import type {SessionSnapshot} from '../canvas-core/gestures.ts';
|
||||
import type {ExternalSource} from './selectors.ts';
|
||||
import {createFrameQueue,type FrameClock} from './frame-queue.ts';
|
||||
/** One subscription owns one disposable frame queue. Reads always see current sessions. */
|
||||
export function createSessionFrameSource(source:ExternalSource<SessionSnapshot>,clock:FrameClock):ExternalSource<SessionSnapshot>{
|
||||
return Object.freeze({getSnapshot:source.getSnapshot,subscribe(notify:()=>void){
|
||||
let previous=source.getSnapshot();const queue=createFrameQueue<string>(clock,()=>notify());
|
||||
const stop=source.subscribe(()=>{const next=source.getSnapshot(),dirty=new Set<string>();
|
||||
for(const [id,view]of previous.views)if(next.views.get(id)!==view)dirty.add(`view:${id}`);
|
||||
for(const [id,view]of next.views)if(previous.views.get(id)!==view)dirty.add(`view:${id}`);
|
||||
if(previous.gesture!==next.gesture)for(const state of [previous.gesture,next.gesture])for(const target of state?.targets??[])dirty.add(`element:${target.id}`);
|
||||
if(previous.connectionGesture!==next.connectionGesture)for(const state of [previous.connectionGesture,next.connectionGesture])if(state)dirty.add(`element:${state.target.id}`);
|
||||
previous=next;queue.invalidate(dirty);
|
||||
});return()=>{stop();queue.dispose();};
|
||||
}});
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import type {Id,Target,Geometry} from '../canvas-core/types.ts';
|
||||
import type {SessionSnapshot,GestureState} from '../canvas-core/gestures.ts';
|
||||
import {equal} from '../canvas-core/values.ts';
|
||||
import type {ExternalSource} from './selectors.ts';
|
||||
|
||||
export type GestureSelection={readonly preview:Geometry|undefined;readonly active:boolean};
|
||||
/** Derived view/target subscriptions. Values retain no document or full per-hook session. */
|
||||
export function createSessionSources(source:ExternalSource<SessionSnapshot>){
|
||||
type Observer={update(snapshot:SessionSnapshot):boolean;notify():void};
|
||||
const modes=new Map<Id,Map<Id,Set<Observer>>>(),gestures=new Map<Id,Set<Observer>>();
|
||||
const targetIndexes=new WeakMap<readonly Target[],ReadonlyMap<Id,number>>();
|
||||
let stop:(()=>void)|undefined,previous:SessionSnapshot|undefined;
|
||||
function targets(gesture:GestureState){let index=targetIndexes.get(gesture.targets);if(!index){index=new Map(gesture.targets.map(target=>[target.id,target.incarnation]));targetIndexes.set(gesture.targets,index);}return index;}
|
||||
function changed(){
|
||||
if(!previous)return;const before=previous,next=source.getSnapshot();previous=next;
|
||||
const candidates=new Set<Observer>(),add=(items:Set<Observer>|undefined)=>{for(const item of items??[])candidates.add(item);};
|
||||
if(before.views!==next.views)for(const[viewId,byId]of modes){
|
||||
const old=before.views.get(viewId)?.modes,current=next.views.get(viewId)?.modes;if(old===current)continue;
|
||||
for(const id of new Set([...(old?.keys()??[]),...(current?.keys()??[])]))if(old?.get(id)!==current?.get(id))add(byId.get(id));
|
||||
}
|
||||
const old=before.gesture,current=next.gesture;
|
||||
if(old!==current){
|
||||
const ids=new Set<Id>();
|
||||
if(!old||!current||old.token!==current.token||old.viewId!==current.viewId||old.targets!==current.targets){for(const gesture of [old,current])for(const target of gesture?.targets??[])ids.add(target.id);}
|
||||
else if(old.preview!==current.preview){for(const[id,value]of old.preview)if(current.preview.get(id)!==value)ids.add(id);for(const[id,value]of current.preview)if(old.preview.get(id)!==value)ids.add(id);}
|
||||
for(const id of ids)add(gestures.get(id));
|
||||
}
|
||||
const notifications:Observer[]=[];for(const observer of candidates)if(observer.update(next))notifications.push(observer);
|
||||
let failed=false,error:unknown;for(const observer of notifications)try{observer.notify();}catch(caught){if(!failed){failed=true;error=caught;}}if(failed)throw error;
|
||||
}
|
||||
function channel<T>(read:(snapshot:SessionSnapshot)=>T,register:(observer:Observer)=>()=>void):ExternalSource<T>{
|
||||
let initialized=false,value:T;
|
||||
return Object.freeze({
|
||||
getSnapshot(){const next=read(source.getSnapshot());if(!initialized||!equal(value,next))value=next;initialized=true;return value;},
|
||||
subscribe(notify:()=>void){
|
||||
let observed=read(source.getSnapshot()),failed=false;
|
||||
const observer:Observer={notify,update(snapshot){try{const next=read(snapshot);if(!failed&&equal(observed,next))return false;observed=next;failed=false;}catch{failed=true;}return true;}};
|
||||
const remove=register(observer);
|
||||
if(!stop){previous=source.getSnapshot();try{stop=source.subscribe(changed);}catch(error){remove();previous=undefined;throw error;}}
|
||||
let active=true;return()=>{if(!active)return;active=false;remove();if(!modes.size&&!gestures.size){stop?.();stop=undefined;previous=undefined;}};
|
||||
},
|
||||
});
|
||||
}
|
||||
return Object.freeze({
|
||||
mode(viewId:Id,id:Id):ExternalSource<string|null>{return channel(snapshot=>snapshot.views.get(viewId)?.modes.get(id)??null,observer=>{
|
||||
let byId=modes.get(viewId);if(!byId){byId=new Map();modes.set(viewId,byId);}let bucket=byId.get(id);if(!bucket){bucket=new Set();byId.set(id,bucket);}bucket.add(observer);
|
||||
return()=>{bucket!.delete(observer);if(!bucket!.size)byId!.delete(id);if(!byId!.size)modes.delete(viewId);};
|
||||
});},
|
||||
gesture(viewId:Id,target:Target):ExternalSource<GestureSelection>{const{id,incarnation}=target;return channel(snapshot=>{
|
||||
const gesture=snapshot.gesture,matched=!!gesture&&targets(gesture).get(id)===incarnation;
|
||||
return{preview:matched?gesture!.preview.get(id):undefined,active:matched&&gesture!.viewId===viewId};
|
||||
},observer=>{let bucket=gestures.get(id);if(!bucket){bucket=new Set();gestures.set(id,bucket);}bucket.add(observer);return()=>{bucket!.delete(observer);if(!bucket!.size)gestures.delete(id);};});},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
import type {Rect} from '../canvas-core/geometry.ts';
|
||||
/** Positive-area native node visibility. Degenerate nodes are retained for measurement. */
|
||||
export function nativeRectVisible(node:Rect,viewport:Rect){if(node.width<=0||node.height<=0)return true;return Math.min(node.x+node.width,viewport.x+viewport.width)>Math.max(node.x,viewport.x)&&Math.min(node.y+node.height,viewport.y+viewport.height)>Math.max(node.y,viewport.y);}
|
||||
@@ -0,0 +1,51 @@
|
||||
import type { Document, Geometry, Id, Json, Point } from './types.ts';
|
||||
import { ancestors } from './document.ts';
|
||||
import { fault } from './values.ts';
|
||||
import { closeNumber, closeScale, unitScale } from './units.ts';
|
||||
import { connectionPathValid } from './features.ts';
|
||||
export type Transform = { x: number; y: number; scale: number; rotation: number };
|
||||
export const sameTransform=(a:Transform,b:Transform)=>closeNumber(a.x,b.x)&&closeNumber(a.y,b.y)&&closeScale(a.scale,b.scale)&&Math.abs(((a.rotation-b.rotation+540)%360)-180)<1e-9;
|
||||
const origin: Transform = { x: 0, y: 0, scale: 1, rotation: 0 };
|
||||
const rotate = (point: Point, angle: number): Point => { const a = angle * Math.PI / 180; return { x: point.x * Math.cos(a) - point.y * Math.sin(a), y: point.x * Math.sin(a) + point.y * Math.cos(a) }; };
|
||||
export function toWorld(transform: Transform, point: Point): Point { const value = rotate(point, transform.rotation); return { x: transform.x + value.x * transform.scale, y: transform.y + value.y * transform.scale }; }
|
||||
export function toLocal(transform: Transform, point: Point): Point { return rotate({ x: (point.x - transform.x) / transform.scale, y: (point.y - transform.y) / transform.scale }, -transform.rotation); }
|
||||
const object = (value: Json | undefined) => value && typeof value === 'object' && !Array.isArray(value) ? value : {};
|
||||
export function transformPath(path: Json, transform: Transform, inverse = false): Json {
|
||||
const raw = object(path);
|
||||
if (raw.type !== 'bezier-chain' || !Array.isArray(raw.knots)) return path;
|
||||
const point = (input: Json | undefined, vector = false): Json | undefined => {
|
||||
const p = object(input); if (typeof p.x !== 'number' || typeof p.y !== 'number') return input;
|
||||
const t = vector ? { ...transform, x: 0, y: 0 } : transform;
|
||||
return { ...p, ...(inverse ? toLocal(t, { x: p.x, y: p.y }) : toWorld(t, { x: p.x, y: p.y })) };
|
||||
};
|
||||
return { ...raw, ...(raw.startControl ? { startControl: point(raw.startControl, true)! } : {}), ...(raw.endControl ? { endControl: point(raw.endControl, true)! } : {}), knots: raw.knots.map(knot => {
|
||||
const k = object(knot); return { ...k, position: point(k.position)!, in: point(k.in, true)!, out: point(k.out, true)! };
|
||||
}) };
|
||||
}
|
||||
|
||||
/** Format-1 child content origin: the same inset, scale and rotation used by renderer projection. */
|
||||
export function childCanvasTransform(parent:Transform,geometry:Geometry,presentation:Json|undefined):Transform {
|
||||
const data=object(presentation),units=unitScale(presentation),scale=(typeof data.contentScale==='number'&&data.contentScale>0?data.contentScale:1)*units;
|
||||
const offset=rotate({x:24*units-geometry.width/2,y:36*units-geometry.height/2},geometry.rotation);
|
||||
const start=toWorld(parent,{x:geometry.x+geometry.width/2+offset.x,y:geometry.y+geometry.height/2+offset.y});
|
||||
return{...start,scale:parent.scale*scale,rotation:((parent.rotation+geometry.rotation)%360+360)%360};
|
||||
}
|
||||
export function canvasTransform(document:Document,canvasId:Id):Transform {
|
||||
let result:Transform={...origin};
|
||||
for(const id of ancestors(document,canvasId).reverse()){
|
||||
const parent=document.canvases.get(id)!.parent;if(!parent)continue;
|
||||
const entry=document.elements.get(parent.entryElementId);if(!entry||entry.structure!=='node')throw fault('invalid_canvas_entry',[id]);
|
||||
result=childCanvasTransform(result,entry.geometry,entry.data.presentation);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
export function convertGeometry(geometry:Geometry,from:Transform,to:Transform):Geometry {
|
||||
if(sameTransform(from,to))return geometry;
|
||||
const center=toLocal(to,toWorld(from,{x:geometry.x+geometry.width/2,y:geometry.y+geometry.height/2})),ratio=from.scale/to.scale,width=geometry.width*ratio,height=geometry.height*ratio;
|
||||
return{x:center.x-width/2,y:center.y-height/2,width,height,rotation:((geometry.rotation+from.rotation-to.rotation)%360+360)%360};
|
||||
}
|
||||
export function convertPath(path:Json,from:Transform,to:Transform):Json {
|
||||
if(path===null||sameTransform(from,to))return path;
|
||||
if(!connectionPathValid(path))throw fault('unsupported_coordinate_path');
|
||||
return transformPath(transformPath(path,from),to,true);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { Json } from './types.ts';
|
||||
import { fault } from './values.ts';
|
||||
|
||||
/** Own JSON fields only. A * segment enumerates an array, never object keys. */
|
||||
export function* copySlots(data: Json, path: readonly string[]): Generator<{parent: Record<string, Json>; key: string}> {
|
||||
const work: {value: Json; depth: number}[] = [{value: data, depth: 0}];
|
||||
while (work.length) {
|
||||
const {value, depth} = work.pop()!, key = path[depth];
|
||||
if (key === '*') {
|
||||
if (!Array.isArray(value)) throw fault('invalid_copy_reference');
|
||||
if (depth === path.length - 1) { for (let i = 0; i < value.length; i++) yield {parent: value as unknown as Record<string, Json>, key: String(i)}; }
|
||||
else for (let i = value.length - 1; i >= 0; i--) work.push({value: value[i], depth: depth + 1});
|
||||
} else if (value && typeof value === 'object' && Object.hasOwn(value, key)) {
|
||||
if (depth === path.length - 1) yield {parent: value as Record<string, Json>, key};
|
||||
else work.push({value: (value as Record<string, Json>)[key], depth: depth + 1});
|
||||
}
|
||||
}
|
||||
}
|
||||
export function* copyReferenceIds(data: Json, ref: {path: readonly string[]; many?: boolean}): Generator<string> {
|
||||
for (const {parent, key} of copySlots(data, ref.path)) {
|
||||
const value = parent[key]; if (value === null) continue;
|
||||
if (ref.many && !Array.isArray(value)) throw fault('invalid_copy_reference');
|
||||
for (const id of ref.many ? value as Json[] : [value]) {
|
||||
if (typeof id !== 'string' || !id) throw fault('invalid_copy_reference');
|
||||
yield id;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import {detachElementData} from './element-data.ts';
|
||||
import type {CanvasRecord,Change,Document,ElementRecord,Id} from './types.ts';
|
||||
import {applyChanges} from './document.ts';
|
||||
import {clone,fault,freeze,readMap,validJson} from './values.ts';
|
||||
|
||||
/** Transaction-local append views. Each retained view has fixed membership;
|
||||
* at most 64 additions are copied before folding into a new read-only base. */
|
||||
const overlays=new WeakMap<object,{base:ReadonlyMap<Id,unknown>;added:ReadonlyMap<Id,unknown>}>(),drafts=new WeakSet<Document>();
|
||||
function append<T>(input:ReadonlyMap<Id,T>,id:Id,value:T):ReadonlyMap<Id,T>{
|
||||
if(input.has(id))throw fault('duplicate_identity',[id]);
|
||||
let previous=overlays.get(input) as {base:ReadonlyMap<Id,T>;added:ReadonlyMap<Id,T>}|undefined;
|
||||
if(previous&&previous.added.size>=64)previous=undefined;
|
||||
const base=previous?.base??(overlays.has(input)?readMap(input):input),added=new Map(previous?.added);
|
||||
added.set(id,value);
|
||||
const entries=function*():Generator<[Id,T],undefined,unknown>{yield* base;yield* added;return undefined;};
|
||||
const view:ReadonlyMap<Id,T>=Object.freeze({size:base.size+added.size,get:(key:Id)=>added.has(key)?added.get(key):base.get(key),has:(key:Id)=>added.has(key)||base.has(key),entries,
|
||||
*keys():Generator<Id,undefined,unknown>{for(const [key]of entries())yield key;return undefined;},*values():Generator<T,undefined,unknown>{for(const [,item]of entries())yield item;return undefined;},[Symbol.iterator]:entries,
|
||||
forEach(callback:(value:T,key:Id,map:ReadonlyMap<Id,T>)=>void,thisArg?:unknown){for(const[key,item]of entries())callback.call(thisArg,item,key,view);},
|
||||
});
|
||||
overlays.set(view,{base,added});return view;
|
||||
}
|
||||
/** Internal editor path: input tables already have fixed membership. */
|
||||
export function stageCreation(document:Document,change:Extract<Change,{kind:'canvas'|'element'}>):Document{
|
||||
if(change.before!==null||!change.after)throw fault('invalid_creation_draft');
|
||||
if(!validJson(change.after))throw fault(change.kind==='canvas'?'invalid_canvas':'invalid_element',[change.id]);
|
||||
if(change.after.id!==change.id)throw fault('identity_mismatch',[change.id]);
|
||||
const value=change.kind==='element'?detachElementData(change.after as ElementRecord):freeze(clone(change.after));
|
||||
const next=Object.freeze({...document,...(change.kind==='canvas'?{canvases:append(document.canvases,change.id,value as CanvasRecord)}:{elements:append(document.elements,change.id,value as ElementRecord)})});drafts.add(next);return next;
|
||||
}
|
||||
/** Only published documents use the core's owned table envelopes. */
|
||||
export const materializeCreations=(document:Document)=>drafts.has(document)?applyChanges(document,[]):document;
|
||||
@@ -0,0 +1,100 @@
|
||||
import type { Change, CreationSizeEffect, Document, ElementRecord, Geometry, HistoryState, Id, Registry, Target } from './types.ts';
|
||||
import { applyChanges, invert, validateDocument } from './document.ts';
|
||||
import { closeNumber, unitScale } from './units.ts';
|
||||
import { equal, fault, freeze, readMap, validJson } from './values.ts';
|
||||
|
||||
export function validateCreationSizeEffect(value: unknown): asserts value is CreationSizeEffect {
|
||||
const effect = value as CreationSizeEffect | undefined;
|
||||
if (!effect || effect.version !== 1 || effect.kind !== 'creation-size') throw fault('history_effect_interpreter_unavailable');
|
||||
if (!validJson(effect) || typeof effect.entryId !== 'string' || !effect.entryId || typeof effect.target?.id !== 'string' || !effect.target.id
|
||||
|| !Number.isSafeInteger(effect.target.incarnation) || effect.target.incarnation < 1
|
||||
|| !effect.size || ![effect.size.width, effect.size.height].every(value => typeof value === 'number' && Number.isFinite(value) && value > 0)) throw fault('invalid_creation_size_effect');
|
||||
}
|
||||
|
||||
/** A pure proposal, not a publication API. The coordinator must still validate a
|
||||
* durable sizing grant and record a recoverable history effect before adopting it.
|
||||
* Already-issued operation bodies are deliberately outside this function.
|
||||
*/
|
||||
export function planCreationSize(document: Document, history: HistoryState, registry: Registry,
|
||||
entryId: Id, target: Target, size: { readonly width: number; readonly height: number }) {
|
||||
const fail = (code: string): never => { throw fault(code, [target.id]); };
|
||||
if (![size.width, size.height].every(value => Number.isFinite(value) && value > 0)) fail('invalid_creation_size');
|
||||
const index = history.undo.indexOf(entryId), entry = history.entries.get(entryId);
|
||||
const current = document.elements.get(target.id);
|
||||
if (index < 0 || !entry || current?.incarnation !== target.incarnation || current.structure !== 'node') fail('creation_size_target_changed');
|
||||
const creations = entry!.changes.filter(change => change.kind === 'element' && change.id === target.id && !change.before && change.after?.incarnation === target.incarnation);
|
||||
if (creations.length !== 1 || creations[0].kind !== 'element' || creations[0].after?.structure !== 'node') fail('creation_size_entry_mismatch');
|
||||
const created = creations[0].after as ElementRecord & { structure: 'node' };
|
||||
if (registry.resolve(current!) !== 'known' || !registry.supports(current!, 'geometry.resize')) fail('unsupported_capability');
|
||||
const units = unitScale(created.data.presentation);
|
||||
const baseline = { width: created.geometry.width / units, height: created.geometry.height / units };
|
||||
const ratio = { width: size.width / baseline.width, height: size.height / baseline.height };
|
||||
const matches = (element: ElementRecord | undefined) => element?.incarnation === target.incarnation && element.structure === 'node';
|
||||
const unchanged = (element: ElementRecord | undefined) => matches(element) && element!.structure === 'node'
|
||||
&& closeNumber(element!.geometry.width / unitScale(element!.data.presentation), baseline.width)
|
||||
&& closeNumber(element!.geometry.height / unitScale(element!.data.presentation), baseline.height);
|
||||
if (!unchanged(current)) fail('creation_size_user_resized');
|
||||
const scaled = (geometry: Geometry): Geometry => freeze({ ...geometry, width: geometry.width * ratio.width, height: geometry.height * ratio.height });
|
||||
// Rewind on private roots. applyChanges intentionally preserves historical
|
||||
// incarnations here; this is inspection, not a user-visible delete/restore.
|
||||
let original = document;
|
||||
const applied = history.undo.slice(index);
|
||||
for (const id of [...applied].reverse()) original = applyChanges(original, invert(history.entries.get(id)!.changes));
|
||||
let amended = original, inheriting = false;
|
||||
const entries = new Map(history.entries);
|
||||
const timeline = [...applied, ...[...history.redo].reverse()];
|
||||
for (let cursor = 0; cursor < timeline.length; cursor++) {
|
||||
const id = timeline[cursor], source = entries.get(id)!;
|
||||
const end = applyChanges(original, source.changes), isApplied = cursor < applied.length;
|
||||
if (isApplied && !unchanged(end.elements.get(target.id))) fail('creation_size_user_resized');
|
||||
// A future explicit resize remains a resize to the user's chosen dimensions.
|
||||
// Only its before value inherits the asynchronous default correction.
|
||||
const futureResize = !isApplied && inheriting && matches(end.elements.get(target.id)) && !unchanged(end.elements.get(target.id));
|
||||
let resizeConsumed = false;
|
||||
const preserveFutureResize = () => {
|
||||
if (!futureResize || resizeConsumed) return;
|
||||
const value = end.elements.get(target.id) as ElementRecord & { structure: 'node' };
|
||||
if (!closeNumber(value.geometry.width / unitScale(value.data.presentation), baseline.width)) ratio.width = 1;
|
||||
if (!closeNumber(value.geometry.height / unitScale(value.data.presentation), baseline.height)) ratio.height = 1;
|
||||
resizeConsumed = true;
|
||||
};
|
||||
const changes: Change[] = [];
|
||||
for (const change of source.changes) {
|
||||
let next = change;
|
||||
if (change.kind === 'element' && change.id === target.id) {
|
||||
const before = amended.elements.get(target.id) ?? null;
|
||||
const birth = id === entryId && change === creations[0];
|
||||
if (birth) inheriting = true;
|
||||
else preserveFutureResize();
|
||||
const after = change.after && inheriting && change.after.incarnation === target.incarnation && change.after.structure === 'node'
|
||||
? freeze({ ...change.after, geometry: scaled(change.after.geometry) }) : change.after;
|
||||
next = freeze({ ...change, before, after });
|
||||
if (!change.after || change.after.incarnation !== target.incarnation) inheriting = false;
|
||||
} else if (change.kind === 'field' && change.target.id === target.id && change.target.incarnation === target.incarnation && change.field === 'geometry') {
|
||||
const before = amended.elements.get(target.id);
|
||||
if (before?.structure !== 'node') fail('creation_size_target_changed');
|
||||
preserveFutureResize();
|
||||
next = freeze({ ...change, before: (before as ElementRecord & { structure: 'node' }).geometry, after: inheriting ? scaled(change.after as Geometry) : change.after });
|
||||
}
|
||||
amended = applyChanges(amended, [next]);
|
||||
changes.push(next);
|
||||
}
|
||||
original = end;
|
||||
if (!equal(changes, source.changes)) entries.set(id, freeze({ ...source, changes }));
|
||||
}
|
||||
// Publishable proposal changes only the currently live geometry. Replaying
|
||||
// redo above was validation; it must never become the working document.
|
||||
const after = freeze({ ...(current as ElementRecord & { structure: 'node' }).geometry,
|
||||
width: (current as ElementRecord & { structure: 'node' }).geometry.width * size.width / baseline.width,
|
||||
height: (current as ElementRecord & { structure: 'node' }).geometry.height * size.height / baseline.height });
|
||||
const changes: readonly Change[] = equal(after, (current as ElementRecord & { structure: 'node' }).geometry) ? [] : freeze([
|
||||
{ kind: 'field', target, field: 'geometry', before: (current as ElementRecord & { structure: 'node' }).geometry, after },
|
||||
]);
|
||||
const nextDocument = applyChanges(document, changes);
|
||||
validateDocument(nextDocument, registry);
|
||||
const handler = registry.handlers.get(current!.componentId)?.get('geometry.resize');
|
||||
const proposed = handler!({ element: current!, input: freeze({ patch: { width: after.width, height: after.height } }) });
|
||||
for (const change of proposed) if (change.target.id !== target.id || change.target.incarnation !== target.incarnation || change.field !== 'geometry') fail('invalid_handler_change');
|
||||
if (!equal(applyChanges(document, proposed).elements.get(target.id), nextDocument.elements.get(target.id))) fail('creation_size_constraint');
|
||||
return Object.freeze({ document: nextDocument, changes, history: Object.freeze({ undo: history.undo, redo: history.redo, entries: readMap(entries) }) });
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import type {Point} from './types.ts';
|
||||
import type {Cubic} from './geometry.ts';
|
||||
const distance=(p:Point,a:Point,b:Point)=>{const magnitude=Math.max(1,Math.abs(p.x),Math.abs(p.y),Math.abs(a.x),Math.abs(a.y),Math.abs(b.x),Math.abs(b.y)),x=b.x/magnitude-a.x/magnitude,y=b.y/magnitude-a.y/magnitude,dx=p.x/magnitude-a.x/magnitude,dy=p.y/magnitude-a.y/magnitude,length=Math.hypot(x,y),ux=length?x/length:0,uy=length?y/length:0,t=Math.max(0,Math.min(length,dx*ux+dy*uy));return Math.hypot(dx-t*ux,dy-t*uy)*magnitude;};
|
||||
const mid=(a:Point,b:Point)=>({x:a.x/2+b.x/2,y:a.y/2+b.y/2});
|
||||
function split([a,b,c,d]:Cubic):[Cubic,Cubic]{const ab=mid(a,b),bc=mid(b,c),cd=mid(c,d),abc=mid(ab,bc),bcd=mid(bc,cd),center=mid(abc,bcd);return[[a,ab,abc,center],[center,bcd,cd,d]];}
|
||||
/** Hull rejection + bounded de Casteljau refinement; ambiguous boundaries are explicit, never a proven miss. */
|
||||
export function hitCubic(curve:Cubic,point:Point,radius:number,options:{error:number;maxDepth:number;maxSteps:number}){
|
||||
const stack:{curve:Cubic;depth:number}[]=[{curve,depth:0}];let steps=0,possible=false,estimate=Infinity,error=0;
|
||||
while(stack.length&&steps<options.maxSteps){const item=stack.pop()!;steps++;const xs=item.curve.map(p=>p.x),ys=item.curve.map(p=>p.y),dx=Math.max(0,Math.min(...xs)-point.x,point.x-Math.max(...xs)),dy=Math.max(0,Math.min(...ys)-point.y,point.y-Math.max(...ys));if(Math.hypot(dx,dy)>radius)continue;
|
||||
const[a,b,c,d]=item.curve,roundoff=Number.EPSILON*64*Math.max(1,Math.abs(point.x),Math.abs(point.y),...xs.map(Math.abs),...ys.map(Math.abs)),flat=Math.max(distance(b,a,d),distance(c,a,d))+roundoff,near=distance(point,a,d);if(!Number.isFinite(flat)||!Number.isFinite(near))return{hit:true,certainty:'possible' as const,steps,distance:Infinity,error:Infinity,budget:true};if(near-flat>radius)continue;if(near+flat<=radius)return{hit:true,certainty:'confirmed' as const,steps,distance:near,error:flat,budget:false};
|
||||
if(flat<=options.error||item.depth>=options.maxDepth){possible=true;if(near<estimate){estimate=near;error=flat;}continue;}const[left,right]=split(item.curve);stack.push({curve:right,depth:item.depth+1},{curve:left,depth:item.depth+1});
|
||||
}
|
||||
return{hit:possible||stack.length>0,certainty:'possible' as const,steps,distance:estimate,error,budget:stack.length>0};
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
// Private shape provenance: values may still be mutable and require capture.
|
||||
// Always copy; external Object.freeze is never accepted as ownership evidence.
|
||||
const owned = new WeakSet<object>();
|
||||
export function dataArray<T>(values: Iterable<T>): readonly T[] {
|
||||
const result = Object.freeze(Array.from(values)); owned.add(result); return result;
|
||||
}
|
||||
export function dataRecord<T extends object>(value: T): Readonly<T> {
|
||||
const result = Object.freeze({...value}); owned.add(result); return result;
|
||||
}
|
||||
export const isDataContainer = (value: object) => owned.has(value);
|
||||
@@ -0,0 +1,46 @@
|
||||
import type {Change,Command,Document,Id,Json,Registry,Target} from './types.ts';
|
||||
import {applyChanges,targetElement} from './document.ts';
|
||||
import {canvasTransform,convertGeometry} from './coordinates.ts';
|
||||
import {unitScale,withUnitScale} from './units.ts';
|
||||
import {copySlots} from './copy-paths.ts';
|
||||
import {repairConnectionOwners} from './structure.ts';
|
||||
import {clone,equal,fault,freeze} from './values.ts';
|
||||
|
||||
/** Remove an owning canvas, retaining its entry and promoting only direct members. */
|
||||
export function planDissolveCanvas(document:Document,registry:Registry,canvasId:Id,entryTarget:Target):{readonly changes:readonly Change[];readonly commands:readonly Command[]}{
|
||||
const canvas=document.canvases.get(canvasId);if(!canvas)throw fault('canvas_not_loaded',[canvasId]);
|
||||
if(!canvas.parent)throw fault('cannot_dissolve_root',[canvasId]);
|
||||
if(canvas.parent.entryElementId!==entryTarget.id)throw fault('canvas_entry_changed',[canvasId]);
|
||||
targetElement(document,entryTarget);
|
||||
const parent=canvas.parent.canvasId,from=canvasTransform(document,canvasId),to=canvasTransform(document,parent),changes:Change[]=[],commands:Command[]=[];
|
||||
for(const element of document.elements.values())if(element.ownerCanvasId===canvasId&&element.structure==='node'){
|
||||
if(!registry.supports(element,'geometry.move'))throw fault('unsupported_capability',[element.id,'geometry.move']);
|
||||
const target={id:element.id,incarnation:element.incarnation},geometry=convertGeometry(element.geometry,from,to),presentation=withUnitScale(element.data.presentation,unitScale(element.data.presentation)*from.scale/to.scale);
|
||||
commands.push({type:'element.move',target,canvasId:parent});
|
||||
changes.push({kind:'field',target,field:'ownerCanvasId',before:canvasId,after:parent});
|
||||
if(!equal(geometry,element.geometry))changes.push({kind:'field',target,field:'geometry',before:element.geometry,after:geometry});
|
||||
if(!equal(presentation,element.data.presentation))changes.push({kind:'field',target,field:'data.presentation',before:element.data.presentation??null,beforePresent:Object.hasOwn(element.data,'presentation'),after:presentation??null,afterPresent:presentation!==undefined});
|
||||
}
|
||||
for(const child of document.canvases.values())if(child.parent?.canvasId===canvasId)changes.push({kind:'canvas',id:child.id,before:child,after:{...child,parent:{...child.parent,canvasId:parent}}});
|
||||
changes.push({kind:'canvas',id:canvasId,before:canvas,after:null});
|
||||
const promoted=applyChanges(document,changes);changes.push(...repairConnectionOwners(document,promoted));
|
||||
// Only declared canvas references have meaning here. Opaque payloads are retained.
|
||||
for(const element of promoted.elements.values()){
|
||||
if(registry.resolve(element)!=='known')continue;
|
||||
const references=registry.components.get(element.componentId)?.copy?.references.filter(reference=>reference.target==='canvas')??[];
|
||||
if(!references.length)continue;
|
||||
const data=clone(element.data) as Record<string,Json>;
|
||||
for(const reference of references)for(const slot of copySlots(data,reference.path)){
|
||||
const value=slot.parent[slot.key];if(value===null)continue;
|
||||
if(reference.many&&!Array.isArray(value))throw fault('invalid_copy_reference',[element.id]);
|
||||
const map=(value:Json):Json=>{if(typeof value!=='string'||!value)throw fault('invalid_copy_reference',[element.id]);return value===canvasId?parent:value;};
|
||||
slot.parent[slot.key]=reference.many?(value as Json[]).map(map):map(value);
|
||||
}
|
||||
for(const key of Object.keys(data))if(!equal(data[key],element.data[key])){
|
||||
if(!registry.supports(element,'content.edit'))throw fault('unsupported_capability',[element.id,'content.edit']);
|
||||
changes.push({kind:'field',target:{id:element.id,incarnation:element.incarnation},field:`data.${key}`,before:element.data[key],after:data[key]});
|
||||
commands.push({type:'element.data',target:{id:element.id,incarnation:element.incarnation},patch:{[key]:data[key]}});
|
||||
}
|
||||
}
|
||||
return freeze({changes,commands});
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
import {detachElementData,hasOwnedElementData,componentDataValid} from './element-data.ts';
|
||||
import type { CanvasRecord, Change, Document, ElementRecord, Geometry, Id, Registry, Target } from './types.ts';
|
||||
import { clone, equal, fault, freeze, readMap, validJson } from './values.ts';
|
||||
|
||||
// This owns only the envelope and fixed table membership, not validation or
|
||||
// immutability of values inherited by applyChanges from an external document.
|
||||
const factoryDocuments=new WeakSet<object>();
|
||||
function documentEnvelope(document:Document):Document{const value=Object.freeze(document);factoryDocuments.add(value);return value;}
|
||||
export const isFactoryDocument=(document:Document)=>factoryDocuments.has(document);
|
||||
|
||||
export function createDocument(projectId: Id, canvases: readonly CanvasRecord[], elements: readonly ElementRecord[] = [], tombstones: ReadonlyMap<Id, number> = new Map()): Document {
|
||||
for (const element of elements) { const id = element.id; if (!staticElementProofs.has(element)&&!validJson(element)) throw fault('invalid_element', [id]); }
|
||||
for (const canvas of canvases) if (!validJson(canvas)) throw fault('invalid_canvas');
|
||||
if (!projectId || new Set(canvases.map(item => item.id)).size !== canvases.length || new Set(elements.map(item => item.id)).size !== elements.length) throw fault('duplicate_identity');
|
||||
return documentEnvelope({ projectId, canvases: readMap(new Map(canvases.map(item => [item.id, freeze(clone(item))]))), elements: readMap(new Map(elements.map(item => [item.id, detachElementData(item)]))), tombstones: readMap(tombstones) });
|
||||
}
|
||||
export function geometryValid(value: Geometry): boolean {
|
||||
return !!value && typeof value === 'object' && [value.x, value.y, value.width, value.height, value.rotation].every(Number.isFinite) && value.width > 0 && value.height > 0 && value.rotation >= 0 && value.rotation < 360;
|
||||
}
|
||||
export function targetElement(document: Document, target: Target): ElementRecord {
|
||||
const element = document.elements.get(target.id);
|
||||
if (!element) throw fault('element_missing', [target.id]);
|
||||
if (element.incarnation !== target.incarnation) throw fault('stale_incarnation', [target.id]);
|
||||
return element;
|
||||
}
|
||||
export function ancestors(document: Document, canvasId: Id): Id[] {
|
||||
const result: Id[] = [], visited = new Set<Id>();
|
||||
let next: Id | undefined = canvasId;
|
||||
while (next) {
|
||||
if (visited.has(next)) throw fault('canvas_cycle', [next]);
|
||||
visited.add(next); result.push(next);
|
||||
const canvas: CanvasRecord | undefined = document.canvases.get(next);
|
||||
if (!canvas) throw fault('canvas_not_loaded', [next]);
|
||||
next = canvas.parent?.canvasId;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
export function commonCanvas(document: Document, left: Id, right: Id): Id {
|
||||
const parents = new Set(ancestors(document, left));
|
||||
const result = ancestors(document, right).find(id => parents.has(id));
|
||||
if (!result) throw fault('no_common_canvas', [left, right]);
|
||||
return result;
|
||||
}
|
||||
// Only intrinsic shape is reusable; document- and registry-dependent checks stay live.
|
||||
const staticElementProofs=new WeakSet<object>();
|
||||
/** Validate one record before its graph dependencies have arrived. This does not
|
||||
* prove owner membership, endpoint existence or connection common ownership. */
|
||||
export function validateElementRecord(element: ElementRecord, registry: Registry, tombstones: ReadonlyMap<Id,number> = new Map(), componentData = true): void {
|
||||
const proven=!!element&&staticElementProofs.has(element);
|
||||
if (!proven&&(!element || !validJson(element) || typeof element.id !== 'string' || !element.id || typeof element.ownerCanvasId !== 'string' || !element.ownerCanvasId || typeof element.componentId !== 'string' || !element.componentId || !['node','connection'].includes(element.structure) || !Number.isSafeInteger(element.incarnation) || element.incarnation < 1 || !Number.isSafeInteger(element.dataVersion) || element.dataVersion < 1 || !element.data || typeof element.data !== 'object' || Array.isArray(element.data) || !validJson(element.data))) throw fault('invalid_element', [element?.id]);
|
||||
const dead=tombstones.get(element.id);if(dead!==undefined&&element.incarnation<=dead)throw fault('stale_incarnation',[element.id]);
|
||||
const definition=registry.components.get(element.componentId);
|
||||
if(componentData&®istry.resolve(element)==='known'&&(!definition||definition.structure!==element.structure||!componentDataValid(element,definition)))throw fault('invalid_component_data',[element.id]);
|
||||
if(proven)return;
|
||||
if(element.structure==='node'){if(!geometryValid(element.geometry))throw fault('invalid_geometry',[element.id]);}
|
||||
else if(!element.source||!element.target||typeof element.source.elementId!=='string'||!element.source.elementId||typeof element.target.elementId!=='string'||!element.target.elementId||[element.source,element.target].some(endpoint=>endpoint.portId!==undefined&&(typeof endpoint.portId!=='string'||!endpoint.portId))||!validJson(element.path))throw fault('invalid_endpoint',[element.id]);
|
||||
if(hasOwnedElementData(element))staticElementProofs.add(element);
|
||||
}
|
||||
|
||||
function validateTombstone(id:Id,incarnation:number){
|
||||
if(!id||!Number.isSafeInteger(incarnation)||incarnation<1)throw fault('invalid_tombstone',[id]);
|
||||
}
|
||||
function validateCanvasInDocument(document:Document,canvas:CanvasRecord,entries:Set<Id>):number{
|
||||
if(typeof canvas.id!=='string'||!canvas.id||!Object.hasOwn(canvas,'parent')||canvas.parent!==null&&(!canvas.parent||typeof canvas.parent.canvasId!=='string'||typeof canvas.parent.entryElementId!=='string'))throw fault('invalid_canvas');
|
||||
ancestors(document,canvas.id);
|
||||
if(!canvas.parent)return 1;
|
||||
const entry=document.elements.get(canvas.parent.entryElementId);
|
||||
if(!entry||entry.structure!=='node'||entry.ownerCanvasId!==canvas.parent.canvasId||entries.has(entry.id))throw fault('invalid_canvas_entry',[canvas.id]);
|
||||
entries.add(entry.id);return 0;
|
||||
}
|
||||
function validateElementInDocument(document:Document,element:ElementRecord,registry:Registry,componentData:boolean){
|
||||
validateElementRecord(element,registry,document.tombstones,componentData);
|
||||
if(!document.canvases.has(element.ownerCanvasId))throw fault('invalid_element',[element.id]);
|
||||
if(element.structure==='connection'){
|
||||
const source=document.elements.get(element.source.elementId),target=document.elements.get(element.target.elementId);
|
||||
if(!source||!target||source.structure!=='node'||target.structure!=='node'||!validJson(element.path))throw fault('invalid_endpoint',[element.id]);
|
||||
if(element.ownerCanvasId!==commonCanvas(document,source.ownerCanvasId,target.ownerCanvasId))throw fault('invalid_connection_owner',[element.id]);
|
||||
}
|
||||
}
|
||||
/** Cooperative and synchronous paths use the same per-record checks and order. */
|
||||
function* validateDocumentChecks(document:Document,registry:Registry,componentData:boolean):Generator<void,void>{
|
||||
if(!document.canvases.size)throw fault('missing_root');
|
||||
for(const[id,incarnation]of document.tombstones){yield;validateTombstone(id,incarnation);}
|
||||
const entries=new Set<Id>();let roots=0;
|
||||
for(const canvas of document.canvases.values()){yield;roots+=validateCanvasInDocument(document,canvas,entries);}
|
||||
if(roots!==1)throw fault('invalid_roots');
|
||||
for(const element of document.elements.values()){yield;validateElementInDocument(document,element,registry,componentData);}
|
||||
}
|
||||
/** Structural indexes contain no component payload; use only for source planning, never editor publication. */
|
||||
export function* validateDocumentTopologySteps(document:Document,registry:Registry):Generator<void,void>{yield* validateDocumentChecks(document,registry,false);}
|
||||
export function* validateDocumentSteps(document:Document,registry:Registry):Generator<void,void>{yield* validateDocumentChecks(document,registry,true);}
|
||||
export function validateDocument(document:Document,registry:Registry):void{
|
||||
if(!document.canvases.size)throw fault('missing_root');
|
||||
for(const[id,incarnation]of document.tombstones)validateTombstone(id,incarnation);
|
||||
const entries=new Set<Id>();let roots=0;
|
||||
for(const canvas of document.canvases.values())roots+=validateCanvasInDocument(document,canvas,entries);
|
||||
if(roots!==1)throw fault('invalid_roots');
|
||||
for(const element of document.elements.values())validateElementInDocument(document,element,registry,true);
|
||||
}
|
||||
export type ReplayChangeBatch = {readonly changes:readonly Change[];readonly canvasIds?:readonly Id[]};
|
||||
export function applyChanges(document: Document, changes: readonly Change[], conditional = true): Document {
|
||||
return applyChangeBatches(document,[{changes}],conditional,false);
|
||||
}
|
||||
/** Ordered conditional replay against one private draft; no intermediate document escapes. */
|
||||
export function replayChanges(document:Document,batches:Iterable<ReplayChangeBatch>):Document {
|
||||
return applyChangeBatches(document,batches,true,true);
|
||||
}
|
||||
function applyChangeBatches(document:Document,batches:Iterable<ReplayChangeBatch>,conditional:boolean,replay:boolean):Document {
|
||||
const elements = new Map(document.elements), canvases = new Map(document.canvases), tombstones = new Map(document.tombstones);
|
||||
for (const batch of batches) for (const change of batch.changes) {
|
||||
if(replay){
|
||||
// Check scope even when the desired field value is already present.
|
||||
if(change.kind==='field'){const owner=elements.get(change.target.id)?.ownerCanvasId;if(owner&&batch.canvasIds?.length&&!batch.canvasIds.includes(owner))throw fault('operation_context_changed',[change.target.id]);}
|
||||
if(changeSatisfied(elements,canvases,change))continue;
|
||||
}
|
||||
if (change.kind === 'field') {
|
||||
if (!validJson(change.after)) throw fault('invalid_change');
|
||||
if (!change.field.startsWith('data.') && !['geometry', 'ownerCanvasId', 'source', 'target', 'path'].includes(change.field)) throw fault('invalid_field');
|
||||
const element = elements.get(change.target.id);
|
||||
if (!element || element.incarnation !== change.target.incarnation) throw fault('stale_incarnation', [change.target.id]);
|
||||
const dataKey = change.field.startsWith('data.') ? change.field.slice(5) : null;
|
||||
const prior = dataKey !== null ? element.data[dataKey] ?? null : (element as unknown as Record<string, unknown>)[change.field];
|
||||
if (conditional && (dataKey !== null && Object.hasOwn(element.data, dataKey) !== (change.beforePresent ?? true) || !equal(prior, change.before))) throw fault('field_conflict', [element.id]);
|
||||
if (dataKey !== null) {
|
||||
if (!dataKey || ['__proto__', 'prototype', 'constructor'].includes(dataKey)) throw fault('invalid_field');
|
||||
const data = { ...element.data };
|
||||
if (change.afterPresent === false) delete data[dataKey]; else data[dataKey] = clone(change.after);
|
||||
elements.set(element.id, freeze({ ...element, data }));
|
||||
} else elements.set(element.id, freeze({ ...element, [change.field]: clone(change.after) }) as ElementRecord);
|
||||
} else if (change.kind === 'element') {
|
||||
if (conditional && !equal(elements.get(change.id) || null, change.before)) throw fault('element_conflict', [change.id]);
|
||||
if (change.after) {
|
||||
if (!validJson(change.after)) throw fault('invalid_element', [change.id]);
|
||||
if (change.after.id !== change.id) throw fault('identity_mismatch', [change.id]);
|
||||
elements.set(change.id, detachElementData(change.after));
|
||||
} else {
|
||||
const old = elements.get(change.id);
|
||||
if (old) tombstones.set(change.id, Math.max(tombstones.get(change.id) || 0, old.incarnation));
|
||||
elements.delete(change.id);
|
||||
}
|
||||
} else if (change.kind === 'canvas') {
|
||||
if (conditional && !equal(canvases.get(change.id) || null, change.before)) throw fault('canvas_conflict', [change.id]);
|
||||
if (change.after) {
|
||||
if (!validJson(change.after)) throw fault('invalid_canvas', [change.id]);
|
||||
if (change.after.id !== change.id) throw fault('identity_mismatch', [change.id]);
|
||||
canvases.set(change.id, freeze(clone(change.after)));
|
||||
} else canvases.delete(change.id);
|
||||
} else throw fault('invalid_change');
|
||||
}
|
||||
return documentEnvelope({ projectId: document.projectId, elements: readMap(elements), canvases: readMap(canvases), tombstones: readMap(tombstones) });
|
||||
}
|
||||
function changeSatisfied(elements:ReadonlyMap<Id,ElementRecord>,canvases:ReadonlyMap<Id,CanvasRecord>,change:Change):boolean {
|
||||
if(change.kind==='canvas')return equal(canvases.get(change.id)??null,change.after);
|
||||
if(change.kind==='element')return equal(elements.get(change.id)??null,change.after);
|
||||
const element=elements.get(change.target.id);
|
||||
if(!element||element.incarnation!==change.target.incarnation)return false;
|
||||
if(change.field.startsWith('data.')){const key=change.field.slice(5);return Object.hasOwn(element.data,key)===(change.afterPresent??true)&&equal(element.data[key]??null,change.after);}
|
||||
return equal((element as unknown as Record<string,unknown>)[change.field],change.after);
|
||||
}
|
||||
export function invert(changes: readonly Change[]): readonly Change[] {
|
||||
return changes.slice().reverse().map(change => {
|
||||
if(change.kind==='field'){const{beforePresent,afterPresent,...record}=change;return freeze({...record,before:change.after,after:change.before,...(afterPresent===undefined?{}:{beforePresent:afterPresent}),...(beforePresent===undefined?{}:{afterPresent:beforePresent})});}
|
||||
return freeze({...change,before:change.after,after:change.before}) as Change;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import {detachJsonData} from './json-data.ts';
|
||||
import type { CanvasRecord, Change, Document, ElementRecord, Id, Point, Registry, Target } from './types.ts';
|
||||
import { ancestors, targetElement, validateDocumentSteps, validateDocumentTopologySteps } from './document.ts';
|
||||
import { duplicateTreeChange, duplicateEdgeChange, duplicateOverlay, type DuplicateOptions, type DuplicatePlan } from './duplicate.ts';
|
||||
import { clone, equal, fault, freeze, validJson } from './values.ts';
|
||||
const phases=['selection','canvases','members','roots','seed','dfs','edges','allocate-elements','allocate-canvases','mapping','emit','emit-edges','validate','done'] as const;
|
||||
type Task={kind:'element'|'canvas';id:Id};
|
||||
/** Internal preparation state: IDs/cursors only. Emitted records are a deterministic derived cache. */
|
||||
type MutableCheckpoint={version:1;projectId:Id;sourceToken:string;schemaToken:string;selection:Target[];offset:Point;mapping?:DuplicateOptions['mapping'];phase:typeof phases[number];cursor:number;steps:number;selected:Id[];children:[Id,Id][];members:[Id,Id[]][];roots:Id[];copied:Id[];canvases:Id[];order:Task[];work:Task[];expanding?:{canvas:Id;index:number};edges:Id[];elementIds:[Id,Id][];canvasIds:[Id,Id][];emitted:number;emittedEdges:number};
|
||||
type Immutable<T>=T extends object?{readonly [K in keyof T]:Immutable<T[K]>}:T;
|
||||
export type DuplicateCheckpoint=Immutable<MutableCheckpoint>;
|
||||
const ids=(value:unknown):value is Id[]=>Array.isArray(value)&&value.every(id=>typeof id==='string'&&!!id);
|
||||
export function validateDuplicateCheckpoint(value:DuplicateCheckpoint):void{
|
||||
if(!value||!validJson(value)||value.version!==1)throw fault('copy_checkpoint_interpreter_unavailable');
|
||||
if(![value.projectId,value.sourceToken,value.schemaToken].every(id=>typeof id==='string'&&!!id)||!phases.includes(value.phase)||!Number.isSafeInteger(value.cursor)||value.cursor<(value.phase==='seed'?-1:0)||![value.steps,value.emitted,value.emittedEdges].every(n=>Number.isSafeInteger(n)&&n>=0)||!Array.isArray(value.selection)||!value.selection.length||value.selection.some(t=>!t||typeof t.id!=='string'||!t.id||!Number.isSafeInteger(t.incarnation)||t.incarnation<1)||!value.offset||![value.offset.x,value.offset.y].every(Number.isFinite))throw fault('invalid_copy_checkpoint');
|
||||
for(const list of [value.selected,value.roots,value.copied,value.canvases,value.edges])if(!ids(list)||new Set(list).size!==list.length)throw fault('invalid_copy_checkpoint');
|
||||
for(const list of [value.order,value.work])if(!Array.isArray(list)||list.some(t=>!t||!['element','canvas'].includes(t.kind)||typeof t.id!=='string'||!t.id))throw fault('invalid_copy_checkpoint');
|
||||
for(const list of [value.children,value.elementIds,value.canvasIds])if(!Array.isArray(list)||list.some(pair=>!ids(pair)||pair.length!==2)||new Set(list.map(pair=>pair[0])).size!==list.length)throw fault('invalid_copy_checkpoint');
|
||||
if(!Array.isArray(value.members)||value.members.some(pair=>!Array.isArray(pair)||pair.length!==2||typeof pair[0]!=='string'||!pair[0]||!ids(pair[1]))||new Set(value.members.map(pair=>pair[0])).size!==value.members.length||value.emitted>value.order.length||value.emittedEdges>value.edges.length||value.emittedEdges>0&&value.emitted!==value.order.length||value.expanding&&(typeof value.expanding.canvas!=='string'||!value.expanding.canvas||!Number.isSafeInteger(value.expanding.index)||value.expanding.index< -1))throw fault('invalid_copy_checkpoint');
|
||||
}
|
||||
export function createDuplicatePlanner(document:Document,registry:Registry,selection:readonly Target[],options:DuplicateOptions,binding:{sourceToken:string;schemaToken:string;checkpoint?:DuplicateCheckpoint;topologyOnly?:boolean}){
|
||||
const offset=options.worldOffset??{x:32,y:32},idFactory=options.createId,suppliedMapping=options.mapping?freeze(clone(options.mapping)):undefined;
|
||||
const state:MutableCheckpoint=binding.checkpoint?clone(binding.checkpoint) as MutableCheckpoint:{version:1,projectId:document.projectId,sourceToken:binding.sourceToken,schemaToken:binding.schemaToken,selection:clone(selection) as Target[],offset:clone(offset),...(suppliedMapping?{mapping:suppliedMapping}:{}),phase:'selection',cursor:0,steps:0,selected:[],children:[],members:[],roots:[],copied:[],canvases:[],order:[],work:[],edges:[],elementIds:[],canvasIds:[],emitted:0,emittedEdges:0};
|
||||
validateDuplicateCheckpoint(state);
|
||||
if(state.projectId!==document.projectId||state.sourceToken!==binding.sourceToken||state.schemaToken!==binding.schemaToken||!equal(state.selection,selection)||!equal(state.offset,offset)||!equal(state.mapping,suppliedMapping))throw fault('copy_checkpoint_binding_mismatch');
|
||||
const sourceElements=[...document.elements.values()],sourceCanvases=[...document.canvases.values()],selected=new Set(state.selected),copied=new Set(state.copied),canvases=new Set(state.canvases),children=new Map(state.children),members=new Map(state.members),elementIds=new Map(state.elementIds),canvasIds=new Map(state.canvasIds),used=new Set([...document.elements.keys(),...document.canvases.keys(),...document.tombstones.keys()]);
|
||||
for(const id of [...state.selected,...state.roots,...state.copied,...state.edges])if(!document.elements.has(id))throw fault('invalid_copy_checkpoint');
|
||||
for(const id of state.canvases)if(!document.canvases.has(id))throw fault('invalid_copy_checkpoint');
|
||||
for(const [entry,id]of children)if(document.canvases.get(id)?.parent?.entryElementId!==entry)throw fault('invalid_copy_checkpoint');
|
||||
for(const [owner,list]of members)for(const id of list)if(document.elements.get(id)?.ownerCanvasId!==owner)throw fault('invalid_copy_checkpoint');
|
||||
for(const [list,scope]of [[state.elementIds,copied],[state.canvasIds,canvases]] as const)for(const [id,mapped]of list){if(!scope.has(id)||used.has(mapped))throw fault('invalid_copy_checkpoint');used.add(mapped);}
|
||||
for(const task of [...state.order,...state.work])if(!(task.kind==='element'?document.elements:document.canvases).has(task.id))throw fault('invalid_copy_checkpoint');
|
||||
if(phases.indexOf(state.phase)>=phases.indexOf('emit')&&(elementIds.size!==copied.size||canvasIds.size!==canvases.size))throw fault('invalid_copy_checkpoint');
|
||||
const limits:Partial<Record<DuplicateCheckpoint['phase'],number>>={selection:state.selection.length,canvases:sourceCanvases.length,members:sourceElements.length,roots:state.selected.length,seed:state.roots.length,edges:sourceElements.length,'allocate-elements':state.copied.length,'allocate-canvases':state.canvases.length};if(limits[state.phase]!==undefined&&state.cursor>limits[state.phase]!)throw fault('invalid_copy_checkpoint');
|
||||
const supplied=suppliedMapping?[...Object.keys(suppliedMapping.elements).map(id=>({id,kind:'element' as const})),...Object.keys(suppliedMapping.canvases).map(id=>({id,kind:'canvas' as const}))]:[];
|
||||
const outputElements=new Map<Id,ElementRecord>(),outputCanvases=new Map<Id,CanvasRecord>(),output=duplicateOverlay(document,outputElements,outputCanvases),changes:Change[]=[];
|
||||
let treeCache=0,edgeCache=0,sourceChecks:Generator<void>|undefined=(binding.topologyOnly?validateDocumentTopologySteps:validateDocumentSteps)(document,registry),outputChecks:Generator<void>|undefined,validated=false;
|
||||
const phase=(next:DuplicateCheckpoint['phase'],cursor=0)=>{state.phase=next;state.cursor=cursor;};
|
||||
function add(change:Exclude<Change,{kind:'field'}>){freeze(change);changes.push(change);if(change.kind==='element')outputElements.set(change.id,change.after!);else outputCanvases.set(change.id,change.after!);}
|
||||
function tree(index:number){const task=state.order[index];add(duplicateTreeChange(document,registry,task.kind,(task.kind==='element'?document.elements:document.canvases).get(task.id)!,elementIds,canvasIds,canvases,state.offset));}
|
||||
function edge(index:number){add(duplicateEdgeChange(document,registry,document.elements.get(state.edges[index])!,elementIds,canvasIds,output));}
|
||||
function allocate(id:Id,kind:'elements'|'canvases'){const map=suppliedMapping?.[kind],value=map&&Object.hasOwn(map,id)?map[id]:idFactory();if(typeof value!=='string'||!value||used.has(value))throw fault('duplicate_identity',[value]);used.add(value);return value;}
|
||||
return Object.freeze({
|
||||
ready():boolean{return !sourceChecks&&state.phase==='emit'&&state.emitted===0;},
|
||||
progress(){return Object.freeze({phase:state.phase,steps:state.steps,allocated:state.elementIds.length+state.canvasIds.length});},
|
||||
checkpoint():DuplicateCheckpoint{return detachJsonData(state);},
|
||||
step():boolean{
|
||||
if(sourceChecks){if(sourceChecks.next().done)sourceChecks=undefined;return false;}
|
||||
// Reconstruct only the derived output cache. Index/DFS/allocation work stays at its saved cursor.
|
||||
if(treeCache<state.emitted){tree(treeCache++);return false;}if(edgeCache<state.emittedEdges){edge(edgeCache++);return false;}
|
||||
if(state.phase==='validate'||state.phase==='done'){outputChecks??=validateDocumentSteps(output,registry);if(!outputChecks.next().done)return false;validated=true;if(state.phase!=='done'){phase('done');state.steps++;}return true;}
|
||||
switch(state.phase){
|
||||
case 'selection':if(state.cursor<state.selection.length){const id=targetElement(document,state.selection[state.cursor++]).id;if(!selected.has(id)){selected.add(id);state.selected.push(id);}}else{if(!selected.size)throw fault('empty_copy_selection');phase('canvases');}break;
|
||||
case 'canvases':if(state.cursor<sourceCanvases.length){const canvas=sourceCanvases[state.cursor++];if(canvas.parent){children.set(canvas.parent.entryElementId,canvas.id);state.children.push([canvas.parent.entryElementId,canvas.id]);}}else phase('members');break;
|
||||
case 'members':if(state.cursor<sourceElements.length){const record=sourceElements[state.cursor++];let list=members.get(record.ownerCanvasId);if(!list){list=[];members.set(record.ownerCanvasId,list);state.members.push([record.ownerCanvasId,list]);}list.push(record.id);}else phase('roots');break;
|
||||
case 'roots':if(state.cursor<state.selected.length){const id=state.selected[state.cursor++];if(!ancestors(document,document.elements.get(id)!.ownerCanvasId).some(canvas=>{const parent=document.canvases.get(canvas)!.parent;return parent&&selected.has(parent.entryElementId);}))state.roots.push(id);}else phase('seed',state.roots.length-1);break;
|
||||
case 'seed':if(state.cursor>=0){const id=state.roots[state.cursor--];if(document.elements.get(id)!.structure==='node')state.work.push({kind:'element',id});}else phase('dfs');break;
|
||||
case 'dfs':{
|
||||
if(state.expanding){const list=members.get(state.expanding.canvas)??[];if(state.expanding.index>=list.length)throw fault('invalid_copy_checkpoint');if(state.expanding.index<0)delete state.expanding;else{const id=list[state.expanding.index--];if(document.elements.get(id)!.structure==='node')state.work.push({kind:'element',id});}break;}
|
||||
const task=state.work.pop();if(!task){phase('edges');break;}
|
||||
if(task.kind==='canvas'){if(canvases.has(task.id))break;canvases.add(task.id);state.canvases.push(task.id);state.order.push(task);state.expanding={canvas:task.id,index:(members.get(task.id)?.length??0)-1};}
|
||||
else{if(copied.has(task.id))break;copied.add(task.id);state.copied.push(task.id);state.order.push(task);const child=children.get(task.id);if(child)state.work.push({kind:'canvas',id:child});}break;
|
||||
}
|
||||
case 'edges':if(state.cursor<sourceElements.length){const record=sourceElements[state.cursor++];if(record.structure==='connection'&&(selected.has(record.id)||canvases.has(record.ownerCanvasId)||copied.has(record.target.elementId))){state.edges.push(record.id);copied.add(record.id);state.copied.push(record.id);}}else phase('allocate-elements');break;
|
||||
case 'allocate-elements':if(state.cursor<state.copied.length){const id=state.copied[state.cursor++],value=allocate(id,'elements');elementIds.set(id,value);state.elementIds.push([id,value]);}else phase('allocate-canvases');break;
|
||||
case 'allocate-canvases':if(state.cursor<state.canvases.length){const id=state.canvases[state.cursor++],value=allocate(id,'canvases');canvasIds.set(id,value);state.canvasIds.push([id,value]);}else phase('mapping');break;
|
||||
case 'mapping':if(state.cursor<supplied.length){const value=supplied[state.cursor++];if(!(value.kind==='element'?copied:canvases).has(value.id))throw fault('copy_mapping_scope_mismatch');}else phase('emit');break;
|
||||
case 'emit':if(state.emitted<state.order.length){tree(state.emitted++);treeCache++;}else phase('emit-edges');break;
|
||||
case 'emit-edges':if(state.emittedEdges<state.edges.length){edge(state.emittedEdges++);edgeCache++;}else phase('validate');break;
|
||||
}
|
||||
state.steps++;return false;
|
||||
},
|
||||
result():DuplicatePlan{if(!validated||state.phase!=='done')throw fault('copy_plan_not_ready');return freeze({version:1,roots:state.roots.map(id=>elementIds.get(id)!),elementIds:Object.fromEntries(elementIds),canvasIds:Object.fromEntries(canvasIds),changes:[...changes]});},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
import type { CanvasRecord, Change, Data, Document, ElementRecord, Id, Json, Point, Registry, Target } from './types.ts';
|
||||
import { copySlots } from './copy-paths.ts';
|
||||
import { ancestors, commonCanvas, targetElement, validateDocumentSteps } from './document.ts';
|
||||
import { connectionPathValid } from './features.ts';
|
||||
import { canvasTransform, convertPath, toLocal, toWorld } from './coordinates.ts';
|
||||
import { clone, fault, freeze } from './values.ts';
|
||||
|
||||
export type DuplicatePlan = { readonly version: 1; readonly roots: readonly Id[]; readonly elementIds: Readonly<Record<Id, Id>>; readonly canvasIds: Readonly<Record<Id, Id>>; readonly changes: readonly Change[] };
|
||||
export type DuplicateOptions = { readonly createId: () => Id; readonly worldOffset?: Point; readonly mapping?: { readonly elements: Readonly<Record<Id, Id>>; readonly canvases: Readonly<Record<Id, Id>> } };
|
||||
|
||||
export function remapCopyData(element: ElementRecord, registry: Registry, elements: ReadonlyMap<Id, Id>, canvases: ReadonlyMap<Id, Id>, document: Document): Data {
|
||||
const definition = registry.components.get(element.componentId), schema = definition?.copy;
|
||||
if (registry.resolve(element) !== 'known' || !schema) throw fault('copy_schema_unavailable', [element.id]);
|
||||
const data = clone(element.data) as Record<string, Json>;
|
||||
for (const ref of schema.references) {
|
||||
for (const slot of copySlots(data, ref.path)) {
|
||||
if (slot.parent[slot.key] === null) continue;
|
||||
const mapped = (value: Json): Json => {
|
||||
if (typeof value !== 'string' || !value) throw fault('invalid_copy_reference', [element.id]);
|
||||
const map = ref.target === 'element' ? elements : canvases, known = ref.target === 'element' ? document.elements : document.canvases;
|
||||
if (map.has(value)) return map.get(value)!;
|
||||
if (ref.external !== 'preserve' || !known.has(value)) throw fault('copy_external_reference', [element.id, value]);
|
||||
return value;
|
||||
};
|
||||
const value = slot.parent[slot.key];
|
||||
if (ref.many && !Array.isArray(value)) throw fault('invalid_copy_reference', [element.id]);
|
||||
slot.parent[slot.key] = ref.many ? (value as Json[]).map(mapped) : mapped(value);
|
||||
}
|
||||
}
|
||||
for (const path of schema.omit) for (const slot of copySlots(data, path)) { if (Array.isArray(slot.parent)) throw fault('invalid_copy_omit', [element.id]); delete slot.parent[slot.key]; }
|
||||
return data;
|
||||
}
|
||||
|
||||
/** Pure, complete-snapshot planning. Runtime owns time slicing, durable source/mapping and partial progress. */
|
||||
export function* planDuplicateSteps(document: Document, registry: Registry, selection: readonly Target[], options: DuplicateOptions): Generator<void, DuplicatePlan> {
|
||||
yield* validateDocumentSteps(document, registry);
|
||||
const offset = options.worldOffset ?? { x: 32, y: 32 };
|
||||
if (![offset.x, offset.y].every(Number.isFinite)) throw fault('invalid_copy_offset');
|
||||
const selected = new Set<Id>();
|
||||
for (const target of selection) { yield; selected.add(targetElement(document, target).id); }
|
||||
if (!selected.size) throw fault('empty_copy_selection');
|
||||
const childByEntry = new Map<Id, CanvasRecord>(), members = new Map<Id, ElementRecord[]>();
|
||||
for (const canvas of document.canvases.values()) { yield; if (canvas.parent) childByEntry.set(canvas.parent.entryElementId, canvas); }
|
||||
for (const element of document.elements.values()) { yield; const list = members.get(element.ownerCanvasId) ?? []; list.push(element); members.set(element.ownerCanvasId, list); }
|
||||
const roots: Id[] = [];
|
||||
for (const id of selected) { yield; if (!ancestors(document, document.elements.get(id)!.ownerCanvasId).some(canvas => { const parent = document.canvases.get(canvas)!.parent; return parent && selected.has(parent.entryElementId); })) roots.push(id); }
|
||||
const copied = new Set<Id>(), copiedCanvases = new Set<Id>(), order: ({ kind: 'element'; record: ElementRecord } | { kind: 'canvas'; record: CanvasRecord })[] = [];
|
||||
const work: typeof order = [];
|
||||
for (let index = roots.length - 1; index >= 0; index--) { yield; const record = document.elements.get(roots[index])!; if (record.structure === 'node') work.push({kind:'element',record}); }
|
||||
while (work.length) {
|
||||
yield;
|
||||
const item = work.pop()!;
|
||||
if (item.kind === 'canvas') {
|
||||
if (copiedCanvases.has(item.record.id)) continue;
|
||||
copiedCanvases.add(item.record.id); order.push(item);
|
||||
const list = members.get(item.record.id) ?? [];
|
||||
for (let index = list.length - 1; index >= 0; index--) { yield; const member = list[index]; if (member.structure === 'node') work.push({ kind: 'element', record: member }); }
|
||||
} else {
|
||||
if (copied.has(item.record.id)) continue;
|
||||
copied.add(item.record.id); order.push(item);
|
||||
const child = childByEntry.get(item.record.id); if (child) work.push({ kind: 'canvas', record: child });
|
||||
}
|
||||
}
|
||||
const edges: ElementRecord[] = [];
|
||||
for (const element of document.elements.values()) { yield; if (element.structure === 'connection' && (selected.has(element.id) || copiedCanvases.has(element.ownerCanvasId) || copied.has(element.target.elementId))) { edges.push(element); copied.add(element.id); } }
|
||||
const elementIds = new Map<Id, Id>(), canvasIds = new Map<Id, Id>(), used = new Set<Id>();
|
||||
for (const map of [document.elements, document.canvases, document.tombstones]) for (const id of map.keys()) { yield; used.add(id); }
|
||||
const allocate = (source: Id, kind: 'elements' | 'canvases') => {
|
||||
const supplied = options.mapping?.[kind];
|
||||
const id = supplied && Object.hasOwn(supplied, source) ? supplied[source] : options.createId();
|
||||
if (typeof id !== 'string' || !id || used.has(id)) throw fault('duplicate_identity', [id]);
|
||||
used.add(id); return id;
|
||||
};
|
||||
for (const id of copied) { yield; elementIds.set(id, allocate(id, 'elements')); }
|
||||
for (const id of copiedCanvases) { yield; canvasIds.set(id, allocate(id, 'canvases')); }
|
||||
if (options.mapping) for (const [mapping, scope] of [[options.mapping.elements,copied],[options.mapping.canvases,copiedCanvases]] as const) for (const id in mapping) { yield; if (Object.hasOwn(mapping,id) && !scope.has(id)) throw fault('copy_mapping_scope_mismatch'); }
|
||||
const changes: Change[] = [];
|
||||
for (const item of order) {
|
||||
yield;
|
||||
changes.push(duplicateTreeChange(document,registry,item.kind,item.record,elementIds,canvasIds,copiedCanvases,offset));
|
||||
}
|
||||
const nodeElements = new Map<Id, ElementRecord>(), nodeCanvases = new Map<Id, CanvasRecord>();
|
||||
for (const change of changes) { yield; if (change.kind === 'element' && change.after) nodeElements.set(change.id, change.after); else if (change.kind === 'canvas' && change.after) nodeCanvases.set(change.id, change.after); }
|
||||
const nodes: Document = { ...document, elements: overlay(document.elements, nodeElements), canvases: overlay(document.canvases, nodeCanvases) };
|
||||
for (const edge of edges) {
|
||||
yield;
|
||||
const change=duplicateEdgeChange(document,registry,edge,elementIds,canvasIds,nodes);
|
||||
changes.push(change);nodeElements.set(change.id,change.after!);
|
||||
}
|
||||
yield* validateDocumentSteps(nodes, registry);
|
||||
const elementMapping: Record<Id,Id> = {}, canvasMapping: Record<Id,Id> = {}, copiedRoots: Id[] = [];
|
||||
for (const [id, value] of elementIds) { yield; Object.defineProperty(elementMapping,id,{value,enumerable:true}); }
|
||||
for (const [id, value] of canvasIds) { yield; Object.defineProperty(canvasMapping,id,{value,enumerable:true}); }
|
||||
for (const id of roots) { yield; copiedRoots.push(elementIds.get(id)!); }
|
||||
for (const change of changes) { yield; freeze(change); }
|
||||
return Object.freeze({ version: 1, roots: Object.freeze(copiedRoots), elementIds: Object.freeze(elementMapping), canvasIds: Object.freeze(canvasMapping), changes: Object.freeze(changes) });
|
||||
}
|
||||
|
||||
/** Shared deterministic emitters for synchronous and checkpointed planners. */
|
||||
export function duplicateTreeChange(document:Document,registry:Registry,kind:'element'|'canvas',record:ElementRecord|CanvasRecord,elementIds:ReadonlyMap<Id,Id>,canvasIds:ReadonlyMap<Id,Id>,copiedCanvases:ReadonlySet<Id>,offset:Point):Exclude<Change,{kind:'field'}>{
|
||||
if (kind === 'canvas') {
|
||||
const canvas=record as CanvasRecord,parent=canvas.parent!;
|
||||
const after = { ...clone(canvas), id: canvasIds.get(canvas.id)!, parent: { ...parent, canvasId: canvasIds.get(parent.canvasId) ?? parent.canvasId, entryElementId: elementIds.get(parent.entryElementId)! } };
|
||||
return { kind: 'canvas', id: after.id, before: null, after };
|
||||
} else {
|
||||
const old = record as ElementRecord; if (old.structure !== 'node') throw fault('invalid_copy_node');
|
||||
let geometry = clone(old.geometry);
|
||||
if (!copiedCanvases.has(old.ownerCanvasId)) {
|
||||
const transform = canvasTransform(document, old.ownerCanvasId), zero = toLocal(transform, { x: 0, y: 0 }), shift = toLocal(transform, offset);
|
||||
geometry = { ...geometry, x: geometry.x + shift.x - zero.x, y: geometry.y + shift.y - zero.y };
|
||||
}
|
||||
const after = { ...clone(old), id: elementIds.get(old.id)!, incarnation: 1, ownerCanvasId: canvasIds.get(old.ownerCanvasId) ?? old.ownerCanvasId, data: remapCopyData(old, registry, elementIds, canvasIds, document), geometry };
|
||||
return { kind: 'element', id: after.id, before: null, after };
|
||||
}
|
||||
}
|
||||
export function duplicateEdgeChange(document:Document,registry:Registry,edge:ElementRecord,elementIds:ReadonlyMap<Id,Id>,canvasIds:ReadonlyMap<Id,Id>,nodes:Document):Extract<Change,{kind:'element'}>{
|
||||
if (edge.structure !== 'connection') throw fault('invalid_copy_endpoint');
|
||||
if (!connectionPathValid(edge.path)) throw fault('unsupported_copy_path', [edge.id]);
|
||||
const source = { ...edge.source, elementId: elementIds.get(edge.source.elementId) ?? edge.source.elementId }, target = { ...edge.target, elementId: elementIds.get(edge.target.elementId) ?? edge.target.elementId };
|
||||
const owner = commonCanvas(nodes, nodes.elements.get(source.elementId)!.ownerCanvasId, nodes.elements.get(target.elementId)!.ownerCanvasId), transform = canvasTransform(nodes, owner);
|
||||
let path = convertPath(edge.path, canvasTransform(document, edge.ownerCanvasId), transform);
|
||||
if (path && typeof path === 'object' && !Array.isArray(path) && Array.isArray(path.knots)) {
|
||||
const displacement = (beforeId: Id, afterId: Id) => {
|
||||
const a = document.elements.get(beforeId)!, b = nodes.elements.get(afterId)!;
|
||||
if (a.structure !== 'node' || b.structure !== 'node') throw fault('invalid_copy_endpoint');
|
||||
const from = toWorld(canvasTransform(document, a.ownerCanvasId), { x: a.geometry.x + a.geometry.width / 2, y: a.geometry.y + a.geometry.height / 2 }), to = toWorld(canvasTransform(nodes, b.ownerCanvasId), { x: b.geometry.x + b.geometry.width / 2, y: b.geometry.y + b.geometry.height / 2 });
|
||||
return { x: to.x - from.x, y: to.y - from.y };
|
||||
};
|
||||
const from = displacement(edge.source.elementId, source.elementId), to = displacement(edge.target.elementId, target.elementId), knots = path.knots;
|
||||
path = { ...path, knots: knots.map((raw, index) => { const knot = raw as Record<string, Json>, point = knot.position as { x: number; y: number }, world = toWorld(transform, point), t = (index + 1) / (knots.length + 1); return { ...knot, position: toLocal(transform, { x: world.x + from.x * (1 - t) + to.x * t, y: world.y + from.y * (1 - t) + to.y * t }) }; }) };
|
||||
}
|
||||
const after: ElementRecord = { ...clone(edge), id: elementIds.get(edge.id)!, incarnation: 1, ownerCanvasId: owner, source, target, path, data: remapCopyData(edge, registry, elementIds, canvasIds, document) };
|
||||
return { kind: 'element', id: after.id, before: null, after };
|
||||
}
|
||||
export function duplicateOverlay(document:Document,elements:ReadonlyMap<Id,ElementRecord>,canvases:ReadonlyMap<Id,CanvasRecord>):Document{return {...document,elements:overlay(document.elements,elements),canvases:overlay(document.canvases,canvases)};}
|
||||
|
||||
/** Internal overlay avoids an O(document size) clone between planning phases. Additions own unique IDs. */
|
||||
function overlay<K,V>(base: ReadonlyMap<K,V>, additions: ReadonlyMap<K,V>): ReadonlyMap<K,V> {
|
||||
const view: ReadonlyMap<K,V> = {
|
||||
get size() { return base.size + additions.size; },
|
||||
get: key => additions.has(key) ? additions.get(key) : base.get(key), has: key => additions.has(key) || base.has(key),
|
||||
*entries(): Generator<[K,V],undefined> { yield* base; yield* additions; return undefined; },
|
||||
*keys(): Generator<K,undefined> { yield* base.keys(); yield* additions.keys(); return undefined; },
|
||||
*values(): Generator<V,undefined> { yield* base.values(); yield* additions.values(); return undefined; },
|
||||
[Symbol.iterator]() { return this.entries(); },
|
||||
forEach(callback,thisArg) { for (const [key,value] of view) callback.call(thisArg,value,key,view); },
|
||||
};
|
||||
return Object.freeze(view);
|
||||
}
|
||||
|
||||
/** Synchronous API retained for small snapshots and callers that own scheduling. */
|
||||
export function planDuplicate(document: Document, registry: Registry, selection: readonly Target[], options: DuplicateOptions): DuplicatePlan {
|
||||
const steps = planDuplicateSteps(document,registry,selection,options);
|
||||
while (true) { const step = steps.next(); if (step.done) return step.value; }
|
||||
}
|
||||
@@ -0,0 +1,353 @@
|
||||
import {dataArray,dataRecord} from './data-containers.ts';
|
||||
import {stageCreation,materializeCreations} from './creation-draft.ts';
|
||||
import type { Applied, Availability, Capability, Change, Command, Commit, CoreSnapshot, Document, ElementRecord, Geometry, HistoryEntry, HistoryState, Id, OperationIntent, Origin, Registry, Result, Snapshot } from './types.ts';
|
||||
import { applyChanges, createDocument, invert, targetElement, validateDocument } from './document.ts';
|
||||
import { clone, equal, fault, freeze, readMap, reject, validJson } from './values.ts';
|
||||
|
||||
import {createDocumentQueries,advanceDocumentQueries,withQueryCoverage,type QueryCoverage,type QueryBudget} from './queries.ts';
|
||||
import { canvasTransform, convertGeometry } from './coordinates.ts';
|
||||
import { unitScale, withUnitScale } from './units.ts';
|
||||
import { removalChanges, repairConnectionOwners } from './structure.ts';
|
||||
import {planDissolveCanvas} from './dissolve.ts';
|
||||
import { planHistoryChanges } from './history.ts';
|
||||
import { describeHistoryTransition } from './history-transition.ts';
|
||||
import { planCreationSize, validateCreationSizeEffect } from './creation-size-plan.ts';
|
||||
import type { CreationSizeEffect } from './types.ts';
|
||||
|
||||
export type EditorOptions<Runtime = undefined> = {
|
||||
registry: Registry; document: Document; queryBudget?:QueryBudget; createId?: () => Id; historyLimit?: number;
|
||||
queryCoverage?: (snapshot:Snapshot<Runtime>)=>QueryCoverage;
|
||||
policy?: (command: Command, document: Document) => Availability;
|
||||
traversalPolicy?: (entry: HistoryEntry, direction: 'undo' | 'redo') => Availability;
|
||||
onListenerError?: (error: unknown) => void;
|
||||
allowRawChanges?: boolean;
|
||||
recovery?: CoreSnapshot & { readonly rootRevision: number };
|
||||
runtime?: Runtime;
|
||||
/** Installed by the engine. Pure preparation: an error rejects the entire commit. */
|
||||
reduceRuntime?: (previous: Runtime, next: CoreSnapshot, commit: Commit) => Runtime;
|
||||
/** Engine may attach transport semantics without changing identity, order or document changes. */
|
||||
prepareIntent?: (intent: OperationIntent, runtime: Runtime) => OperationIntent;
|
||||
/** Pure coordinator hook for all publication paths; may replace runtime only or reject. */
|
||||
prepareRuntime?: (previous: Snapshot<Runtime>, next: Snapshot<Runtime>) => Runtime;
|
||||
nextOperationSequence?: (runtime: Runtime, fallback: number) => number;
|
||||
};
|
||||
export type Transaction = { dispatch(command: Command): Availability; getDocument(): Document };
|
||||
export type Editor = ReturnType<typeof createEditor>;
|
||||
const isThenable = (value: unknown) => !!value && (typeof value === 'object' || typeof value === 'function') && typeof (value as { then?: unknown }).then === 'function';
|
||||
|
||||
export function createEditor<Runtime = undefined>(options: EditorOptions<Runtime>) {
|
||||
const source = options.recovery?.document ?? options.document;
|
||||
const document = createDocument(source.projectId, [...source.canvases.values()], [...source.elements.values()], source.tombstones);
|
||||
validateDocument(document, options.registry);
|
||||
let state: Snapshot<Runtime> = Object.freeze({ revision: options.recovery?.revision ?? 0, rootRevision: options.recovery?.rootRevision ?? 0, document, history: options.recovery?.history ?? Object.freeze({ undo: Object.freeze([]), redo: Object.freeze([]), entries: readMap(new Map<Id, HistoryEntry>()) }), operations: options.recovery?.operations ?? readMap(new Map<Id, OperationIntent>()), runtime: options.runtime as Runtime });
|
||||
const bindCoverage=(snapshot:Snapshot<Runtime>,queries:ReturnType<typeof createDocumentQueries>)=>options.queryCoverage?withQueryCoverage(queries,options.queryCoverage(snapshot)):queries;
|
||||
let queries=bindCoverage(state,createDocumentQueries(document,options.registry,state.revision,options.queryBudget));
|
||||
const queriesByDocument=new WeakMap([[document,queries]]);
|
||||
let entered = false, disposed = false, sequence = 0;
|
||||
for (const intent of state.operations.values()) sequence = Math.max(sequence, intent.sequence);
|
||||
const listeners = new Set<(commit: Commit, snapshot: Snapshot<Runtime>) => void>();
|
||||
const createId = options.createId || (() => globalThis.crypto.randomUUID());
|
||||
const limit = options.historyLimit ?? 100;
|
||||
if (!Number.isSafeInteger(limit) || limit < 1) throw fault('invalid_history_limit');
|
||||
|
||||
function plan(document: Document, command: Command): { document: Document; changes: readonly Change[] } {
|
||||
const permission = options.policy?.(command, document);
|
||||
if (permission && !permission.enabled) throw fault(permission.code, permission.targets);
|
||||
let changes: readonly Change[];
|
||||
if (command.type === 'canvas.create') {
|
||||
if (document.canvases.has(command.canvas.id)) throw fault('duplicate_identity', [command.canvas.id]);
|
||||
changes = [{ kind: 'canvas', id: command.canvas.id, before: null, after: command.canvas }];
|
||||
} else if (command.type === 'canvas.reparent') {
|
||||
const canvas = document.canvases.get(command.canvasId);
|
||||
if (!canvas) throw fault('canvas_not_loaded', [command.canvasId]);
|
||||
changes = equal(canvas.parent, command.parent) ? [] : [{ kind: 'canvas', id: canvas.id, before: canvas, after: { ...canvas, parent: command.parent } }];
|
||||
if(changes.length)changes=[...changes,...repairConnectionOwners(document,applyChanges(document,changes))];
|
||||
} else if(command.type==='canvas.dissolve'){
|
||||
const dissolved=planDissolveCanvas(document,options.registry,command.canvasId,command.entry);changes=dissolved.changes;
|
||||
for(const member of dissolved.commands){
|
||||
const allowed=options.policy?.(member,document);if(allowed&&!allowed.enabled)throw fault(allowed.code,allowed.targets);
|
||||
}
|
||||
} else if (command.type === 'element.create') {
|
||||
const element = command.element;
|
||||
if (document.elements.has(element.id) || document.tombstones.has(element.id)) throw fault('duplicate_identity', [element.id]);
|
||||
if (options.registry.resolve(element) !== 'known') throw fault('unsupported_component', [element.id]);
|
||||
changes = [{ kind: 'element', id: element.id, before: null, after: element }];
|
||||
} else if (command.type === 'changes.apply') {
|
||||
if (!options.allowRawChanges) throw fault('raw_changes_not_allowed');
|
||||
changes = command.changes;
|
||||
} else {
|
||||
let element = targetElement(document, command.target);
|
||||
if (options.registry.resolve(element) !== 'known') throw fault('unsupported_component', [element.id]);
|
||||
if (command.type === 'connection.path') {
|
||||
if (element.structure !== 'connection' || !options.registry.supports(element, 'connection.edit')) throw fault('unsupported_capability', [element.id]);
|
||||
if (!validJson(command.path)) throw fault('invalid_connection_path', [element.id]);
|
||||
changes = options.registry.handlers.get(element.componentId)!.get('connection.edit')!({ element, input: freeze({ patch: { path: clone(command.path) } }) });
|
||||
for (const change of changes) if (change.kind !== 'field' || change.field !== 'path' || change.target.id !== element.id || change.target.incarnation !== element.incarnation) throw fault('invalid_handler_change', [element.id]);
|
||||
} else if(command.type==='connection.endpoints'){
|
||||
if(element.structure!=='connection'||!options.registry.supports(element,'connection.edit'))throw fault('unsupported_capability',[element.id]);
|
||||
changes=[];
|
||||
for(const [field,endpoint]of [['source',command.source],['target',command.destination]] as const)if(endpoint&&!equal(element[field],endpoint))changes=[...changes,{kind:'field',target:command.target,field,before:element[field],after:clone(endpoint)}];
|
||||
if(changes.length)changes=[...changes,...repairConnectionOwners(document,applyChanges(document,changes))];
|
||||
} else if (command.type === 'element.remove') {
|
||||
changes = removalChanges(document,command.target,queriesByDocument.get(document));
|
||||
} else if (command.type === 'element.move') {
|
||||
if (!options.registry.supports(element, 'geometry.move')) throw fault('unsupported_capability', [element.id]);
|
||||
changes = [{ kind: 'field', target: command.target, field: 'ownerCanvasId', before: element.ownerCanvasId, after: command.canvasId }];
|
||||
if(element.structure==='node'){
|
||||
const source=canvasTransform(document,element.ownerCanvasId),destination=canvasTransform(document,command.canvasId),presentation=withUnitScale(element.data.presentation,unitScale(element.data.presentation)*source.scale/destination.scale);
|
||||
if(!equal(presentation,element.data.presentation))changes=[...changes,{kind:'field',target:command.target,field:'data.presentation',before:element.data.presentation??null,beforePresent:Object.hasOwn(element.data,'presentation'),after:presentation??null,afterPresent:presentation!==undefined}];
|
||||
const geometry=command.geometry??(element.ownerCanvasId===command.canvasId?element.geometry:convertGeometry(element.geometry,canvasTransform(document,element.ownerCanvasId),canvasTransform(document,command.canvasId)));
|
||||
if(!equal(geometry,element.geometry))changes=[...changes,{kind:'field',target:command.target,field:'geometry',before:element.geometry,after:geometry}];
|
||||
}
|
||||
for (const canvas of document.canvases.values()) if (canvas.parent?.entryElementId === element.id) changes = [...changes, { kind: 'canvas', id: canvas.id, before: canvas, after: { ...canvas, parent: { ...canvas.parent, canvasId: command.canvasId } } }];
|
||||
changes=[...changes,...repairConnectionOwners(document,applyChanges(document,changes))];
|
||||
} else {
|
||||
const output: Change[] = [];
|
||||
const run = (capability: Capability, patch: unknown) => {
|
||||
const handler = options.registry.handlers.get(element.componentId)?.get(capability);
|
||||
if (!handler) throw fault('unsupported_capability', [element.id, capability]);
|
||||
const generated = handler({ element, input: freeze({ patch: clone(patch) }) });
|
||||
// A handler is scoped to this target; it cannot write another object or domain.
|
||||
for (const change of generated) if (change.target.id !== element.id || change.target.incarnation !== element.incarnation || (capability === 'content.edit' ? !change.field.startsWith('data.') : change.field !== 'geometry')) throw fault('invalid_handler_change', [element.id]);
|
||||
output.push(...generated);
|
||||
element = targetElement(applyChanges(document, output), command.target);
|
||||
};
|
||||
if (command.type === 'element.data') run('content.edit', command.patch);
|
||||
else {
|
||||
if (element.structure !== 'node' || Object.keys(command.patch).some(key => !['x', 'y', 'width', 'height', 'rotation'].includes(key))) throw fault('invalid_geometry_patch', [element.id]);
|
||||
const parts: [Capability, Partial<Geometry>][] = [
|
||||
['geometry.move', Object.fromEntries(Object.entries(command.patch).filter(([key]) => key === 'x' || key === 'y'))],
|
||||
['geometry.resize', Object.fromEntries(Object.entries(command.patch).filter(([key]) => key === 'width' || key === 'height'))],
|
||||
['geometry.rotate', Object.fromEntries(Object.entries(command.patch).filter(([key]) => key === 'rotation'))],
|
||||
];
|
||||
for (const [capability, patch] of parts) if (Object.keys(patch).length) run(capability, patch);
|
||||
}
|
||||
changes = output;
|
||||
}
|
||||
}
|
||||
const next = command.type==='element.create'||command.type==='canvas.create'?stageCreation(document,changes[0] as Extract<Change,{kind:'canvas'|'element'}>):applyChanges(document, changes);
|
||||
return { document: next, changes: freeze(clone(changes)) };
|
||||
}
|
||||
|
||||
function assertOpen() {
|
||||
if (disposed) throw fault('editor_disposed');
|
||||
if (entered) throw fault('reentrant_dispatch');
|
||||
}
|
||||
function prepareState(next: Snapshot<Runtime>): Snapshot<Runtime> {
|
||||
if(!options.prepareRuntime)return next;
|
||||
const runtime=options.prepareRuntime(state,next);
|
||||
if(isThenable(runtime))throw fault('async_transaction');
|
||||
return runtime===next.runtime?next:Object.freeze({...next,runtime});
|
||||
}
|
||||
function emit(commit: Commit) {
|
||||
for (const listener of [...listeners]) try { listener(commit, state); } catch (error) { try { options.onListenerError?.(error); } catch { /* Observers cannot roll back a committed state. */ } }
|
||||
}
|
||||
function uniqueId(used: Set<Id>): Id {
|
||||
const id = createId();
|
||||
if (!id || used.has(id) || state.operations.has(id) || state.history.entries.has(id)) throw fault('duplicate_operation_id');
|
||||
used.add(id); return id;
|
||||
}
|
||||
function publish(document: Document, changes: readonly Change[], origin: Origin, history: HistoryState, historyEntryId?: Id, historyEffect?: CreationSizeEffect): Applied {
|
||||
const used = new Set<Id>();
|
||||
for (const id of history.entries.keys()) used.add(id);
|
||||
// Canonical remote/ACK/replay updates must never feed back into the outbox.
|
||||
const outbound = !['remote', 'ack', 'replay'].includes(origin);
|
||||
const operationIds = Object.freeze(outbound ? [uniqueId(used)] : []);
|
||||
const operations = new Map(state.operations);
|
||||
const nextSequence = outbound ? options.nextOperationSequence?.(state.runtime, sequence + 1) ?? sequence + 1 : sequence;
|
||||
if (!Number.isSafeInteger(nextSequence) || outbound && nextSequence <= sequence) throw fault('invalid_operation_sequence');
|
||||
if (historyEntryId) {
|
||||
const entries = new Map(history.entries), entry = entries.get(historyEntryId)!;
|
||||
entries.set(entry.id, dataRecord(freeze({ ...entry, operationIds: dataArray(freeze([...entry.operationIds, ...operationIds])) })));
|
||||
history = Object.freeze({ ...history, entries: readMap(entries) });
|
||||
}
|
||||
if (outbound) {
|
||||
const canvasIds = new Set<Id>();
|
||||
for (const change of changes) {
|
||||
if (change.kind === 'canvas') {
|
||||
canvasIds.add(change.id);
|
||||
if (change.before?.parent) canvasIds.add(change.before.parent.canvasId);
|
||||
if (change.after?.parent) canvasIds.add(change.after.parent.canvasId);
|
||||
} else if (change.kind === 'element') {
|
||||
if (change.before) canvasIds.add(change.before.ownerCanvasId);
|
||||
if (change.after) canvasIds.add(change.after.ownerCanvasId);
|
||||
if (!change.before && change.after?.structure === 'connection') {
|
||||
for (const endpoint of [change.after.source, change.after.target]) {
|
||||
const element = document.elements.get(endpoint.elementId) ?? state.document.elements.get(endpoint.elementId);
|
||||
if (element) canvasIds.add(element.ownerCanvasId);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const before = state.document.elements.get(change.target.id), after = document.elements.get(change.target.id);
|
||||
if (before) canvasIds.add(before.ownerCanvasId);
|
||||
if (after) canvasIds.add(after.ownerCanvasId);
|
||||
if (change.field === 'ownerCanvasId') { canvasIds.add(change.before as Id); canvasIds.add(change.after as Id); }
|
||||
if (change.field === 'source' || change.field === 'target') {
|
||||
for (const endpoint of [change.before, change.after]) {
|
||||
const id = (endpoint as { elementId?: Id })?.elementId;
|
||||
const element = id ? document.elements.get(id) ?? state.document.elements.get(id) : undefined;
|
||||
if (element) canvasIds.add(element.ownerCanvasId);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
const historyTransition=describeHistoryTransition(state.history,history,state.rootRevision+1,historyEntryId,true);
|
||||
const intent: OperationIntent = freeze({ id: operationIds[0], operationType: 'document.change', operationSchemaVersion: 1, sequence: nextSequence, origin, canvasIds: [...canvasIds].sort(), changes, dependencies: [],...(historyTransition?{historyTransition}:{}),...(historyEffect?{historyEffect}:{}) });
|
||||
const prepared=options.prepareIntent?.(intent,state.runtime) ?? intent;
|
||||
if(prepared.operationType==='run.apply'&&!validJson(prepared.action)||!['document.change','run.apply'].includes(prepared.operationType)||['id','operationSchemaVersion','sequence','origin','canvasIds','changes','dependencies','historyTransition','historyEffect'].some(key=>!equal((prepared as unknown as Record<string,unknown>)[key],(intent as unknown as Record<string,unknown>)[key])))throw fault('invalid_prepared_intent');
|
||||
operations.set(intent.id, freeze(clone(prepared)));
|
||||
}
|
||||
const next: CoreSnapshot = Object.freeze({ revision: state.revision + 1, document, history, operations: readMap(operations) });
|
||||
const commit = freeze({ revision: next.revision, origin, changes, operationIds });
|
||||
const runtime = options.reduceRuntime ? options.reduceRuntime(state.runtime, next, commit) : state.runtime;
|
||||
if (isThenable(runtime)) throw fault('async_transaction');
|
||||
const nextState=prepareState(Object.freeze({ ...next, rootRevision: state.rootRevision + 1, runtime })),nextQueries=bindCoverage(nextState,advanceDocumentQueries(queries,document,options.registry,next.revision,changes));
|
||||
state=nextState;queries=nextQueries;queriesByDocument.set(document,queries);
|
||||
if (outbound) sequence = nextSequence;
|
||||
emit(commit);
|
||||
return Object.freeze({ status: 'applied', revision: state.revision, operationIds, ...(historyEntryId ? { historyEntryId } : {}) });
|
||||
}
|
||||
|
||||
function transaction(callback: (transaction: Transaction) => unknown, settings: { label?: string; groupId?: Id; history?: boolean; origin?: Origin } = {}): Result {
|
||||
try { assertOpen(); } catch (error) { return reject(error); }
|
||||
entered = true;
|
||||
let active = true, document = state.document, failed: unknown;
|
||||
const changes: Change[] = [];
|
||||
const tx: Transaction = Object.freeze({
|
||||
dispatch(command: Command): Availability {
|
||||
if (!active) throw fault('transaction_closed');
|
||||
if (failed) return { enabled: false, ...reject(failed) };
|
||||
try {
|
||||
const result = plan(document, command); document = result.document; changes.push(...result.changes);
|
||||
return { enabled: true };
|
||||
} catch (error) { failed = error; const result = reject(error); return { enabled: false, code: result.code, targets: result.targets }; }
|
||||
},
|
||||
getDocument() { if (!active) throw fault('transaction_closed'); return document; },
|
||||
});
|
||||
try {
|
||||
const result = callback(tx);
|
||||
active = false;
|
||||
if (isThenable(result)) { Promise.resolve(result).catch(() => {}); throw fault('async_transaction'); }
|
||||
if (failed) throw failed;
|
||||
document=materializeCreations(document);
|
||||
validateDocument(document, options.registry);
|
||||
if (!changes.length || documentsEqual(document, state.document)) return Object.freeze({ status: 'applied', revision: state.revision, operationIds: Object.freeze([]) });
|
||||
let history = state.history, historyEntryId: Id | undefined;
|
||||
if (settings.history !== false && (!settings.origin || settings.origin === 'user')) {
|
||||
const entries = new Map(history.entries), undo = [...history.undo];
|
||||
for (const id of history.redo) entries.delete(id);
|
||||
const prior = undo.length ? entries.get(undo[undo.length - 1]) : undefined;
|
||||
if (settings.groupId && prior?.groupId === settings.groupId) {
|
||||
historyEntryId = prior.id;
|
||||
entries.set(prior.id, dataRecord(freeze({ ...prior, changes: dataArray(freeze([...prior.changes, ...changes])) })));
|
||||
} else {
|
||||
historyEntryId = uniqueId(new Set());
|
||||
entries.set(historyEntryId, dataRecord(freeze({ id: historyEntryId, label: settings.label || 'Edit', changes: dataArray(freeze([...changes])), operationIds: dataArray<Id>([]), ...(settings.groupId ? { groupId: settings.groupId } : {}) })));
|
||||
undo.push(historyEntryId);
|
||||
}
|
||||
while (undo.length > limit) entries.delete(undo.shift()!);
|
||||
history = Object.freeze({ undo: Object.freeze(undo), redo: Object.freeze([]), entries: readMap(entries) });
|
||||
}
|
||||
return publish(document, freeze([...changes]), settings.origin || 'user', history, historyEntryId);
|
||||
} catch (error) { active = false; return reject(error); }
|
||||
finally { entered = false; }
|
||||
}
|
||||
function dispatch(command: Command, settings?: Parameters<typeof transaction>[1]): Result {
|
||||
return transaction(tx => { tx.dispatch(command); }, settings);
|
||||
}
|
||||
function can(command: Command): Availability {
|
||||
try { if (disposed) throw fault('editor_disposed'); const result = plan(state.document, command); validateDocument(result.document, options.registry); return { enabled: true }; }
|
||||
catch (error) { const result = reject(error); return { enabled: false, code: result.code, targets: result.targets }; }
|
||||
}
|
||||
function traverse(direction: 'undo' | 'redo'): Result {
|
||||
try { assertOpen(); } catch (error) { return reject(error); }
|
||||
entered = true;
|
||||
try {
|
||||
const source = state.history[direction], id = source[source.length - 1];
|
||||
if (!id) throw fault('history_empty');
|
||||
const entry = state.history.entries.get(id)!;
|
||||
const allowed = options.traversalPolicy?.(entry, direction);
|
||||
if (allowed && !allowed.enabled) throw fault(allowed.code, allowed.targets);
|
||||
const planned=direction==='undo'?invert(entry.changes):entry.changes;
|
||||
const {document,changes,remap,restored}=planHistoryChanges(state.document,planned,options.registry);
|
||||
const entries=new Map(state.history.entries);
|
||||
// Later redo entries belong to the lifecycle restored by this explicit history action.
|
||||
// Frozen operations retain their original identities and incarnations.
|
||||
if(restored)for(const [otherId,other]of entries)if(otherId!==id)entries.set(otherId,dataRecord(freeze({...other,changes:dataArray(freeze(other.changes.map(remap)))})));
|
||||
entries.set(id,dataRecord(freeze({...entry,changes:dataArray(freeze(direction==='undo'?invert(changes):changes))})));
|
||||
const history: HistoryState = Object.freeze({ undo: Object.freeze(direction === 'undo' ? state.history.undo.slice(0, -1) : [...state.history.undo, id]), redo: Object.freeze(direction === 'redo' ? state.history.redo.slice(0, -1) : [...state.history.redo, id]), entries: readMap(entries) });
|
||||
return publish(document, changes, direction, history, id);
|
||||
} catch (error) { return reject(error); }
|
||||
finally { entered = false; }
|
||||
}
|
||||
return Object.freeze({ getSnapshot: () => state,getQueries:(document=state.document)=>{const value=queriesByDocument.get(document);if(!value)throw fault('query_snapshot_unavailable');return value;}, registry: options.registry, dispatch, transaction, can,
|
||||
/** Coordinator entry: document, amended history and outbound fact share one Root. */
|
||||
resolveCreationSize(effect: CreationSizeEffect, expectedRootRevision: number): Result {
|
||||
try { assertOpen(); } catch (error) { return reject(error); }
|
||||
entered = true;
|
||||
try {
|
||||
if (expectedRootRevision !== state.rootRevision) throw fault('stale_plan');
|
||||
validateCreationSizeEffect(effect);
|
||||
const plan = planCreationSize(state.document, state.history, options.registry, effect.entryId, effect.target, effect.size);
|
||||
const after = plan.document.elements.get(effect.target.id)!;
|
||||
if (after.structure !== 'node') throw fault('creation_size_target_changed');
|
||||
const allowed = options.policy?.({ type: 'element.geometry', target: effect.target, patch: { width: after.geometry.width, height: after.geometry.height } }, state.document);
|
||||
if (allowed && !allowed.enabled) throw fault(allowed.code, allowed.targets);
|
||||
if (!plan.changes.length) return Object.freeze({ status: 'applied', revision: state.revision, operationIds: Object.freeze([]) });
|
||||
return publish(plan.document, plan.changes, 'asset-resolution', plan.history, effect.entryId, freeze(clone(effect)));
|
||||
} catch (error) { return reject(error); }
|
||||
finally { entered = false; }
|
||||
},
|
||||
subscribe(listener: (commit: Commit, snapshot: Snapshot<Runtime>) => void) { if (disposed) throw fault('editor_disposed'); listeners.add(listener); return () => { listeners.delete(listener); }; },
|
||||
/** Engine-only metadata commit, sharing the document/history publication boundary. */
|
||||
updateRuntime(reduce: (runtime: Runtime) => Runtime, expectedRootRevision = state.rootRevision): Result {
|
||||
try { assertOpen(); } catch (error) { return reject(error); }
|
||||
entered = true;
|
||||
try {
|
||||
if (expectedRootRevision !== state.rootRevision) throw fault('stale_plan');
|
||||
const runtime = reduce(state.runtime);
|
||||
if (isThenable(runtime)) throw fault('async_transaction');
|
||||
if (runtime !== state.runtime) {
|
||||
const nextState=prepareState(Object.freeze({ ...state, rootRevision: state.rootRevision + 1, runtime })),nextQueries=bindCoverage(nextState,queries);
|
||||
state=nextState;queries=nextQueries;queriesByDocument.set(state.document,queries);
|
||||
emit(freeze({ revision: state.revision, origin: 'ack', changes: [], operationIds: [] }));
|
||||
}
|
||||
return Object.freeze({ status: 'applied', revision: state.revision, operationIds: Object.freeze([]) });
|
||||
} catch (error) { return reject(error); }
|
||||
finally { entered = false; }
|
||||
},
|
||||
/** Engine-only canonical proposal: validation and runtime state publish as one root. */
|
||||
reconcile(document: Document, runtime: Runtime, expectedRootRevision: number, replacement?: { readonly history: HistoryState; readonly operations: ReadonlyMap<Id,OperationIntent> }): Result {
|
||||
try { assertOpen(); } catch (error) { return reject(error); }
|
||||
entered = true;
|
||||
try {
|
||||
if (expectedRootRevision !== state.rootRevision) throw fault('stale_plan');
|
||||
if (document.projectId !== state.document.projectId) throw fault('project_mismatch');
|
||||
const next = createDocument(document.projectId, [...document.canvases.values()], [...document.elements.values()], document.tombstones);
|
||||
validateDocument(next, options.registry);
|
||||
let nextSequence=sequence;
|
||||
if(replacement){
|
||||
const {history,operations}=replacement,ids=[...history.undo,...history.redo];
|
||||
if(new Set(ids).size!==ids.length||ids.length!==history.entries.size||ids.some(id=>!history.entries.has(id)))throw fault('invalid_history');
|
||||
for(const [id,intent]of state.operations)if(!equal(intent,operations.get(id)))throw fault('operation_identity_conflict',[id]);
|
||||
for(const [id,intent]of operations){if(id!==intent.id||!Number.isSafeInteger(intent.sequence)||intent.sequence<1)throw fault('invalid_operation_sequence');nextSequence=Math.max(nextSequence,intent.sequence);}
|
||||
for(const [id,entry]of history.entries)if(entry.id!==id||entry.operationIds.some(operationId=>!operations.has(operationId)))throw fault('invalid_history');
|
||||
}
|
||||
const changed = !documentsEqual(next, state.document);
|
||||
const nextState=prepareState(Object.freeze({ ...state, ...(replacement?{history:replacement.history,operations:replacement.operations}:{}), document: changed ? next : state.document, runtime, revision: state.revision + Number(changed), rootRevision: state.rootRevision + 1 })),nextQueries=bindCoverage(nextState,changed?advanceDocumentQueries(queries,next,options.registry,state.revision+1):queries);
|
||||
state=nextState;queries=nextQueries;queriesByDocument.set(state.document,queries);
|
||||
sequence=nextSequence;
|
||||
emit(freeze({ revision: state.revision, origin: 'ack', changes: [], operationIds: [] }));
|
||||
return Object.freeze({ status: 'applied', revision: state.revision, operationIds: Object.freeze([]) });
|
||||
} catch (error) { return reject(error); }
|
||||
finally { entered = false; }
|
||||
},
|
||||
history: Object.freeze({ undo: () => traverse('undo'), redo: () => traverse('redo'), canUndo: () => !!state.history.undo.length, canRedo: () => !!state.history.redo.length }),
|
||||
dispose() { disposed = true; listeners.clear(); },
|
||||
});
|
||||
}
|
||||
function documentsEqual(a: Document, b: Document): boolean {
|
||||
if(a===b)return true;
|
||||
if(a.canvases.size!==b.canvases.size||a.elements.size!==b.elements.size||a.tombstones.size!==b.tombstones.size)return false;
|
||||
return equal([...a.canvases], [...b.canvases]) && equal([...a.elements], [...b.elements]) && equal([...a.tombstones], [...b.tombstones]);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import type {ElementRecord,ComponentDefinition} from './types.ts';
|
||||
import {detachJsonData} from './json-data.ts';
|
||||
|
||||
// Provenance comes from detaching JSON data, never from an external freeze or
|
||||
// document envelope. This does not assert record validity or registry support.
|
||||
const owned=new WeakSet<object>();
|
||||
export function detachElementData(record:ElementRecord):ElementRecord{
|
||||
if(record&&typeof record==='object'&&owned.has(record))return record;
|
||||
const value=detachJsonData(record);
|
||||
if(value&&typeof value==='object')owned.add(value);
|
||||
return value;
|
||||
}
|
||||
export const hasOwnedElementData=(record:ElementRecord)=>!!record&&typeof record==='object'&&owned.has(record);
|
||||
|
||||
// Framework-owned validators depend only on detached immutable data. Custom
|
||||
// validators remain live, including their method receiver and external context.
|
||||
const builtinValidators=new WeakSet<Function>(),dataProofs=new WeakMap<object,ComponentDefinition['validate']>();
|
||||
export function builtinDataValidator<T extends ComponentDefinition['validate']>(validate:T):T{builtinValidators.add(validate);return validate;}
|
||||
export function componentDataValid(record:ElementRecord,definition:ComponentDefinition):boolean{
|
||||
const validate=definition.validate,reusable=hasOwnedElementData(record)&&builtinValidators.has(validate);
|
||||
if(reusable&&dataProofs.get(record)===validate)return true;
|
||||
const valid=Reflect.apply(validate,definition,[record.data]);
|
||||
if(valid&&reusable)dataProofs.set(record,validate);
|
||||
return valid;
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import {builtinDataValidator} from './element-data.ts';
|
||||
import {rectangleGeometry,bezierChainSegments,type BezierChain} from './geometry.ts';
|
||||
import type { CapabilityHandler, ComponentDefinition, Data, FieldChange, Geometry, Json } from './types.ts';
|
||||
import { defineComponent, defineFeature } from './registry.ts';
|
||||
import { presentationUnitsValid, unitScale } from './units.ts';
|
||||
import { equal, fault, validJson } from './values.ts';
|
||||
|
||||
const geometryHandler = (allowed: readonly (keyof Geometry)[], constrain?: (geometry: Geometry) => Geometry): CapabilityHandler => ({ element, input }) => {
|
||||
if (element.structure !== 'node') throw fault('unsupported_capability', [element.id]);
|
||||
const patch = input.patch as Partial<Geometry>;
|
||||
if (!patch || Object.values(patch).some(value => typeof value !== 'number' || !Number.isFinite(value)) || Object.keys(patch).some(key => !allowed.includes(key as keyof Geometry))) throw fault('invalid_geometry_patch', [element.id]);
|
||||
const after = constrain ? constrain({ ...element.geometry, ...patch }) : { ...element.geometry, ...patch };
|
||||
return equal(element.geometry, after) ? [] : [{ kind: 'field', target: { id: element.id, incarnation: element.incarnation }, field: 'geometry', before: element.geometry, after }];
|
||||
};
|
||||
export const movable = () => defineFeature('geometry.move', { 'geometry.move': geometryHandler(['x', 'y']) });
|
||||
export const resizable = (inputOptions: { min?: number; max?: number; aspect?: boolean } = {}) => {
|
||||
const options = Object.freeze({ ...inputOptions });
|
||||
if (options.min !== undefined && (!Number.isFinite(options.min) || options.min <= 0) || options.max !== undefined && (!Number.isFinite(options.max) || options.max < (options.min ?? 1))) throw fault('invalid_resize_constraint');
|
||||
return defineFeature('geometry.resize', {
|
||||
'geometry.resize': ({ element, input }) => {
|
||||
if (element.structure !== 'node') throw fault('unsupported_capability', [element.id]);
|
||||
const patch = input.patch as Partial<Geometry>;
|
||||
return geometryHandler(['width', 'height'], geometry => {
|
||||
const units=unitScale(element.data.presentation),min=(options.min??1)*units,max=(options.max??100000)*units;
|
||||
let width = Math.min(max, Math.max(min, geometry.width)), height = Math.min(max, Math.max(min, geometry.height));
|
||||
if (options.aspect) {
|
||||
const ratio = element.geometry.width / element.geometry.height;
|
||||
if (patch.width !== undefined) height = width / ratio; else width = height * ratio;
|
||||
if (width < min || height < min || width > max || height > max) throw fault('aspect_constraint', [element.id]);
|
||||
}
|
||||
return { ...geometry, width, height };
|
||||
})({ element, input });
|
||||
},
|
||||
});
|
||||
};
|
||||
export const rotatable = () => defineFeature('geometry.rotate', { 'geometry.rotate': geometryHandler(['rotation']) });
|
||||
export const editable = () => defineFeature('content.edit', {
|
||||
'content.edit': ({ element, input }) => {
|
||||
const patch = input.patch as Data;
|
||||
if (!patch || !validJson(patch) || Array.isArray(patch)) throw fault('invalid_data_patch', [element.id]);
|
||||
return Object.entries(patch).flatMap(([key, after]): FieldChange[] => {
|
||||
const before = element.data[key] ?? null;
|
||||
return Object.hasOwn(element.data, key) && equal(before, after) ? [] : [{ kind: 'field', target: { id: element.id, incarnation: element.incarnation }, field: `data.${key}`, before, after, beforePresent: Object.hasOwn(element.data, key), afterPresent: true }];
|
||||
});
|
||||
},
|
||||
});
|
||||
const textData = builtinDataValidator((data: Data) => presentationUnitsValid(data.presentation) && validJson(data) && typeof data.title === 'string' && typeof data.text === 'string');
|
||||
export function builtinComponents(): readonly ComponentDefinition[] {
|
||||
return ['text', 'image', 'script', 'plain-text', 'plain-image', 'icon', 'frame'].map(id => defineComponent({
|
||||
id, dataVersion: 1, structure: 'node', validate: textData,
|
||||
features: [movable(), resizable(), ...(id === 'frame' ? [] : [rotatable()]), editable()],
|
||||
resourceFields: ['image', 'plain-image'].includes(id) ? ['assetId','asset'] : [],
|
||||
geometry:rectangleGeometry,copy: { references: id === 'frame' ? [{path:['ports','*','nodeId'],target:'element',external:'reject'}] : [], omit: [['taskId'], ['runId'], ['progress'], ['runStatus']] },
|
||||
})).concat([defineComponent({ id: 'connection', dataVersion: 1, structure: 'connection', validate: builtinDataValidator((data:Data) => validJson(data)), features: [editable(), connectionEditable()], resourceFields: [],geometry:{kind:'connection',version:1,strokeWidth:({element})=>{const appearance=element.data.appearance;return (appearance&&typeof appearance==='object'&&!Array.isArray(appearance)?appearance.width:undefined) as number||1.5;},segments:context=>{if(context.displayPath===undefined||!connectionPathValid(context.displayPath))throw fault('unsupported_connection_geometry');return bezierChainSegments(context.source,context.target,context.displayPath as unknown as BezierChain|null);}}, copy: { references: [], omit: [] } })]);
|
||||
}
|
||||
|
||||
export function connectionPathValid(path: Json): boolean {
|
||||
if (path === null) return true;
|
||||
if (typeof path !== 'object' || Array.isArray(path) || path.type !== 'bezier-chain' || !Array.isArray(path.knots) || path.knots.length > 64) return false;
|
||||
const point = (value: Json | undefined) => value && typeof value === 'object' && !Array.isArray(value) && typeof value.x === 'number' && typeof value.y === 'number' && Number.isFinite(value.x) && Number.isFinite(value.y);
|
||||
if (path.startControl !== undefined && !point(path.startControl) || path.endControl !== undefined && !point(path.endControl)) return false;
|
||||
const ids = new Set<string>();
|
||||
for (const knot of path.knots) {
|
||||
if (!knot || typeof knot !== 'object' || Array.isArray(knot) || typeof knot.id !== 'string' || !knot.id || ids.has(knot.id) || !['corner', 'smooth', 'symmetric'].includes(knot.mode as string) || !point(knot.position) || !point(knot.in) || !point(knot.out)) return false;
|
||||
ids.add(knot.id);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
export const connectionEditable = () => defineFeature('connection.path', { 'connection.edit': ({ element, input }) => {
|
||||
const path = (input.patch as { path?: Json }).path;
|
||||
if (element.structure !== 'connection' || !connectionPathValid(element.path)) throw fault('unsupported_connection_path', [element.id]);
|
||||
if (path === undefined || !validJson(path) || !connectionPathValid(path)) throw fault('invalid_connection_path', [element.id]);
|
||||
return equal(element.path, path) ? [] : [{ kind: 'field', target: { id: element.id, incarnation: element.incarnation }, field: 'path', before: element.path, after: path }];
|
||||
} });
|
||||
@@ -0,0 +1,10 @@
|
||||
import type {Geometry,Point} from './types.ts';
|
||||
export type Rect={readonly x:number;readonly y:number;readonly width:number;readonly height:number};
|
||||
export type Cubic=readonly[Point,Point,Point,Point];
|
||||
export type Connector={readonly point:Point;readonly normal:Point};
|
||||
export type BezierChain={readonly knots:readonly{readonly position:Point;readonly in:Point;readonly out:Point}[];readonly startControl?:Point;readonly endControl?:Point};
|
||||
export function rectangleBounds(g:Geometry):Rect{const angle=g.rotation*Math.PI/180,c=Math.abs(Math.cos(angle)),s=Math.abs(Math.sin(angle)),width=g.width*c+g.height*s,height=g.width*s+g.height*c;return{x:g.x+g.width/2-width/2,y:g.y+g.height/2-height/2,width,height};}
|
||||
export function hitRectangle(g:Geometry,point:Point,tolerance=0):boolean{const angle=-g.rotation*Math.PI/180,c=Math.cos(angle),s=Math.sin(angle),dx=point.x-g.x-g.width/2,dy=point.y-g.y-g.height/2,x=dx*c-dy*s,y=dx*s+dy*c,ox=Math.max(0,Math.abs(x)-g.width/2),oy=Math.max(0,Math.abs(y)-g.height/2);return Math.hypot(ox,oy)<=tolerance+Number.EPSILON*64*Math.max(1,Math.abs(point.x),Math.abs(point.y),g.width,g.height);}
|
||||
export const rectangleGeometry=Object.freeze({kind:'node' as const,version:1 as const,dataDependencies:Object.freeze([] as string[]),bounds:({geometry}:{geometry:Geometry})=>rectangleBounds(geometry),hit:({geometry,point,tolerance}:{geometry:Geometry;point:Point;tolerance:number})=>hitRectangle(geometry,point,tolerance)});
|
||||
/** Shared by the built-in model protocol and the SVG renderer; all arguments are in one display space. */
|
||||
export function bezierChainSegments(from:Connector,to:Connector,path?:BezierChain|null):Cubic[]{const reach=Math.min(240,Math.max(40,Math.hypot(to.point.x-from.point.x,to.point.y-from.point.y)*.4)),knots=path?.knots??[],anchors=[from.point,...knots.map(knot=>knot.position),to.point],add=(a:Point,b:Point)=>({x:a.x+b.x,y:a.y+b.y});return anchors.slice(0,-1).map((point,index)=>[point,add(point,index?knots[index-1].out:path?.startControl??{x:from.normal.x*reach,y:from.normal.y*reach}),add(anchors[index+1],index<knots.length?knots[index].in:path?.endControl??{x:to.normal.x*reach,y:to.normal.y*reach}),anchors[index+1]]);}
|
||||
@@ -0,0 +1,30 @@
|
||||
import type {Document,Geometry,Id,Registry} from './types.ts';
|
||||
import {fault} from './values.ts';
|
||||
import {unitScale} from './units.ts';
|
||||
|
||||
type PlacementStep={canvasId:Id;parentCanvasId:Id;entryId:Id;incarnation:number;componentId:string;dataVersion:number;geometry:Geometry;contentScale:number;unitScale:number};
|
||||
|
||||
/** A short-lived reader of the visible owner chain relative to one view, not the project root.
|
||||
* Cache by owner only during this admission/publication, so multi-selection shares ancestry work. */
|
||||
export function createGesturePlacementReader(document:Document,viewCanvasId:Id,registry:Registry){
|
||||
const values=new Map<Id,readonly PlacementStep[]>();
|
||||
return(ownerCanvasId:Id):readonly PlacementStep[]=>{
|
||||
const cached=values.get(ownerCanvasId);if(cached)return cached;
|
||||
if(!document.canvases.has(viewCanvasId))throw fault('canvas_missing',[viewCanvasId]);
|
||||
const chain:PlacementStep[]=[],seen=new Set<Id>();let id=ownerCanvasId;
|
||||
while(id!==viewCanvasId){
|
||||
if(seen.has(id))throw fault('canvas_cycle',[id]);seen.add(id);
|
||||
const parent=document.canvases.get(id)?.parent;
|
||||
if(!parent)throw fault('target_outside_view',[ownerCanvasId]);
|
||||
const entry=document.elements.get(parent.entryElementId);
|
||||
// A future/unknown entry is a collapsed placeholder; never interpret its payload.
|
||||
if(!entry||entry.structure!=='node'||registry.resolve(entry)!=='known')throw fault('target_hidden',[ownerCanvasId]);
|
||||
const raw=entry.data.presentation,presentation=raw&&typeof raw==='object'&&!Array.isArray(raw)?raw:{};
|
||||
if(presentation.collapsed===true)throw fault('target_hidden',[ownerCanvasId]);
|
||||
const contentScale=typeof presentation.contentScale==='number'&&presentation.contentScale>0?presentation.contentScale:1;
|
||||
chain.push({canvasId:id,parentCanvasId:parent.canvasId,entryId:entry.id,incarnation:entry.incarnation,componentId:entry.componentId,dataVersion:entry.dataVersion,geometry:entry.geometry,contentScale,unitScale:unitScale(raw)});
|
||||
id=parent.canvasId;
|
||||
}
|
||||
values.set(ownerCanvasId,chain);return chain;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
import type { Editor } from './editor.ts';
|
||||
import type { Availability, Capability, ElementRecord, Geometry, Id, Json, Result, Target } from './types.ts';
|
||||
import { geometryValid, targetElement } from './document.ts';
|
||||
import {createGesturePlacementReader} from './gesture-placement.ts';
|
||||
import { clone, equal, fault, freeze, readMap, readSet, reject, validJson } from './values.ts';
|
||||
|
||||
export type ViewState = { readonly id: Id; readonly canvasId: Id; readonly selection: ReturnType<typeof readSet<Id>>; readonly viewport: { readonly x: number; readonly y: number; readonly zoom: number }; readonly tool: 'select' | 'pan'; readonly modes: ReadonlyMap<Id, string> };
|
||||
export type GestureState = { readonly token: number; readonly viewId: Id; readonly targets: readonly Target[]; readonly preview: ReadonlyMap<Id, Geometry>; readonly phase: 'active' | 'committing' };
|
||||
export type ConnectionGesture = { readonly token: number; readonly viewId: Id; readonly target: Target; readonly path: Json; readonly phase: 'active' | 'committing' };
|
||||
export type SessionSnapshot = { readonly views: ReadonlyMap<Id, ViewState>; readonly gesture: GestureState | null; readonly connectionGesture: ConnectionGesture | null; readonly lastCancelReason: string | null };
|
||||
|
||||
/** Transient interaction state belongs to views; no pointer event enters the ledger. */
|
||||
export function createSessions(editor: Editor) {
|
||||
let snapshot: SessionSnapshot = Object.freeze({ views: readMap(new Map<Id, ViewState>()), gesture: null, connectionGesture: null, lastCancelReason: null });
|
||||
let baseline = new Map<Id, ElementRecord>(), connectionBaseline: ElementRecord | undefined, disposed = false, nextGestureToken = 0;
|
||||
// Compare adjacent published identities without duplicating the per-view mode table.
|
||||
let publishedDocument=editor.getSnapshot().document;
|
||||
const listeners = new Set<() => void>();
|
||||
const notify = () => { for (const listener of [...listeners]) try { listener(); } catch { /* A renderer cannot invalidate model commits. */ } };
|
||||
function publishViews(views:Map<Id,ViewState>,cancelOwnedView?:Id,reason='view_interrupted'){
|
||||
const owned=cancelOwnedView!==undefined&&(snapshot.gesture?.viewId===cancelOwnedView||snapshot.connectionGesture?.viewId===cancelOwnedView);
|
||||
if(owned){baseline.clear();connectionBaseline=undefined;}
|
||||
snapshot=Object.freeze({...snapshot,views:readMap(views),...(owned?{gesture:null,connectionGesture:null,lastCancelReason:reason}:{})});notify();
|
||||
}
|
||||
function setView(view: ViewState,cancelOwnedReason?:string) { const views = new Map(snapshot.views); views.set(view.id, view); publishViews(views,cancelOwnedReason?view.id:undefined,cancelOwnedReason); }
|
||||
function cancel(reason = 'canceled') {
|
||||
if (!snapshot.gesture && !snapshot.connectionGesture) return;
|
||||
baseline.clear(); connectionBaseline = undefined; snapshot = Object.freeze({ ...snapshot, gesture: null, connectionGesture: null, lastCancelReason: reason }); notify();
|
||||
}
|
||||
const detachEditor = editor.subscribe(() => {
|
||||
const document = editor.getSnapshot().document,beforeDocument=publishedDocument;publishedDocument=document;
|
||||
const views = new Map(snapshot.views); let changed = false;
|
||||
for (const [id, view] of views) {
|
||||
const canvasMissing = !document.canvases.has(view.canvasId);
|
||||
const selection = [...view.selection].filter(target => document.elements.has(target)), modes = new Map([...view.modes].filter(([target]) => {
|
||||
const before=beforeDocument.elements.get(target),after=document.elements.get(target);
|
||||
return !!before&&!!after&&before.incarnation===after.incarnation&&before.ownerCanvasId===after.ownerCanvasId&&before.structure===after.structure&&before.componentId===after.componentId&&before.dataVersion===after.dataVersion;
|
||||
}));
|
||||
if (canvasMissing || selection.length !== view.selection.size || modes.size !== view.modes.size) { views.set(id, freeze({ ...view, canvasId: canvasMissing ? [...document.canvases.values()].find(canvas => !canvas.parent)!.id : view.canvasId, selection: readSet(canvasMissing ? [] : selection), modes: readMap(canvasMissing ? new Map() : modes) })); changed = true; }
|
||||
}
|
||||
let cancelReason:string|undefined;
|
||||
const active=snapshot.gesture?.phase==='active'?snapshot.gesture:snapshot.connectionGesture?.phase==='active'?snapshot.connectionGesture:undefined;
|
||||
const activeView=active?snapshot.views.get(active.viewId):undefined;
|
||||
if(activeView&&!document.canvases.has(activeView.canvasId))cancelReason='canvas_changed';
|
||||
const oldPlacement=activeView?createGesturePlacementReader(beforeDocument,activeView.canvasId,editor.registry):undefined,newPlacement=activeView?createGesturePlacementReader(document,activeView.canvasId,editor.registry):undefined;
|
||||
const placementChanged=(owner:Id)=>{if(beforeDocument===document)return false;try{return !oldPlacement||!newPlacement||!equal(oldPlacement(owner),newPlacement(owner));}catch{return true;}};
|
||||
if (!cancelReason&&snapshot.connectionGesture?.phase === 'active') {
|
||||
const current = document.elements.get(snapshot.connectionGesture.target.id), before = connectionBaseline;
|
||||
if (!current || current.structure !== 'connection' || before?.structure !== 'connection' || current.incarnation !== before.incarnation || current.ownerCanvasId !== before.ownerCanvasId || current.componentId!==before.componentId || current.dataVersion!==before.dataVersion || !equal(current.path, before.path)) cancelReason='target_changed';
|
||||
else if(placementChanged(current.ownerCanvasId))cancelReason='coordinate_context_changed';
|
||||
else if (!editor.can({ type: 'connection.path', target: snapshot.connectionGesture.target, path: current.path }).enabled) cancelReason='permission_changed';
|
||||
}
|
||||
if (snapshot.gesture?.phase === 'active'&&!cancelReason) for (const target of snapshot.gesture.targets) {
|
||||
const before = baseline.get(target.id), current = document.elements.get(target.id);
|
||||
if (!current || current.structure !== 'node' || before?.structure !== 'node' || current.incarnation !== before.incarnation || current.ownerCanvasId !== before.ownerCanvasId || current.componentId!==before.componentId || current.dataVersion!==before.dataVersion || !equal(current.geometry, before.geometry)) { cancelReason='target_changed'; break; }
|
||||
if(placementChanged(current.ownerCanvasId)){cancelReason='coordinate_context_changed';break;}
|
||||
if (!editor.can({ type: 'element.geometry', target, patch: {} }).enabled) { cancelReason='permission_changed'; break; }
|
||||
}
|
||||
if(changed||cancelReason){
|
||||
if(cancelReason){baseline.clear();connectionBaseline=undefined;}
|
||||
snapshot=Object.freeze({...snapshot,...(changed?{views:readMap(views)}:{}),...(cancelReason?{gesture:null,connectionGesture:null,lastCancelReason:cancelReason}:{})});notify();
|
||||
}
|
||||
});
|
||||
function view(id: Id) { const found = snapshot.views.get(id); if (!found || disposed) throw fault('view_detached', [id]); return found; }
|
||||
function update(viewId: Id, target: Target, patch: Partial<Geometry>): Availability {
|
||||
try {
|
||||
view(viewId);
|
||||
if (snapshot.gesture?.viewId !== viewId || !snapshot.gesture.targets.some(value => value.id === target.id && value.incarnation === target.incarnation)) throw fault('gesture_not_owned', [target.id]);
|
||||
const original = targetElement(editor.getSnapshot().document, target);
|
||||
if (original.structure !== 'node') throw fault('unsupported_capability', [target.id]);
|
||||
if (Object.keys(patch).some(key => !['x', 'y', 'width', 'height', 'rotation'].includes(key))) throw fault('invalid_geometry_patch', [target.id]);
|
||||
let element: ElementRecord = freeze({ ...original, geometry: snapshot.gesture.preview.get(target.id) || original.geometry });
|
||||
const parts: [Capability, Partial<Geometry>][] = [
|
||||
['geometry.move', Object.fromEntries(Object.entries(patch).filter(([key]) => key === 'x' || key === 'y'))],
|
||||
['geometry.resize', Object.fromEntries(Object.entries(patch).filter(([key]) => key === 'width' || key === 'height'))],
|
||||
['geometry.rotate', Object.fromEntries(Object.entries(patch).filter(([key]) => key === 'rotation'))],
|
||||
];
|
||||
for (const [capability, value] of parts) if (Object.keys(value).length) {
|
||||
if (!editor.registry.supports(element, capability)) throw fault('unsupported_capability', [target.id]);
|
||||
const changes = editor.registry.handlers.get(element.componentId)!.get(capability)!({ element, input: freeze({ patch: value }) });
|
||||
for (const change of changes) {
|
||||
if (change.field !== 'geometry' || change.target.id !== target.id || change.target.incarnation !== target.incarnation) throw fault('invalid_handler_change', [target.id]);
|
||||
const geometry = change.after as Geometry;
|
||||
if (!geometryValid(geometry)) throw fault('invalid_geometry', [target.id]);
|
||||
element = freeze({ ...element, geometry });
|
||||
}
|
||||
}
|
||||
const preview = new Map(snapshot.gesture.preview); preview.set(target.id, (element as ElementRecord & { structure: 'node' }).geometry);
|
||||
snapshot = Object.freeze({ ...snapshot, gesture: freeze({ ...snapshot.gesture, preview: readMap(preview) }) }); notify();
|
||||
return { enabled: true };
|
||||
} catch (error) { const result = reject(error); return { enabled: false, code: result.code, targets: result.targets }; }
|
||||
}
|
||||
return Object.freeze({
|
||||
getSnapshot: () => snapshot,
|
||||
attach(id: Id, canvasId = [...editor.getSnapshot().document.canvases.values()].find(canvas => !canvas.parent)!.id) {
|
||||
if (disposed || snapshot.views.has(id)) throw fault('duplicate_view', [id]);
|
||||
if (!editor.getSnapshot().document.canvases.has(canvasId)) throw fault('canvas_missing', [canvasId]);
|
||||
setView(freeze({ id, canvasId, selection: readSet<Id>([]), viewport: { x: 0, y: 0, zoom: 1 }, tool: 'select', modes: readMap(new Map<Id, string>()) }));
|
||||
let attached=true;return () => { if(!attached)return;attached=false;if(!snapshot.views.has(id))return;const views=new Map(snapshot.views);views.delete(id);publishViews(views,id,'view_detached'); };
|
||||
},
|
||||
canvas(id: Id, canvasId: Id) {
|
||||
const current = view(id);
|
||||
if (!editor.getSnapshot().document.canvases.has(canvasId)) throw fault('canvas_missing', [canvasId]);
|
||||
setView(freeze({ ...current, canvasId, selection: readSet<Id>([]), modes: readMap(new Map<Id, string>()) }),'canvas_changed');
|
||||
},
|
||||
select(id: Id, ids: readonly Id[]) {
|
||||
const current = view(id), document = editor.getSnapshot().document;
|
||||
for (const target of ids) if (!document.elements.has(target)) throw fault('element_missing', [target]);
|
||||
setView(freeze({ ...current, selection: readSet(ids) }));
|
||||
},
|
||||
viewport(id: Id, viewport: ViewState['viewport']) {
|
||||
if (![viewport.x, viewport.y, viewport.zoom].every(Number.isFinite) || viewport.zoom <= 0) throw fault('invalid_viewport');
|
||||
setView(freeze({ ...view(id), viewport: { ...viewport } }));
|
||||
},
|
||||
tool(id: Id, tool: ViewState['tool']) { const current=view(id);if(tool!=='select'&&tool!=='pan')throw fault('invalid_tool');if(current.tool===tool)return;setView(freeze({ ...current, tool }),'tool_changed'); },
|
||||
mode(id: Id, target: Id, mode: string | null) { const current = view(id);if(mode!==null&&typeof mode!=='string')throw fault('invalid_mode');if(mode&&!editor.getSnapshot().document.elements.has(target))throw fault('element_missing',[target]);if((current.modes.get(target)??null)===(mode||null))return;const modes = new Map(current.modes); if (mode) modes.set(target, mode); else modes.delete(target); setView(freeze({ ...current, modes: readMap(modes) })); },
|
||||
begin(viewId: Id, targets: readonly Target[]): Availability {
|
||||
try {
|
||||
if(view(viewId).tool!=='select')throw fault('tool_not_select');
|
||||
if (snapshot.gesture || snapshot.connectionGesture || !targets.length || new Set(targets.map(target => target.id)).size !== targets.length) throw fault('gesture_busy');
|
||||
const placement=createGesturePlacementReader(editor.getSnapshot().document,view(viewId).canvasId,editor.registry);
|
||||
const plannedBaseline = new Map(targets.map(target => { const element = targetElement(editor.getSnapshot().document, target); if (element.structure !== 'node' || editor.registry.resolve(element) !== 'known') throw fault('unsupported_component', [target.id]); return [target.id, element]; }));
|
||||
for (const target of targets) { placement(plannedBaseline.get(target.id)!.ownerCanvasId);const allowed = editor.can({ type: 'element.geometry', target, patch: {} }); if (!allowed.enabled) throw fault(allowed.code, allowed.targets); }
|
||||
baseline=plannedBaseline;
|
||||
snapshot = Object.freeze({ ...snapshot, lastCancelReason: null, gesture: freeze({ token: ++nextGestureToken, viewId, targets: targets.map(target => ({ ...target })), preview: readMap(new Map<Id, Geometry>()), phase: 'active' as const }) }); notify();
|
||||
return { enabled: true };
|
||||
} catch (error) { const result = reject(error); return { enabled: false, code: result.code, targets: result.targets }; }
|
||||
},
|
||||
beginConnection(viewId: Id, target: Target): Availability {
|
||||
try {
|
||||
if(view(viewId).tool!=='select')throw fault('tool_not_select');
|
||||
if (snapshot.gesture || snapshot.connectionGesture) throw fault('gesture_busy');
|
||||
const element = targetElement(editor.getSnapshot().document, target);
|
||||
if (element.structure !== 'connection') throw fault('unsupported_capability', [target.id]);
|
||||
createGesturePlacementReader(editor.getSnapshot().document,view(viewId).canvasId,editor.registry)(element.ownerCanvasId);
|
||||
const allowed = editor.can({ type: 'connection.path', target, path: element.path });
|
||||
if (!allowed.enabled) throw fault(allowed.code, allowed.targets);
|
||||
connectionBaseline = element;
|
||||
snapshot = Object.freeze({ ...snapshot, lastCancelReason: null, connectionGesture: freeze({ token: ++nextGestureToken, viewId, target: { ...target }, path: element.path, phase: 'active' as const }) }); notify();
|
||||
return { enabled: true };
|
||||
} catch (error) { const result = reject(error); return { enabled: false, code: result.code, targets: result.targets }; }
|
||||
},
|
||||
updateConnection(viewId: Id, path: Json): Availability {
|
||||
try {
|
||||
view(viewId);
|
||||
if (snapshot.connectionGesture?.viewId !== viewId) throw fault('gesture_not_owned');
|
||||
if (!validJson(path)) throw fault('invalid_connection_path');
|
||||
snapshot = Object.freeze({ ...snapshot, connectionGesture: freeze({ ...snapshot.connectionGesture, path: clone(path) }) }); notify();
|
||||
return { enabled: true };
|
||||
} catch (error) { const result = reject(error); return { enabled: false, code: result.code, targets: result.targets }; }
|
||||
},
|
||||
commitConnection(viewId: Id): Result {
|
||||
try {
|
||||
view(viewId);
|
||||
const gesture = snapshot.connectionGesture;
|
||||
if (!gesture || gesture.viewId !== viewId) throw fault('gesture_not_owned');
|
||||
snapshot = Object.freeze({ ...snapshot, connectionGesture: freeze({ ...gesture, phase: 'committing' as const }) });
|
||||
const result = editor.dispatch({ type: 'connection.path', target: gesture.target, path: gesture.path }, { label: 'Edit connection' });
|
||||
cancel(result.status === 'rejected' ? result.code : 'committed'); return result;
|
||||
} catch (error) { cancel('commit_failed'); return reject(error); }
|
||||
},
|
||||
update,
|
||||
commit(viewId: Id): Result {
|
||||
try {
|
||||
view(viewId);
|
||||
const gesture = snapshot.gesture;
|
||||
if (!gesture || gesture.viewId !== viewId) throw fault('gesture_not_owned');
|
||||
snapshot = Object.freeze({ ...snapshot, gesture: freeze({ ...gesture, phase: 'committing' as const }) });
|
||||
const result = editor.transaction(tx => {
|
||||
for (const [id, geometry] of gesture.preview) {
|
||||
const original = baseline.get(id);
|
||||
if (!original || original.structure !== 'node') throw fault('target_changed', [id]);
|
||||
const patch = Object.fromEntries(Object.entries(geometry).filter(([key, value]) => !equal(original.geometry[key as keyof Geometry], value)));
|
||||
tx.dispatch({ type: 'element.geometry', target: { id, incarnation: original.incarnation }, patch });
|
||||
}
|
||||
}, { label: 'Transform' });
|
||||
cancel(result.status === 'rejected' ? result.code : 'committed');
|
||||
return result;
|
||||
} catch (error) { cancel('commit_failed'); return reject(error); }
|
||||
},
|
||||
cancel,
|
||||
effectiveGeometry(id: Id): Geometry | undefined {
|
||||
const element = editor.getSnapshot().document.elements.get(id);
|
||||
return snapshot.gesture?.preview.get(id) || (element?.structure === 'node' ? element.geometry : undefined);
|
||||
},
|
||||
subscribe(listener: () => void) { if (disposed) throw fault('sessions_disposed'); listeners.add(listener); return () => { listeners.delete(listener); }; },
|
||||
dispose() { if(disposed)return;disposed = true;detachEditor();baseline.clear();connectionBaseline=undefined;snapshot = Object.freeze({ ...snapshot, views: readMap(new Map<Id, ViewState>()),gesture:null,connectionGesture:null,lastCancelReason:'disposed' }); notify();listeners.clear(); },
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import type { Change, HistoryEntry, HistoryState, Id } from './types.ts';
|
||||
import { equal, fault, freeze, readMap, validJson } from './values.ts';
|
||||
|
||||
type Splice<T>={readonly offset:number;readonly beforeLength:number;readonly afterLength:number;readonly before:readonly T[];readonly after:readonly T[]};
|
||||
type Meta={readonly label:string;readonly groupId?:Id};
|
||||
type EntryPatch={readonly id:Id;readonly before:Meta|null;readonly after:Meta|null;readonly changes:Splice<Change>;readonly operationIds:Splice<Id>};
|
||||
export type HistoryTransition={readonly version:1;readonly rootRevision:number;readonly entryId?:Id;readonly before:{readonly undo:readonly Id[];readonly redo:readonly Id[]};readonly after:{readonly undo:readonly Id[];readonly redo:readonly Id[]};readonly entries:readonly EntryPatch[]};
|
||||
// Resource scans revisit the same transition across many durability cuts. Cache
|
||||
// only its flattened changes, never registry-dependent references or callbacks.
|
||||
const transitionChanges=new WeakMap<HistoryTransition,readonly Change[]>();
|
||||
const immutableData=new WeakSet<object>();
|
||||
function immutablePlainData(value:unknown,depth=0):boolean{
|
||||
if(depth>64)return false;
|
||||
if(value===null||typeof value!=='object')return typeof value!=='function';
|
||||
if(immutableData.has(value))return true;
|
||||
if(!Object.isFrozen(value)||Object.getPrototypeOf(value)!==(Array.isArray(value)?Array.prototype:Object.prototype))return false;
|
||||
if(Array.isArray(value))for(let i=0;i<value.length;i++)if(!Object.hasOwn(value,i))return false;
|
||||
// Inspect every own descriptor, including non-enumerable properties. A frozen
|
||||
// getter or a shallow-frozen container cannot establish reusable data.
|
||||
for(const key of Reflect.ownKeys(value)){
|
||||
const descriptor=Object.getOwnPropertyDescriptor(value,key)!;
|
||||
if(!Object.hasOwn(descriptor,'value')||!immutablePlainData(descriptor.value,depth+1))return false;
|
||||
}
|
||||
immutableData.add(value);return true;
|
||||
}
|
||||
const meta=(entry:HistoryEntry|undefined):Meta|null=>{if(!entry)return null;const{id:_id,changes:_changes,operationIds:_operations,...remaining}=entry;return remaining;};
|
||||
function splice<T>(before:readonly T[],after:readonly T[]):Splice<T>{
|
||||
let offset=0,suffix=0;while(offset<before.length&&offset<after.length&&equal(before[offset],after[offset]))offset++;
|
||||
while(suffix<before.length-offset&&suffix<after.length-offset&&equal(before[before.length-suffix-1],after[after.length-suffix-1]))suffix++;
|
||||
return{offset,beforeLength:before.length,afterLength:after.length,before:before.slice(offset,before.length-suffix),after:after.slice(offset,after.length-suffix)};
|
||||
}
|
||||
/** Store changed ranges only. Clearing redo/eviction still retains the removed entry records needed to reverse that transition. */
|
||||
export function describeHistoryTransition(before:HistoryState,after:HistoryState,rootRevision:number,entryId?:Id,recordNoop=false):HistoryTransition|undefined{
|
||||
const entries:EntryPatch[]=[];
|
||||
for(const id of new Set([...before.entries.keys(),...after.entries.keys()])){const a=before.entries.get(id),b=after.entries.get(id);if(equal(a,b))continue;entries.push({id,before:meta(a),after:meta(b),changes:splice(a?.changes??[],b?.changes??[]),operationIds:splice(a?.operationIds??[],b?.operationIds??[])});}
|
||||
if(!recordNoop&&!entries.length&&equal(before.undo,after.undo)&&equal(before.redo,after.redo))return undefined;
|
||||
return freeze({version:1,rootRevision,...(entryId?{entryId}:{}),before:{undo:before.undo,redo:before.redo},after:{undo:after.undo,redo:after.redo},entries});
|
||||
}
|
||||
export function validateHistoryTransition(value:unknown,checkChanges?:(value:unknown)=>unknown):asserts value is HistoryTransition{
|
||||
const object=(v:unknown):Record<string,unknown>=>{if(!v||typeof v!=='object'||Array.isArray(v))throw fault('invalid_history_transition');return v as Record<string,unknown>;};
|
||||
const integer=(v:unknown)=>{if(typeof v!=='number'||!Number.isSafeInteger(v)||v<0)throw fault('invalid_history_transition');};
|
||||
const id=(v:unknown)=>{if(typeof v!=='string'||!v)throw fault('invalid_history_transition');};
|
||||
const ids=(v:unknown):string[]=>{if(!Array.isArray(v))throw fault('invalid_history_transition');v.forEach(id);return v;};
|
||||
const raw=object(value);if(raw.version!==1)throw fault('history_transition_interpreter_unavailable');if(!validJson(value))throw fault('invalid_history_transition');integer(raw.rootRevision);if(raw.entryId!==undefined)id(raw.entryId);
|
||||
for(const side of [raw.before,raw.after]){const s=object(side),list=[...ids(s.undo),...ids(s.redo)];if(new Set(list).size!==list.length)throw fault('invalid_history_transition');}
|
||||
if(!Array.isArray(raw.entries))throw fault('invalid_history_transition');const seen=new Set<string>();
|
||||
for(const item of raw.entries){const p=object(item);id(p.id);if(seen.has(p.id as string))throw fault('invalid_history_transition');seen.add(p.id as string);
|
||||
for(const side of [p.before,p.after])if(side!==null){const m=object(side);if(typeof m.label!=='string'||['id','changes','operationIds'].some(key=>Object.hasOwn(m,key)))throw fault('invalid_history_transition');if(m.groupId!==undefined)id(m.groupId);}
|
||||
for(const key of ['changes','operationIds']){const patch=object(p[key]);for(const field of ['offset','beforeLength','afterLength'])integer(patch[field]);if(!Array.isArray(patch.before)||!Array.isArray(patch.after))throw fault('invalid_history_transition');const{offset,beforeLength,afterLength}=patch as unknown as Splice<unknown>;if(offset+patch.before.length>beforeLength||offset+patch.after.length>afterLength||beforeLength-patch.before.length!==afterLength-patch.after.length)throw fault('invalid_history_transition');if(key==='operationIds'){ids(patch.before);ids(patch.after);}else{checkChanges?.(patch.before);checkChanges?.(patch.after);}
|
||||
if(p.before===null&&beforeLength!==0||p.after===null&&afterLength!==0)throw fault('invalid_history_transition');
|
||||
}
|
||||
}
|
||||
}
|
||||
/** Conditional metadata-only replay. A mismatch leaves the supplied history untouched. */
|
||||
export function applyHistoryTransition(history:HistoryState,transition:HistoryTransition,direction:'forward'|'reverse'):HistoryState{
|
||||
validateHistoryTransition(transition);const reverse=direction==='reverse',source=reverse?transition.after:transition.before,destination=reverse?transition.before:transition.after;
|
||||
if(!equal({undo:history.undo,redo:history.redo},source))throw fault('history_transition_conflict');
|
||||
function apply<T>(current:readonly T[],patch:Splice<T>):readonly T[]{const expected=reverse?patch.after:patch.before,insert=reverse?patch.before:patch.after,length=reverse?patch.afterLength:patch.beforeLength;if(current.length!==length||!equal(current.slice(patch.offset,patch.offset+expected.length),expected))throw fault('history_transition_conflict');return[...current.slice(0,patch.offset),...insert,...current.slice(patch.offset+expected.length)];}
|
||||
const entries=new Map(history.entries);
|
||||
for(const patch of transition.entries){const entry=entries.get(patch.id),expected=reverse?patch.after:patch.before,next=reverse?patch.before:patch.after;if(!equal(meta(entry),expected))throw fault('history_transition_conflict');const changes=apply(entry?.changes??[],patch.changes),operationIds=apply(entry?.operationIds??[],patch.operationIds);if(next)entries.set(patch.id,freeze({id:patch.id,...next,changes,operationIds}));else entries.delete(patch.id);}
|
||||
const ids=[...destination.undo,...destination.redo];if(ids.length!==entries.size||ids.some(id=>!entries.has(id)))throw fault('history_transition_conflict');
|
||||
return Object.freeze({undo:freeze([...destination.undo]),redo:freeze([...destination.redo]),entries:readMap(entries)});
|
||||
}
|
||||
export function historyTransitionChanges(transition:HistoryTransition):readonly Change[]{
|
||||
const cached=transitionChanges.get(transition);if(cached)return cached;
|
||||
validateHistoryTransition(transition);
|
||||
const changes=transition.entries.flatMap(patch=>[...patch.changes.before,...patch.changes.after]);
|
||||
const ownRanges=Object.hasOwn(transition,'entries')&&transition.entries.every(patch=>Object.hasOwn(patch,'changes')&&Object.hasOwn(patch.changes,'before')&&Object.hasOwn(patch.changes,'after'));
|
||||
if(!ownRanges||!immutablePlainData(transition))return changes;
|
||||
const result=Object.freeze(changes);transitionChanges.set(transition,result);return result;
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { Change, Document, ElementRecord, Id, Registry } from './types.ts';
|
||||
import { applyChanges, validateDocument } from './document.ts';
|
||||
import { freeze } from './values.ts';
|
||||
|
||||
/** Pure history execution, also used to validate recovery proposals before publication. */
|
||||
export function planHistoryChanges(initial:Document,planned:readonly Change[],registry:Registry){
|
||||
const generations=new Map<Id,Map<number,number>>();
|
||||
const incarnation=(id:Id,value:number)=>{const mapping=generations.get(id);while(mapping?.has(value))value=mapping.get(value)!;return value;};
|
||||
const remap=(change:Change):Change=>{
|
||||
if(change.kind==='field'){const next=incarnation(change.target.id,change.target.incarnation);return next===change.target.incarnation?change:freeze({...change,target:{...change.target,incarnation:next}});}
|
||||
if(change.kind==='element'){const record=(value:ElementRecord|null)=>value?{...value,incarnation:incarnation(value.id,value.incarnation)}:null;return freeze({...change,before:record(change.before),after:record(change.after)});}
|
||||
return change;
|
||||
};
|
||||
let document=initial;const changes:Change[]=[];
|
||||
for(const original of planned){
|
||||
let change=remap(original);
|
||||
if(change.kind==='element'&&!change.before&&change.after){const dead=document.tombstones.get(change.id);if(dead!==undefined&&change.after.incarnation<=dead){const mapping=generations.get(change.id)??new Map<number,number>();mapping.set(change.after.incarnation,dead+1);generations.set(change.id,mapping);change=remap(change);}}
|
||||
document=applyChanges(document,[change]);changes.push(change);
|
||||
}
|
||||
validateDocument(document,registry);
|
||||
return{document,changes:freeze(changes),remap,restored:generations.size>0};
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
export * from './types.ts';
|
||||
export {fault,reject,freeze,clone,equal,readMap,readSet,validJson} from './values.ts';
|
||||
export * from './document.ts';
|
||||
export * from './registry.ts';
|
||||
export * from './features.ts';
|
||||
export * from './editor.ts';
|
||||
export * from './gestures.ts';
|
||||
export * from './coordinates.ts';
|
||||
export * from './structure.ts';
|
||||
export * from './units.ts';
|
||||
export * from './history-transition.ts';
|
||||
export {planDuplicate,planDuplicateSteps,remapCopyData} from './duplicate.ts';
|
||||
export type {DuplicatePlan,DuplicateOptions} from './duplicate.ts';
|
||||
export {createDuplicatePlanner,validateDuplicateCheckpoint} from './duplicate-checkpoint.ts';
|
||||
export type {DuplicateCheckpoint} from './duplicate-checkpoint.ts';
|
||||
|
||||
export {createDocumentQueries,advanceDocumentQueries} from './queries.ts';
|
||||
export type {DocumentQueries,Bounds,QueryBudget,QueryCoverage,ElementLookup,IdQueryResult} from './queries.ts';
|
||||
|
||||
export {rectangleGeometry,rectangleBounds,hitRectangle,bezierChainSegments} from './geometry.ts';
|
||||
@@ -0,0 +1,11 @@
|
||||
import {clone,freeze} from './values.ts';
|
||||
|
||||
// Private provenance for a fresh, recursively frozen JSON clone. A caller can
|
||||
// request a detached copy, but cannot grant this fact to an existing object.
|
||||
const detached=new WeakSet<object>();
|
||||
export function detachJsonData<T>(input:T):T {
|
||||
const value=freeze(clone(input));
|
||||
if(value&&typeof value==='object')detached.add(value);
|
||||
return value;
|
||||
}
|
||||
export const hasDetachedJsonData=(value:object)=>detached.has(value);
|
||||
@@ -0,0 +1,121 @@
|
||||
import {rectangleBounds,hitRectangle} from './geometry.ts';
|
||||
import type {Change,Document,ElementRecord,Geometry,Id,Point,ReadonlyValueSet,Registry} from './types.ts';
|
||||
import {copyReferenceIds} from './copy-paths.ts';
|
||||
import {equal,fault,readMap,readSet} from './values.ts';
|
||||
export type Bounds={readonly x:number;readonly y:number;readonly width:number;readonly height:number};
|
||||
type Box={readonly minX:number;readonly minY:number;readonly maxX:number;readonly maxY:number};
|
||||
type Relation=Map<Id,ReadonlyValueSet<Id>>;
|
||||
type Spatial={cells:Relation;entries:Map<Id,Box>;oversized:ReadonlyValueSet<Id>};
|
||||
type References={elements:ReadonlySet<Id>;canvases:ReadonlySet<Id>;complete:boolean};
|
||||
export type QueryBudget={readonly cellSize?:number;readonly maxElementCells?:number;readonly maxQueryCells?:number};
|
||||
type ResolvedBudget=Required<QueryBudget>;
|
||||
type Tables={registry:Registry;budget:ResolvedBudget;members:Relation;children:Relation;childByEntry:Map<Id,Id>;incident:Relation;elementRefs:Relation;canvasRefs:Relation;forward:Map<Id,References>;opaque:ReadonlyValueSet<Id>;spatial:Map<Id,Spatial>;order:Map<Id,number>;canvasOrder:Map<Id,number>};
|
||||
const defaultBudget:ResolvedBudget=Object.freeze({cellSize:512,maxElementCells:256,maxQueryCells:4096});
|
||||
function budget(input:QueryBudget):ResolvedBudget{const result={...defaultBudget,...input};if(!Number.isFinite(result.cellSize)||result.cellSize<=0||![result.maxElementCells,result.maxQueryCells].every(value=>Number.isSafeInteger(value)&&value>=1&&value<=1000000))throw fault('invalid_query_budget');return Object.freeze(result);}
|
||||
const privateTables=new WeakMap<DocumentQueries,Tables>();
|
||||
const empty=readSet<Id>([]);
|
||||
const orderedBuckets=new WeakMap<object,WeakMap<object,readonly Id[]>>();
|
||||
function sortedBucket(bucket:ReadonlyValueSet<Id>,ranks:Map<Id,number>){let versions=orderedBuckets.get(bucket);if(!versions){versions=new WeakMap();orderedBuckets.set(bucket,versions);}let ids=versions.get(ranks);if(!ids){ids=Object.freeze([...bucket].sort((a,b)=>ranks.get(a)!-ranks.get(b)!));versions.set(ranks,ids);}return ids;}
|
||||
/** Copy only touched buckets. Final buckets expose no native Set mutation methods. */
|
||||
function editRelation(base:Relation){const changed=new Map<Id,Set<Id>>();return{change(key:Id,id:Id,add:boolean){const current=changed.get(key)??base.get(key);if(!!current?.has(id)===add)return;let bucket=changed.get(key);if(!bucket){bucket=new Set(current??[]);changed.set(key,bucket);}if(add)bucket.add(id);else bucket.delete(id);},finish(){let next=base;for(const[key,ids]of changed){const before=base.get(key);if((before?.size??0)===ids.size&&[...ids].every(id=>before!.has(id)))continue;if(next===base)next=new Map(base);if(ids.size)next.set(key,readSet(ids));else next.delete(key);}return next;}};}
|
||||
function nodeBox(record:Extract<ElementRecord,{structure:'node'}>,registry:Registry):Box{try{const protocol=registry.resolve(record)==='known'?registry.components.get(record.componentId)?.geometry:undefined,logical=rectangleBounds(record.geometry),hit=protocol?.kind==='node'?protocol.bounds({element:record,geometry:record.geometry,role:'hit'}):logical;if(![hit.x,hit.y,hit.width,hit.height].every(Number.isFinite)||hit.width<0||hit.height<0)throw fault('invalid_geometry_bounds');const bounds={x:Math.min(logical.x,hit.x),y:Math.min(logical.y,hit.y),width:Math.max(logical.x+logical.width,hit.x+hit.width)-Math.min(logical.x,hit.x),height:Math.max(logical.y+logical.height,hit.y+hit.height)-Math.min(logical.y,hit.y)};if(![bounds.x,bounds.y,bounds.width,bounds.height].every(Number.isFinite)||bounds.width<0||bounds.height<0)throw fault('invalid_geometry_bounds');const epsilon=Number.EPSILON*32*Math.max(1,Math.abs(bounds.x),Math.abs(bounds.y),bounds.width,bounds.height);return Object.freeze({minX:bounds.x-epsilon,minY:bounds.y-epsilon,maxX:bounds.x+bounds.width+epsilon,maxY:bounds.y+bounds.height+epsilon});}catch{return Object.freeze({minX:NaN,minY:NaN,maxX:NaN,maxY:NaN});}}
|
||||
function cells(box:Box,limit:number,cellSize:number):string[]|undefined{
|
||||
const x0=Math.floor(box.minX/cellSize),x1=Math.floor(box.maxX/cellSize),y0=Math.floor(box.minY/cellSize),y1=Math.floor(box.maxY/cellSize),count=(x1-x0+1)*(y1-y0+1);
|
||||
if(![x0,x1,y0,y1,count].every(Number.isSafeInteger)||count<1||count>limit)return;
|
||||
const keys:string[]=[];for(let x=x0;x<=x1;x++)for(let y=y0;y<=y1;y++)keys.push(`${x},${y}`);return keys;
|
||||
}
|
||||
function refs(record:ElementRecord,registry:Registry):References{
|
||||
const elements=new Set<Id>(),canvases=new Set<Id>(),schema=registry.components.get(record.componentId)?.copy;
|
||||
if(registry.resolve(record)!=='known'||!schema)return{elements,canvases,complete:false};
|
||||
try{for(const ref of schema.references)for(const id of copyReferenceIds(record.data,ref))(ref.target==='element'?elements:canvases).add(id);return{elements,canvases,complete:true};}catch{return{elements:new Set(),canvases:new Set(),complete:false};}
|
||||
}
|
||||
function order(previous:Map<Id,number>|undefined,ids:Iterable<Id>){const list=[...ids];if(previous&&previous.size===list.length&&list.every((id,index)=>previous.get(id)===index))return previous;return new Map(list.map((id,index)=>[id,index]));}
|
||||
function protectSpatial(spatial:Spatial){return Object.freeze({cells:readMap(spatial.cells),entries:readMap(spatial.entries),oversized:spatial.oversized});}
|
||||
function intersect(a:Box,b:Box){return a.minX<=b.maxX&&a.maxX>=b.minX&&a.minY<=b.maxY&&a.maxY>=b.minY;}
|
||||
function finite(box:Box){return Object.values(box).every(Number.isFinite);}
|
||||
export type QueryCoverage={readonly elementIds?:ReadonlyValueSet<Id>;readonly loadedElementIds?:ReadonlyValueSet<Id>;readonly projectComplete:boolean;readonly topologyComplete:boolean;readonly completeCanvasIds:readonly Id[]};
|
||||
export type ElementLookup={readonly status:'present';readonly element:ElementRecord}|{readonly status:'absent'|'unloaded'};
|
||||
export type IdQueryResult={readonly ids:readonly Id[];readonly complete:boolean};
|
||||
export type DocumentQueries={
|
||||
readonly coverage:QueryCoverage;
|
||||
lookupElement(id:Id):ElementLookup;
|
||||
membersResult(canvas:Id):IdQueryResult;
|
||||
childCanvasesResult(canvas:Id):IdQueryResult;
|
||||
incidentResult(element:Id):IdQueryResult;
|
||||
readonly document:Document;readonly registry:Registry;readonly revision:number;readonly budget:ResolvedBudget;
|
||||
readonly relations:{readonly ownerMembers:ReadonlyMap<Id,ReadonlyValueSet<Id>>;readonly canvasChildren:ReadonlyMap<Id,ReadonlyValueSet<Id>>;readonly childCanvasByEntry:ReadonlyMap<Id,Id>;readonly incidentEdges:ReadonlyMap<Id,ReadonlyValueSet<Id>>;readonly elementReferrers:ReadonlyMap<Id,ReadonlyValueSet<Id>>;readonly canvasReferrers:ReadonlyMap<Id,ReadonlyValueSet<Id>>;readonly opaqueReferences:ReadonlyValueSet<Id>;readonly referenceComplete:boolean};
|
||||
readonly spatial:ReadonlyMap<Id,{readonly cells:ReadonlyMap<Id,ReadonlyValueSet<Id>>;readonly entries:ReadonlyMap<Id,Box>;readonly oversized:ReadonlyValueSet<Id>}>;
|
||||
members(canvas:Id):readonly Id[];childCanvases(canvas:Id):readonly Id[];incident(element:Id):readonly Id[];orderedElements(ids:Iterable<Id>):readonly Id[];orderedCanvases(ids:Iterable<Id>):readonly Id[];candidateNodes(canvas:Id,rect:Bounds):readonly Id[];hitNodeBounds(canvas:Id,point:Point,tolerance?:number):{readonly ids:readonly Id[];readonly complete:boolean};hitNodes(canvas:Id,point:Point,tolerance?:number):{readonly ids:readonly Id[];readonly complete:boolean;readonly unavailable:readonly Id[]};
|
||||
};
|
||||
// No mapping callback captures the previous query: retained methods must not
|
||||
// accidentally keep its context alive, including after consumer bundling.
|
||||
function spatialViews(tables:Tables,previous?:DocumentQueries){
|
||||
const old=previous?privateTables.get(previous)!:undefined;
|
||||
if(previous&&tables.spatial===old!.spatial)return previous.spatial;
|
||||
const result=new Map<Id,ReturnType<typeof protectSpatial>>();
|
||||
for(const[id,value]of tables.spatial)result.set(id,previous&&old!.spatial.get(id)===value?previous.spatial.get(id)!:protectSpatial(value));
|
||||
return readMap(result);
|
||||
}
|
||||
function expose(document:Document,revision:number,tables:Tables,previous?:DocumentQueries):DocumentQueries{
|
||||
const sorted=(ids:Iterable<Id>,ranks=tables.order)=>Object.freeze([...ids].filter(id=>ranks.has(id)).sort((a,b)=>ranks.get(a)!-ranks.get(b)!));
|
||||
const relation=Object.freeze({ownerMembers:previous&&tables.members===privateTables.get(previous)!.members?previous.relations.ownerMembers:readMap(tables.members),canvasChildren:previous&&tables.children===privateTables.get(previous)!.children?previous.relations.canvasChildren:readMap(tables.children),childCanvasByEntry:previous&&tables.childByEntry===privateTables.get(previous)!.childByEntry?previous.relations.childCanvasByEntry:readMap(tables.childByEntry),incidentEdges:previous&&tables.incident===privateTables.get(previous)!.incident?previous.relations.incidentEdges:readMap(tables.incident),elementReferrers:previous&&tables.elementRefs===privateTables.get(previous)!.elementRefs?previous.relations.elementReferrers:readMap(tables.elementRefs),canvasReferrers:previous&&tables.canvasRefs===privateTables.get(previous)!.canvasRefs?previous.relations.canvasReferrers:readMap(tables.canvasRefs),opaqueReferences:tables.opaque,referenceComplete:tables.opaque.size===0});
|
||||
const spatial=spatialViews(tables,previous);
|
||||
function candidates(canvas:Id,rect:Bounds){
|
||||
if(!document.canvases.has(canvas))throw fault('canvas_not_loaded',[canvas]);if(![rect.x,rect.y,rect.width,rect.height].every(Number.isFinite)||rect.width<0||rect.height<0)throw fault('invalid_query_bounds');
|
||||
const table=tables.spatial.get(canvas);if(!table)return Object.freeze([]) as readonly Id[];const box={minX:rect.x,minY:rect.y,maxX:rect.x+rect.width,maxY:rect.y+rect.height},keys=cells(box,tables.budget.maxQueryCells,tables.budget.cellSize),ids=new Set<Id>(keys?table.oversized:table.entries.keys());if(keys)for(const key of keys)for(const id of table.cells.get(key)??[])ids.add(id);
|
||||
return sorted([...ids].filter(id=>{const entry=table.entries.get(id)!;return !finite(entry)||intersect(box,entry);}));
|
||||
}
|
||||
const view:DocumentQueries=Object.freeze({
|
||||
coverage:Object.freeze({projectComplete:true,topologyComplete:true,completeCanvasIds:Object.freeze([...document.canvases.keys()])}),
|
||||
lookupElement:(id:Id):ElementLookup=>{const element=document.elements.get(id);return element?Object.freeze({status:'present',element}):Object.freeze({status:'absent'});},
|
||||
membersResult:(canvas:Id)=>Object.freeze({ids:view.members(canvas),complete:true}),
|
||||
childCanvasesResult:(canvas:Id)=>Object.freeze({ids:view.childCanvases(canvas),complete:true}),
|
||||
incidentResult:(element:Id)=>Object.freeze({ids:view.incident(element),complete:true}),
|
||||
document,registry:tables.registry,revision,budget:tables.budget,relations:relation,spatial,
|
||||
members:(canvas:Id)=>{if(!document.canvases.has(canvas))throw fault('canvas_not_loaded',[canvas]);return sortedBucket(tables.members.get(canvas)??empty,tables.order);},childCanvases:(canvas:Id)=>{if(!document.canvases.has(canvas))throw fault('canvas_not_loaded',[canvas]);return sortedBucket(tables.children.get(canvas)??empty,tables.canvasOrder);},incident:(element:Id)=>{if(!document.elements.has(element))throw fault('element_missing',[element]);return sortedBucket(tables.incident.get(element)??empty,tables.order);},orderedElements:(ids:Iterable<Id>)=>sorted(ids),orderedCanvases:(ids:Iterable<Id>)=>sorted(ids,tables.canvasOrder),candidateNodes:candidates,
|
||||
hitNodes(canvas:Id,point:Point,tolerance=0){if(![point.x,point.y,tolerance].every(Number.isFinite)||tolerance<0)throw fault('invalid_query_point');const hits:Id[]=[],unavailable:Id[]=[];for(const id of candidates(canvas,{x:point.x-tolerance,y:point.y-tolerance,width:2*tolerance,height:2*tolerance})){const record=document.elements.get(id)! as Extract<ElementRecord,{structure:'node'}>;try{if(!finite(tables.spatial.get(canvas)!.entries.get(id)!))throw fault('geometry_unavailable');const protocol=tables.registry.resolve(record)==='known'?tables.registry.components.get(record.componentId)?.geometry:undefined,result=protocol?.kind==='node'?protocol.hit({element:record,geometry:record.geometry,point,tolerance}):hitRectangle(record.geometry,point,tolerance);if(typeof result!=='boolean')throw fault('invalid_geometry_response');if(result)hits.push(id);}catch{unavailable.push(id);}}return Object.freeze({ids:Object.freeze(hits),complete:!unavailable.length,unavailable:Object.freeze(unavailable)});},
|
||||
hitNodeBounds(canvas:Id,point:Point,tolerance=0){if(![point.x,point.y,tolerance].every(Number.isFinite)||tolerance<0)throw fault('invalid_query_point');const ids=candidates(canvas,{x:point.x-tolerance,y:point.y-tolerance,width:2*tolerance,height:2*tolerance}),hits:Id[]=[];let complete=true;for(const id of ids){const g=(document.elements.get(id)! as Extract<ElementRecord,{structure:'node'}>).geometry,box=tables.spatial.get(canvas)!.entries.get(id)!;if(!finite(box)){complete=false;continue;}const angle=-g.rotation*Math.PI/180,c=Math.cos(angle),s=Math.sin(angle),dx=point.x-g.x-g.width/2,dy=point.y-g.y-g.height/2,x=dx*c-dy*s,y=dx*s+dy*c,ox=Math.max(0,Math.abs(x)-g.width/2),oy=Math.max(0,Math.abs(y)-g.height/2);if(Math.hypot(ox,oy)<=tolerance+Number.EPSILON*64*Math.max(1,Math.abs(point.x),Math.abs(point.y),g.width,g.height))hits.push(id);}return Object.freeze({ids:Object.freeze(hits),complete});},
|
||||
});privateTables.set(view,tables);return view;
|
||||
}
|
||||
const queryBases=new WeakMap<DocumentQueries,DocumentQueries>();
|
||||
const namespaceCopies=new WeakMap<object,ReadonlyValueSet<Id>>();
|
||||
function ownedNamespace(ids:ReadonlyValueSet<Id>){let value=namespaceCopies.get(ids);if(!value){value=readSet(ids);namespaceCopies.set(ids,value);}return value;}
|
||||
/** Bind completeness to the same publication as the model, without rebuilding indexes. */
|
||||
export function withQueryCoverage(queries:DocumentQueries,input:QueryCoverage):DocumentQueries {
|
||||
const base=queryBases.get(queries)??queries,ids=Object.freeze([...input.completeCanvasIds]);
|
||||
if(new Set(ids).size!==ids.length||ids.some(id=>!base.document.canvases.has(id))||input.projectComplete&&(!input.topologyComplete||ids.length!==base.document.canvases.size))throw fault('invalid_query_coverage');
|
||||
const coverage=Object.freeze({...(input.elementIds?{elementIds:ownedNamespace(input.elementIds)}:{}),...(input.loadedElementIds?{loadedElementIds:ownedNamespace(input.loadedElementIds)}:{}),projectComplete:input.projectComplete,topologyComplete:input.topologyComplete,completeCanvasIds:ids}),complete=new Set(ids);
|
||||
const view:DocumentQueries=Object.freeze({...base,coverage,
|
||||
relations:Object.freeze({...base.relations,referenceComplete:base.relations.referenceComplete&&coverage.projectComplete}),
|
||||
lookupElement:(id:Id):ElementLookup=>{const element=base.document.elements.get(id);return element?Object.freeze({status:'present',element}):Object.freeze({status:coverage.projectComplete||coverage.elementIds&&(!coverage.elementIds.has(id)||coverage.loadedElementIds?.has(id)&&base.document.tombstones.has(id))?'absent':'unloaded'});},
|
||||
membersResult:(canvas:Id)=>Object.freeze({ids:base.members(canvas),complete:complete.has(canvas)}),
|
||||
childCanvasesResult:(canvas:Id)=>Object.freeze({ids:base.childCanvases(canvas),complete:coverage.topologyComplete}),
|
||||
incidentResult:(element:Id)=>Object.freeze({ids:base.incident(element),complete:coverage.projectComplete}),
|
||||
hitNodes(canvas:Id,point:Point,tolerance=0){const result=base.hitNodes(canvas,point,tolerance);return Object.freeze({...result,complete:result.complete&&complete.has(canvas)});},
|
||||
hitNodeBounds(canvas:Id,point:Point,tolerance=0){const result=base.hitNodeBounds(canvas,point,tolerance);return Object.freeze({...result,complete:result.complete&&complete.has(canvas)});},
|
||||
});queryBases.set(view,base);const tables=privateTables.get(base);if(tables)privateTables.set(view,tables);return view;
|
||||
}
|
||||
/** Pure derived query snapshot. Never serialize these tables or publish them as a second document. */
|
||||
export function createDocumentQueries(document:Document,registry:Registry,revision=0,options:QueryBudget={}):DocumentQueries{return advanceDocumentQueries(undefined,document,registry,revision,undefined,options);}
|
||||
export function advanceDocumentQueries(previous:DocumentQueries|undefined,document:Document,registry:Registry,revision:number,changes?:readonly Change[],options:QueryBudget={}):DocumentQueries{
|
||||
if(!Number.isSafeInteger(revision)||revision<0||previous&&(previous.document.projectId!==document.projectId||revision<previous.revision||revision===previous.revision&&document!==previous.document))throw fault('invalid_query_revision');
|
||||
const old=previous?privateTables.get(previous):undefined;if(previous&&!old)throw fault('invalid_query_snapshot');if(old&&old.registry!==registry)throw fault('query_registry_changed');if(old&&Object.keys(options).length&&!equal(old.budget,budget(options)))throw fault('query_budget_changed');if(previous?.document===document&&previous.revision===revision)return previous;
|
||||
const configuration=old?.budget??budget(options),oldDoc=previous?.document,ids=changes?new Set(changes.filter(change=>change.kind!=='canvas').map(change=>change.kind==='field'?change.target.id:change.id)):new Set([...(oldDoc?.elements.keys()??[]),...document.elements.keys()]),canvasIds=changes?new Set(changes.filter(change=>change.kind==='canvas').map(change=>change.id)):new Set([...(oldDoc?.canvases.keys()??[]),...document.canvases.keys()]);
|
||||
const members=editRelation(old?.members??new Map()),children=editRelation(old?.children??new Map()),incident=editRelation(old?.incident??new Map()),elementRefs=editRelation(old?.elementRefs??new Map()),canvasRefs=editRelation(old?.canvasRefs??new Map());let childByEntry=old?.childByEntry??new Map<Id,Id>(),forward=old?.forward??new Map<Id,References>(),opaque=old?.opaque??empty,spatial=old?.spatial??new Map<Id,Spatial>();
|
||||
const replaced=new Set<Id>(),dataFields=new Map<Id,Set<string>>();if(changes)for(const change of changes){if(change.kind==='element')replaced.add(change.id);else if(change.kind==='field'&&change.field.startsWith('data.')){let fields=dataFields.get(change.target.id);if(!fields){fields=new Set();dataFields.set(change.target.id,fields);}fields.add(change.field.slice(5));}}
|
||||
const spatialChanges=new Map<Id,Map<Id,Box|null>>();
|
||||
const spatialChange=(canvas:Id,id:Id,box:Box|null)=>{let list=spatialChanges.get(canvas);if(!list){list=new Map();spatialChanges.set(canvas,list);}list.set(id,box);};
|
||||
for(const id of ids){const before=oldDoc?.elements.get(id),after=document.elements.get(id);if(before===after)continue;
|
||||
if(before?.ownerCanvasId!==after?.ownerCanvasId){if(before)members.change(before.ownerCanvasId,id,false);if(after)members.change(after.ownerCanvasId,id,true);}
|
||||
const beforeEndpoints=before?.structure==='connection'?new Set([before.source.elementId,before.target.elementId]):new Set<Id>(),afterEndpoints=after?.structure==='connection'?new Set([after.source.elementId,after.target.elementId]):new Set<Id>();for(const endpoint of beforeEndpoints)if(!afterEndpoints.has(endpoint))incident.change(endpoint,id,false);for(const endpoint of afterEndpoints)if(!beforeEndpoints.has(endpoint))incident.change(endpoint,id,true);
|
||||
const oldRefs=forward.get(id),schema=after?registry.components.get(after.componentId)?.copy:undefined,referenceFieldsUnchanged=!!changes&&!replaced.has(id)&&!!schema&&schema.references.every(ref=>!dataFields.get(id)?.has(ref.path[0])),nextRefs=after?(before&&oldRefs&&before.componentId===after.componentId&&before.dataVersion===after.dataVersion&&(referenceFieldsUnchanged||equal(before.data,after.data))?oldRefs:refs(after,registry)):undefined;
|
||||
if(oldRefs!==nextRefs)for(const kind of ['elements','canvases'] as const){const table=kind==='elements'?elementRefs:canvasRefs;for(const target of oldRefs?.[kind]??[])if(!nextRefs?.[kind].has(target))table.change(target,id,false);for(const target of nextRefs?.[kind]??[])if(!oldRefs?.[kind].has(target))table.change(target,id,true);}
|
||||
if(oldRefs!==nextRefs&&(!!oldRefs?.complete!==!!nextRefs?.complete||!equal([...(oldRefs?.elements??[])],[...(nextRefs?.elements??[])])||!equal([...(oldRefs?.canvases??[])],[...(nextRefs?.canvases??[])])||!oldRefs&&nextRefs||oldRefs&&!nextRefs)){if(forward===old?.forward)forward=new Map(forward);if(nextRefs)forward.set(id,nextRefs);else forward.delete(id);}
|
||||
const shouldOpaque=!!after&&!nextRefs?.complete;if(opaque.has(id)!==shouldOpaque){const next=new Set(opaque);if(shouldOpaque)next.add(id);else next.delete(id);opaque=readSet(next);}
|
||||
const was=before?.structure==='node'?before:undefined,now=after?.structure==='node'?after:undefined,protocol=now&®istry.resolve(now)==='known'?registry.components.get(now.componentId)?.geometry:undefined,shapeDataChanged=protocol?.kind==='node'&&!equal(before?.data,after?.data)&&(protocol.dataDependencies===undefined||protocol.dataDependencies.some(key=>!equal(before?.data[key],after?.data[key])));if(was?.ownerCanvasId!==now?.ownerCanvasId||!equal(was?.geometry,now?.geometry)||before?.componentId!==after?.componentId||before?.dataVersion!==after?.dataVersion||shapeDataChanged){if(was)spatialChange(was.ownerCanvasId,id,null);if(now)spatialChange(now.ownerCanvasId,id,nodeBox(now,registry));}
|
||||
}
|
||||
for(const id of canvasIds){const before=oldDoc?.canvases.get(id)?.parent,after=document.canvases.get(id)?.parent;if(equal(before,after))continue;if(before){children.change(before.canvasId,id,false);if(childByEntry===old?.childByEntry)childByEntry=new Map(childByEntry);childByEntry.delete(before.entryElementId);}}
|
||||
for(const id of canvasIds){const before=oldDoc?.canvases.get(id)?.parent,after=document.canvases.get(id)?.parent;if(equal(before,after)||!after)continue;children.change(after.canvasId,id,true);if(childByEntry===old?.childByEntry)childByEntry=new Map(childByEntry);childByEntry.set(after.entryElementId,id);}
|
||||
if(spatialChanges.size){spatial=new Map(spatial);for(const[canvas,updates]of spatialChanges){const base=spatial.get(canvas),entries=new Map(base?.entries),grid=editRelation(base?.cells??new Map()),large=new Set(base?.oversized??[]);for(const[id,box]of updates){const before=entries.get(id);if(before)for(const key of cells(before,configuration.maxElementCells,configuration.cellSize)??[])grid.change(key,id,false);large.delete(id);if(box){entries.set(id,box);const keys=cells(box,configuration.maxElementCells,configuration.cellSize);if(keys)for(const key of keys)grid.change(key,id,true);else large.add(id);}else entries.delete(id);}if(entries.size)spatial.set(canvas,{entries,cells:grid.finish(),oversized:readSet(large)});else spatial.delete(canvas);}}
|
||||
const tables:Tables={registry,budget:configuration,members:members.finish(),children:children.finish(),childByEntry,incident:incident.finish(),elementRefs:elementRefs.finish(),canvasRefs:canvasRefs.finish(),forward,opaque,spatial,order:old&&changes&&!changes.some(change=>change.kind==='element'&&(!change.before||!change.after))?old.order:order(old?.order,document.elements.keys()),canvasOrder:old&&changes&&!changes.some(change=>change.kind==='canvas'&&(!change.before||!change.after))?old.canvasOrder:order(old?.canvasOrder,document.canvases.keys())};return expose(document,revision,tables,previous);
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import type { Capability, CapabilityHandler, ComponentDefinition, Data, ElementRecord, Feature, Json, Registry, ReadonlyValueSet } from './types.ts';
|
||||
import { clone, fault, freeze, readMap, readSet } from './values.ts';
|
||||
|
||||
export function defineFeature(id: string, handlers: Partial<Record<Capability, CapabilityHandler>>): Feature {
|
||||
if (!id || !Object.values(handlers).length || Object.values(handlers).some(value => typeof value !== 'function')) throw fault('invalid_feature');
|
||||
return freeze({ id, handlers: { ...handlers } });
|
||||
}
|
||||
export function defineComponent(definition: ComponentDefinition): ComponentDefinition {
|
||||
if (!definition.id || !Number.isSafeInteger(definition.dataVersion) || definition.dataVersion < 1 || typeof definition.validate !== 'function') throw fault('invalid_component');
|
||||
const pathValid = (path: readonly string[]) => Array.isArray(path) && path.length > 0 && path.every(key => typeof key === 'string' && !!key && !['__proto__','prototype','constructor'].includes(key));
|
||||
if (definition.copy) {
|
||||
const references = definition.copy.references, omitted = definition.copy.omit;
|
||||
if (!Array.isArray(references) || !Array.isArray(omitted) || omitted.some(path => !pathValid(path)) || references.some(ref => !pathValid(ref.path) || !['element','canvas'].includes(ref.target) || !['preserve','reject'].includes(ref.external) || ref.many !== undefined && typeof ref.many !== 'boolean')) throw fault('invalid_copy_schema');
|
||||
const paths: readonly (readonly string[])[] = [...references.map(ref => ref.path), ...omitted];
|
||||
for (let i = 0; i < paths.length; i++) for (let j = 0; j < i; j++) if (paths[i].slice(0, Math.min(paths[i].length, paths[j].length)).every((key, index) => (key === paths[j][index] || key === '*' || paths[j][index] === '*'))) throw fault('conflicting_copy_paths');
|
||||
}
|
||||
const geometry=definition.geometry;if(geometry&&(geometry.version!==1||geometry.kind!==definition.structure||geometry.kind==='node'&&(typeof geometry.bounds!=='function'||typeof geometry.hit!=='function'||geometry.dataDependencies!==undefined&&(!Array.isArray(geometry.dataDependencies)||geometry.dataDependencies.some(key=>typeof key!=='string'||!key)))||geometry.kind==='connection'&&(typeof geometry.segments!=='function'||typeof geometry.strokeWidth!=='function')))throw fault('invalid_geometry_protocol');
|
||||
return freeze({ ...definition, geometry:geometry?{...geometry,...(geometry.kind==='node'&&geometry.dataDependencies?{dataDependencies:[...geometry.dataDependencies]}:{})}:undefined, copy: definition.copy ? clone(definition.copy) : undefined, features: definition.features.map(feature => defineFeature(feature.id, feature.handlers)), migrations: definition.migrations ? { ...definition.migrations } : undefined, resourceFields: definition.resourceFields ? [...definition.resourceFields] : undefined });
|
||||
}
|
||||
export function createRegistry(definitions: readonly ComponentDefinition[]): Registry {
|
||||
const components = new Map<string, ComponentDefinition>(), capabilities = new Map<string, ReadonlyValueSet<Capability>>(), handlers = new Map<string, ReadonlyMap<Capability, CapabilityHandler>>();
|
||||
for (const input of definitions) {
|
||||
const definition = defineComponent(input);
|
||||
if (components.has(definition.id)) throw fault('duplicate_component', [definition.id]);
|
||||
const found = new Map<Capability, CapabilityHandler>(), features = new Set<string>();
|
||||
for (const feature of definition.features) {
|
||||
if (features.has(feature.id)) throw fault('duplicate_feature', [feature.id]);
|
||||
features.add(feature.id);
|
||||
for (const [key, handler] of Object.entries(feature.handlers)) {
|
||||
const capability = key as Capability;
|
||||
if (!handler) throw fault('invalid_feature');
|
||||
if (found.has(capability)) throw fault('duplicate_capability', [definition.id, capability]);
|
||||
found.set(capability, handler);
|
||||
}
|
||||
}
|
||||
components.set(definition.id, definition); capabilities.set(definition.id, readSet(found.keys())); handlers.set(definition.id, readMap(found));
|
||||
}
|
||||
const resolve: Registry['resolve'] = element => {
|
||||
const definition = components.get(element.componentId);
|
||||
if (!definition) return 'unknown-component';
|
||||
if (definition.dataVersion !== element.dataVersion) return 'unsupported-version';
|
||||
return 'known';
|
||||
};
|
||||
return Object.freeze({ components: readMap(components), capabilities: readMap(capabilities), handlers: readMap(handlers), resolve,
|
||||
supports: (element: ElementRecord, capability: Capability) => resolve(element) === 'known' && !!capabilities.get(element.componentId)?.has(capability) });
|
||||
}
|
||||
/** Capture each field once; JSON.stringify would silently erase invalid values. */
|
||||
function captureMigrationData(input:unknown,id:string):Data {
|
||||
const invalid=():never=>{throw fault('invalid_migration',[id]);};
|
||||
const capture=(value:unknown,depth=0):Json=>{
|
||||
if(depth>64)return invalid();
|
||||
if(value===null||typeof value==='string'||typeof value==='boolean')return value;
|
||||
if(typeof value==='number')return Number.isFinite(value)?value:invalid();
|
||||
if(!value||typeof value!=='object')return invalid();
|
||||
if(Object.getOwnPropertySymbols(value).some(key=>Object.prototype.propertyIsEnumerable.call(value,key)))return invalid();
|
||||
if(Array.isArray(value)){
|
||||
if(Object.keys(value).length!==value.length)return invalid();
|
||||
const result:Json[]=[];for(let index=0;index<value.length;index++){if(!Object.hasOwn(value,index))return invalid();result.push(capture(value[index],depth+1));}return freeze(result);
|
||||
}
|
||||
if(Object.getPrototypeOf(value)!==Object.prototype)return invalid();
|
||||
const source=value as Record<string,unknown>;
|
||||
return freeze(Object.fromEntries(Object.keys(source).map(key=>[key,capture(source[key],depth+1)])));
|
||||
};
|
||||
const result=capture(input);
|
||||
if(!result||typeof result!=='object'||Array.isArray(result))return invalid();
|
||||
return result;
|
||||
}
|
||||
|
||||
export function migrateElement(element: ElementRecord, registry: Registry): ElementRecord {
|
||||
const definition = registry.components.get(element.componentId);
|
||||
if (!definition || element.dataVersion > definition.dataVersion) return element;
|
||||
let data = captureMigrationData(element.data,element.id), version = element.dataVersion;
|
||||
while (version < definition.dataVersion) {
|
||||
const migrate = definition.migrations?.[version];
|
||||
if (!migrate) throw fault('missing_migration', [element.id]);
|
||||
data = captureMigrationData(migrate(data),element.id); version++;
|
||||
}
|
||||
if (!definition.validate(data)) throw fault('invalid_component_data', [element.id]);
|
||||
return freeze({ ...element, dataVersion: version, data });
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import type {DocumentQueries} from './queries.ts';
|
||||
import type { Change, Document, Id, Target } from './types.ts';
|
||||
import { commonCanvas, targetElement } from './document.ts';
|
||||
import { canvasTransform, convertPath } from './coordinates.ts';
|
||||
import { equal, freeze, fault } from './values.ts';
|
||||
|
||||
/** Own the subtree explicitly; geometry/visibility never selects which descendants are deleted. */
|
||||
export function removalChanges(document:Document,target:Target,queries?:DocumentQueries):readonly Change[] {
|
||||
if(queries){
|
||||
if(queries.document!==document)throw fault('stale_query_snapshot');const element=targetElement(document,target),elements=new Set<Id>([element.id]),canvases=new Set<Id>(),child=queries.relations.childCanvasByEntry.get(element.id),stack=child?[child]:[];
|
||||
while(stack.length){const id=stack.pop()!;if(canvases.has(id))continue;canvases.add(id);stack.push(...queries.childCanvases(id));for(const member of queries.members(id))elements.add(member);}
|
||||
for(const id of [...elements])for(const edge of queries.incident(id))elements.add(edge);
|
||||
return freeze([...queries.orderedElements(elements).map(id=>({kind:'element' as const,id,before:document.elements.get(id)!,after:null})),...queries.orderedCanvases(canvases).map(id=>({kind:'canvas' as const,id,before:document.canvases.get(id)!,after:null}))]);
|
||||
}
|
||||
const element=targetElement(document,target),elements=new Set<Id>([element.id]),canvases=new Set<Id>(),children=new Map<Id,Id[]>();
|
||||
for(const canvas of document.canvases.values())if(canvas.parent){const list=children.get(canvas.parent.canvasId)??[];list.push(canvas.id);children.set(canvas.parent.canvasId,list);}
|
||||
const stack=[...document.canvases.values()].filter(canvas=>canvas.parent?.entryElementId===element.id).map(canvas=>canvas.id);
|
||||
while(stack.length){const id=stack.pop()!;if(canvases.has(id))continue;canvases.add(id);for(const child of children.get(id)??[])stack.push(child);}
|
||||
for(const record of document.elements.values())if(canvases.has(record.ownerCanvasId))elements.add(record.id);
|
||||
for(const record of document.elements.values())if(record.structure==='connection'&&(elements.has(record.source.elementId)||elements.has(record.target.elementId)))elements.add(record.id);
|
||||
return freeze([
|
||||
...[...document.elements.values()].filter(record=>elements.has(record.id)).map(record=>({kind:'element' as const,id:record.id,before:record,after:null})),
|
||||
...[...document.canvases.values()].filter(canvas=>canvases.has(canvas.id)).map(canvas=>({kind:'canvas' as const,id:canvas.id,before:canvas,after:null})),
|
||||
]);
|
||||
}
|
||||
|
||||
/** Ownership changes and their curve coordinates are one operation, with the same stable edge identity. */
|
||||
export function repairConnectionOwners(before:Document,document:Document):readonly Change[] {
|
||||
const changes:Change[]=[];
|
||||
for(const edge of document.elements.values())if(edge.structure==='connection'){
|
||||
const source=document.elements.get(edge.source.elementId),target=document.elements.get(edge.target.elementId);if(!source||!target)continue;
|
||||
const owner=commonCanvas(document,source.ownerCanvasId,target.ownerCanvasId);if(owner===edge.ownerCanvasId)continue;
|
||||
const path=convertPath(edge.path,canvasTransform(before.canvases.has(edge.ownerCanvasId)?before:document,edge.ownerCanvasId),canvasTransform(document,owner));
|
||||
changes.push({kind:'field',target:{id:edge.id,incarnation:edge.incarnation},field:'ownerCanvasId',before:edge.ownerCanvasId,after:owner});
|
||||
if(!equal(edge.path,path))changes.push({kind:'field',target:{id:edge.id,incarnation:edge.incarnation},field:'path',before:edge.path,after:path});
|
||||
}
|
||||
return freeze(changes);
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import type { HistoryTransition } from './history-transition.ts';
|
||||
/** Canonical, serializable contracts. No renderer, browser, or service types. */
|
||||
export type Json = null | boolean | number | string | Json[] | { [key: string]: Json };
|
||||
export type Data = { readonly [key: string]: Json };
|
||||
export type Id = string;
|
||||
export type Point = { readonly x: number; readonly y: number };
|
||||
export type Geometry = Point & { readonly width: number; readonly height: number; readonly rotation: number };
|
||||
export type CanvasRecord = { readonly id: Id; readonly parent: null | { readonly canvasId: Id; readonly entryElementId: Id } };
|
||||
export type Target = { readonly id: Id; readonly incarnation: number };
|
||||
export type Endpoint = { readonly elementId: Id; readonly portId?: string };
|
||||
export type ElementBase = Target & { readonly ownerCanvasId: Id; readonly componentId: string; readonly dataVersion: number; readonly data: Data };
|
||||
export type ElementRecord = ElementBase & (
|
||||
| { readonly structure: 'node'; readonly geometry: Geometry }
|
||||
| { readonly structure: 'connection'; readonly source: Endpoint; readonly target: Endpoint; readonly path: Json }
|
||||
);
|
||||
export type Document = { readonly projectId: Id; readonly canvases: ReadonlyMap<Id, CanvasRecord>; readonly elements: ReadonlyMap<Id, ElementRecord>; readonly tombstones: ReadonlyMap<Id, number> };
|
||||
export type Field = `data.${string}` | 'geometry' | 'ownerCanvasId' | 'source' | 'target' | 'path';
|
||||
export type FieldChange = { readonly kind: 'field'; readonly target: Target; readonly field: Field; readonly before: Json; readonly after: Json; readonly beforePresent?: boolean; readonly afterPresent?: boolean };
|
||||
export type Change = FieldChange
|
||||
| { readonly kind: 'element'; readonly id: Id; readonly before: ElementRecord | null; readonly after: ElementRecord | null }
|
||||
| { readonly kind: 'canvas'; readonly id: Id; readonly before: CanvasRecord | null; readonly after: CanvasRecord | null };
|
||||
export type Command =
|
||||
| { readonly type: 'element.create'; readonly element: ElementRecord }
|
||||
| { readonly type:'connection.endpoints'; readonly target:Target; readonly source?:Endpoint; readonly destination?:Endpoint }
|
||||
| { readonly type: 'connection.path'; readonly target: Target; readonly path: Json }
|
||||
| { readonly type: 'element.remove'; readonly target: Target }
|
||||
| { readonly type: 'element.data'; readonly target: Target; readonly patch: Data }
|
||||
| { readonly type: 'element.geometry'; readonly target: Target; readonly patch: Partial<Geometry> }
|
||||
| { readonly type: 'element.move'; readonly target: Target; readonly canvasId: Id; readonly geometry?: Geometry }
|
||||
| { readonly type: 'canvas.create'; readonly canvas: CanvasRecord }
|
||||
| { readonly type: 'canvas.reparent'; readonly canvasId: Id; readonly parent: CanvasRecord['parent'] }
|
||||
| { readonly type: 'canvas.dissolve'; readonly canvasId: Id; readonly entry: Target }
|
||||
| { readonly type: 'changes.apply'; readonly changes: readonly Change[] };
|
||||
export type Origin = 'user' | 'undo' | 'redo' | 'remote' | 'ack' | 'replay' | 'asset-resolution' | 'migration';
|
||||
export type Rejected = { readonly status: 'rejected'; readonly code: string; readonly targets: readonly Id[] };
|
||||
export type Applied = { readonly status: 'applied'; readonly revision: number; readonly operationIds: readonly Id[]; readonly historyEntryId?: Id };
|
||||
export type Result = Applied | Rejected;
|
||||
export type HistoryEntry = { readonly id: Id; readonly label: string; readonly changes: readonly Change[]; readonly operationIds: readonly Id[]; readonly groupId?: Id };
|
||||
export type HistoryState = { readonly undo: readonly Id[]; readonly redo: readonly Id[]; readonly entries: ReadonlyMap<Id, HistoryEntry> };
|
||||
export type CreationSizeEffect = { readonly version: 1; readonly kind: 'creation-size'; readonly entryId: Id; readonly target: Target; readonly size: { readonly width: number; readonly height: number } };
|
||||
type IntentBase = { readonly historyEffect?:CreationSizeEffect; readonly historyTransition?:HistoryTransition; readonly id: Id; readonly operationSchemaVersion: 1; readonly sequence: number; readonly origin: Origin; readonly canvasIds: readonly Id[]; readonly changes: readonly Change[]; readonly dependencies: readonly Id[] };
|
||||
export type RunInput = { readonly target: Target; readonly boardId: Id; readonly modelId: string; readonly prompt: string; readonly parameters: Data; readonly propagate: boolean };
|
||||
export type RunCancelInput = { readonly runId: Id; readonly boardId: Id };
|
||||
export type RunApplyInput = RunCancelInput & { readonly targetCanvasId: Id; readonly target: Target; readonly outputId: Id };
|
||||
export type OperationIntent = IntentBase & ({ readonly operationType: 'document.change' } | { readonly operationType: 'asset.upload'; readonly resourceId: Id } | { readonly operationType: 'run.start'; readonly run: RunInput } | { readonly operationType: 'run.cancel'; readonly action: RunCancelInput } | { readonly operationType: 'run.apply'; readonly action: RunApplyInput });
|
||||
export type Commit = { readonly revision: number; readonly origin: Origin; readonly changes: readonly Change[]; readonly operationIds: readonly Id[] };
|
||||
export type CoreSnapshot = { readonly revision: number; readonly document: Document; readonly history: HistoryState; readonly operations: ReadonlyMap<Id, OperationIntent> };
|
||||
/** One publication boundary. Runtime metadata changes do not dirty the document revision. */
|
||||
export type Snapshot<Runtime = unknown> = CoreSnapshot & { readonly rootRevision: number; readonly runtime: Runtime };
|
||||
export type Availability = { readonly enabled: true } | { readonly enabled: false; readonly code: string; readonly targets: readonly Id[] };
|
||||
export type ReadonlyValueSet<T> = Pick<ReadonlySet<T>, 'size' | 'has' | 'entries' | 'keys' | 'values' | typeof Symbol.iterator> & { forEach(callback: (value: T, value2: T, set: ReadonlyValueSet<T>) => void): void };
|
||||
export type Capability = 'geometry.move' | 'geometry.resize' | 'geometry.rotate' | 'content.edit' | 'connection.edit';
|
||||
export type CapabilityHandler = (context: { readonly element: ElementRecord; readonly input: Readonly<Record<string, unknown>> }) => readonly FieldChange[];
|
||||
export type Feature = { readonly id: string; readonly handlers: Readonly<Partial<Record<Capability, CapabilityHandler>>> };
|
||||
/** Path segments address own fields; * iterates array entries. many marks a terminal ID array. */
|
||||
export type CopyReference = { readonly path: readonly string[]; readonly target: 'element' | 'canvas'; readonly many?: boolean; readonly external: 'preserve' | 'reject' };
|
||||
export type CopySchema = { readonly references: readonly CopyReference[]; readonly omit: readonly (readonly string[])[] };
|
||||
export type NodeGeometryContext={readonly element:Extract<ElementRecord,{structure:'node'}>;readonly geometry:Geometry};
|
||||
export type GeometryProtocol=({readonly kind:'node';readonly version:1;readonly dataDependencies?:readonly string[];bounds(context:NodeGeometryContext&{readonly role:'logical'|'hit'|'paint'}):import('./geometry.ts').Rect;hit(context:NodeGeometryContext&{readonly point:Point;readonly tolerance:number}):boolean}|{readonly kind:'connection';readonly version:1;strokeWidth(context:{readonly element:Extract<ElementRecord,{structure:'connection'}>;readonly transform:{readonly x:number;readonly y:number;readonly scale:number;readonly rotation:number}}):number;segments(context:{readonly element:Extract<ElementRecord,{structure:'connection'}>;readonly source:import('./geometry.ts').Connector;readonly target:import('./geometry.ts').Connector;readonly displayPath:Json|undefined;readonly transform:{readonly x:number;readonly y:number;readonly scale:number;readonly rotation:number}}):readonly import('./geometry.ts').Cubic[]});
|
||||
export type ComponentDefinition = { readonly id: string; readonly dataVersion: number; readonly structure: ElementRecord['structure']; readonly features: readonly Feature[]; readonly validate: (data: Data) => boolean; readonly migrations?: Readonly<Record<number, (data: Data) => Data>>; readonly resourceFields?: readonly string[]; readonly geometry?:GeometryProtocol; readonly copy?: CopySchema };
|
||||
export type Registry = { readonly components: ReadonlyMap<string, ComponentDefinition>; readonly capabilities: ReadonlyMap<string, ReadonlyValueSet<Capability>>; readonly handlers: ReadonlyMap<string, ReadonlyMap<Capability, CapabilityHandler>>; readonly resolve: (element: ElementRecord) => 'known' | 'unknown-component' | 'unsupported-version'; readonly supports: (element: ElementRecord, capability: Capability) => boolean };
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user