Compare commits
210 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1c832988a2 | |||
| 95e8a57019 | |||
| 6b74629d98 | |||
| 33f5c076f6 | |||
| bcf810998d | |||
| f657052fe9 | |||
| fb737eeb0b | |||
| 6576a45c0a | |||
| 838d3e5c46 | |||
| 01c4a9d8c5 | |||
| b51d78b47d | |||
| 1306df2b35 | |||
| 86dac408c3 | |||
| aacafa7a9b | |||
| c488bc86b6 | |||
| f28a2a96bf | |||
| 450fa3f719 | |||
| 11eaac1f9f | |||
| 3f7f8e4b7c | |||
| 45309eaf87 | |||
| c3b7cf93d6 | |||
| 28f1a74d40 | |||
| c59850e7a3 | |||
| f8e5a07233 | |||
| 6b86ab1d53 | |||
| 06a422045f | |||
| 72e84a6cc1 | |||
| 68e655b52c | |||
| b471d88520 | |||
| af8e2fe76b | |||
| da00490ded | |||
| 4c1b35e0cf | |||
| 391cd48be8 | |||
| 62a0755350 | |||
| afa30d4b35 | |||
| aaeb5acf6e | |||
| 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
|
||||
|
||||
@@ -14,6 +14,6 @@ This block is written and re-added by `next dev` — verify at `node_modules/nex
|
||||
- 按逻辑完整、可验证的工作单元提交;同一目标可以包含多个 commit。目标或可交付阶段完成并通过适当检查后,主动推送,不必每次提交后立即推送。
|
||||
- 连续收到需求时,结合当前目标判断是继续同一工作单元,还是拆成独立提交;不要把无关需求混进同一个 commit。尚未验证或未完成的工作如实报告,不把推送成功当作功能验证通过。
|
||||
- 每次提交前审查差异和暂存区,推送前检查远程状态和待推送 commit;只纳入本次负责的改动。保留用户或其他任务的未提交、未跟踪文件,不擅自暂存、丢弃、stash 或提交。
|
||||
- 分支和 PR 方式遵循用户当前明确授权。本项目当前阶段已获授权直接推送 main、暂不创建 PR;该阶段结束或用户调整约定后,恢复全局工作分支和 PR 流程。不得把主动管理 Git 理解为强推、重写共享历史、自行合并 PR 或绕过权限的授权。
|
||||
- 分支和 PR 方式遵循用户当前明确授权。本项目已恢复工作分支和 PR 流程:完成并验证后推送工作分支、创建或更新面向 main 的 PR,等待用户手动合并;不直接推送 main,也不自行合并 PR。不得把主动管理 Git 理解为强推、重写共享历史、自行合并 PR 或绕过权限的授权。
|
||||
- 涉及 gitea.entroia.com 时始终使用专用 codex-bot 身份和隔离 SSH 配置,遵守全局身份约定。
|
||||
- 每次交付用中文简要说明涉及的文件与修改目的、检查结果、分支、commit 标识及 push 状态;有阻碍及时说明,已获授权的常规操作无需反复询问。
|
||||
|
||||
@@ -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;');
|
||||
};
|
||||
@@ -1,14 +1,25 @@
|
||||
/**
|
||||
* Foundry的同源服务端代理。浏览器只持HttpOnly会话Cookie,代理转为上游Bearer;
|
||||
* 限定可转发路径/查询/header,处理体积、超时、素材类型和会话续期。领域回执/模型校验仍由客户端服务适配器执行。
|
||||
* canonical文档和AI冻结写入保留原字节,模型/图片大响应流式转发,避免代理重编码破坏请求身份或额外缓存整份模型。
|
||||
*/
|
||||
import { fetchCanvasResponse } from "@/lib/canvas-response";
|
||||
import { sessionCookie, sessionMaxAge, SESSION_EXPIRY_HEADER } from "@/lib/session-cookie";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
/** 普通账户/工作区/material接口白名单;canvas与asset细分端点由下方独立表达式允许。 */
|
||||
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_-]+)?$/;
|
||||
/** 明确列出旧canvas、新model增量/迁移、子图事务和AI端点,拒绝把代理变成任意上游路径通道。 */
|
||||
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_-]+))?$/;
|
||||
/** 普通请求体上限1MiB;画布5MiB、multipart素材21MiB由handler显式覆盖。 */
|
||||
const bodyLimit = 1024 * 1024;
|
||||
|
||||
// Route Handlers do not have the Pages Router bodyParser size limit.
|
||||
/** 逐块计数后再组装请求体,超限取消reader,finally释放锁。
|
||||
* binary原样返回字节供冻结JSON/multipart转发,其它请求才解析JSON;这不是无限body缓冲。 */
|
||||
async function readBody(req: NextRequest, limit = bodyLimit, binary = false) {
|
||||
const reader = req.body?.getReader();
|
||||
if (!reader) return {};
|
||||
@@ -28,11 +39,14 @@ async function readBody(req: NextRequest, limit = bodyLimit, binary = false) {
|
||||
} finally {
|
||||
reader.releaseLock();
|
||||
}
|
||||
// 空body普通模式用空对象,binary模式保留空字节;无reader则早已返回空对象。
|
||||
if (!size) return binary ? new Uint8Array() : {};
|
||||
const bytes = Buffer.concat(chunks);
|
||||
return binary ? bytes : JSON.parse(bytes.toString("utf8"));
|
||||
}
|
||||
|
||||
/** 共用GET/POST/PATCH/DELETE处理器。params是当前Next版本的异步路由参数,先await再匹配路径。
|
||||
* 所有本代理响应默认no-store;HTTP成功/失败保持上游状态,上游调用/响应解析异常另转为502或超时504。 */
|
||||
async function handler(
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ path: string[] }> },
|
||||
@@ -40,8 +54,10 @@ async function handler(
|
||||
const requestStarted = performance.now();
|
||||
const requestStartedAt = new Date().toISOString();
|
||||
const responseHeaders = new Headers({ "Cache-Control": "no-store" });
|
||||
/** 复用当前响应头集合,JSON错误和普通结果都带相同缓存/续期/诊断头策略。 */
|
||||
const json = (body: unknown, status: number) =>
|
||||
NextResponse.json(body, { status, headers: responseHeaders });
|
||||
// 非GET且带Origin时比对host;缺Origin的请求在此不被拒绝,最终身份/权限仍由上游校验。
|
||||
const requestOrigin = req.headers.get("origin");
|
||||
if (req.method !== "GET" && requestOrigin) {
|
||||
try {
|
||||
@@ -53,46 +69,65 @@ 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);
|
||||
// operations/metadata返回JSON,不能被当成原图流,也不使用图片multipart请求体上限。
|
||||
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;
|
||||
// 在网络转发前完成请求体限额:原始图片含multipart开销,新模型/AI写入保留原请求字节。
|
||||
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);
|
||||
}
|
||||
}
|
||||
const bodyReceived = performance.now();
|
||||
// 上游地址来自服务端环境,客户端path白名单不会让调用者自己指定任意目标源站。
|
||||
const origin = process.env.FOUNDRY_API_ORIGIN || "https://api.foundry.entroia.com";
|
||||
const project = process.env.NEXT_PUBLIC_FOUNDRY_PROJECT || "endoleap";
|
||||
// multipart沿用浏览器Content-Type和boundary,其余发JSON;不透传浏览器任意请求头。
|
||||
const headers: Record<string, string> = { "Content-Type": asset ? (req.headers.get("content-type") || "application/octet-stream") : "application/json" };
|
||||
if (canvas) headers.Accept = "application/vnd.foundry.canvas+json;version=2";
|
||||
// 仅精确协商的canonical GET模型流改变Accept,普通canvas请求继续兼容v2表示。
|
||||
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';
|
||||
// Bearer从服务端读Cookie,禁止直接依赖客户端自行提供的Authorization冒充当前会话。
|
||||
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"]) {
|
||||
// 明确允许OAuth交换、条件版本、幂等键、账户绑定和资源ID;这些值仍需上游验证。
|
||||
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;
|
||||
}
|
||||
// 查询参数限定名单、单值且每值<300字符,重复值不选择性取第一项,避免前后端解释分歧。
|
||||
const query = new URLSearchParams();
|
||||
for (const key of ["kind", "sort", "q", "limit", "offset", "after_seq", "status", "include_descendants", "include_subgraphs", "cursor", "variant", "width"]) {
|
||||
const values = req.nextUrl.searchParams.getAll(key);
|
||||
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.
|
||||
// binary Buffer直接转发,不JSON.parse/stringify;普通业务JSON才重编码,GET不带body。
|
||||
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";
|
||||
// 模型/旧快照与图片读取使用分层超时流;其它请求组合15秒总超时和客户端取消。
|
||||
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() : "";
|
||||
/** 大响应只记录传输时序/字节数/资源路径,不把图像/模型内容或会话令牌写入trace。 */
|
||||
const requestUpstream = streamedRead ? (url: string, options: RequestInit) => fetchCanvasResponse(url, options, undefined, fetch, (trace) => {
|
||||
console.info(snapshotRead ? "[canvas-transfer]" : "[asset-transfer]", JSON.stringify({ trace_id: traceId, resource: path, ...trace }));
|
||||
}) : fetch;
|
||||
if (streamedRead || snapshotWrite) responseHeaders.set("X-Canvas-Trace-Id", traceId);
|
||||
// 禁止fetch缓存和自动跟随上游跳转;流读取继承请求取消,具体头/body/idle计时由fetchCanvasResponse管理。
|
||||
const upstream = await requestUpstream(
|
||||
origin + "/projects/" + encodeURIComponent(project) + "/" + path +
|
||||
(query.size ? "?" + query.toString() : ""),
|
||||
@@ -105,17 +140,20 @@ async function handler(
|
||||
cache: "no-store",
|
||||
},
|
||||
);
|
||||
// 转发明确允许的重试、版本和schema提示,不能用代理自己的时间推算服务端写入版本。
|
||||
const retryAfter = upstream.headers.get("retry-after");
|
||||
if (retryAfter) responseHeaders.set("Retry-After", retryAfter);
|
||||
const etag = upstream.headers.get("etag");
|
||||
if (etag) responseHeaders.set("ETag", etag);
|
||||
const schema = upstream.headers.get("x-canvas-schema-version");
|
||||
if (schema) responseHeaders.set("X-Canvas-Schema-Version", schema);
|
||||
// 既有会话仅在非401且上游给合法剩余秒数时续期;Secure还取决于生产环境及代理HTTPS头。
|
||||
const renewal = upstream.headers.get(SESSION_EXPIRY_HEADER);
|
||||
if (session && upstream.status !== 401 && renewal !== null && /^\d+$/.test(renewal)) {
|
||||
const secure = process.env.NODE_ENV === "production" && req.headers.get("x-forwarded-proto") === "https";
|
||||
responseHeaders.set("Set-Cookie", sessionCookie(session, sessionMaxAge(renewal), secure));
|
||||
}
|
||||
// 成功素材GET只透传允许的图片MIME并加nosniff;不是任意内容的同源文件托管。
|
||||
if (asset && upstream.ok && req.method === "GET") {
|
||||
const type = upstream.headers.get("content-type") || "";
|
||||
if (!/^image\/(png|jpeg|webp|gif)(?:;|$)/i.test(type)) { await upstream.body?.cancel(); return json({ error: "invalid_asset" }, 502); }
|
||||
@@ -128,12 +166,15 @@ 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");
|
||||
// canonical含错误回执也原流透传;旧snapshot只有成功GET走此路径。此处不读取/删除模型payload字段。
|
||||
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 });
|
||||
}
|
||||
// 普通响应整体解JSON,因为登录/交换成功时须把access_token换成HttpOnly Cookie并从body移除。
|
||||
const body = upstream.status === 204 ? null : await upstream.json();
|
||||
// 旧快照写入trace分出接收/重编码/上游/响应耗时,不输出请求正文;字节数仅对字符串body计算。
|
||||
if (snapshotWrite) console.info("[canvas-save]", JSON.stringify({
|
||||
trace_id: traceId, resource: path, status: upstream.status, started_at: requestStartedAt, finished_at: new Date().toISOString(),
|
||||
client_body_ms: Math.round((bodyReceived - requestStarted) * 10) / 10,
|
||||
@@ -148,6 +189,7 @@ async function handler(
|
||||
`upstream;dur=${(headersReceived - started).toFixed(1)}, total;dur=${(performance.now() - started).toFixed(1)}`,
|
||||
);
|
||||
const secure = process.env.NODE_ENV === "production" && req.headers.get("x-forwarded-proto") === "https";
|
||||
// 登录/交换令牌只进Set-Cookie,不回到浏览器可读JSON;其它会话响应按上游剩余时间更新。
|
||||
if (body?.access_token) {
|
||||
responseHeaders.set("Set-Cookie", sessionCookie(body.access_token, sessionMaxAge(body.expires_in), secure));
|
||||
delete body.access_token;
|
||||
@@ -155,6 +197,7 @@ async function handler(
|
||||
const renewed = upstream.headers.get(SESSION_EXPIRY_HEADER);
|
||||
if (renewed !== null && /^\d+$/.test(renewed)) responseHeaders.set("Set-Cookie", sessionCookie(session, sessionMaxAge(renewed), secure));
|
||||
}
|
||||
// 仅上游确认登出成功后清Cookie,不把一次网络失败当成成功退出。
|
||||
if (path === "auth/logout" && upstream.ok)
|
||||
responseHeaders.set(
|
||||
"Set-Cookie",
|
||||
@@ -163,6 +206,7 @@ async function handler(
|
||||
if (upstream.status === 204)
|
||||
return new NextResponse(null, { status: 204, headers: responseHeaders });
|
||||
return json(body || { error: "upstream_unavailable" }, upstream.status);
|
||||
// 这里只归一化代理传输/解析错误;成功开始转发后的流错误由流自身传播,不能补发第二个JSON响应。
|
||||
} catch (error) {
|
||||
responseHeaders.set("Server-Timing", `total;dur=${(performance.now() - started).toFixed(1)}`);
|
||||
const timeout = error instanceof Error && error.name === "TimeoutError";
|
||||
@@ -170,6 +214,7 @@ async function handler(
|
||||
}
|
||||
}
|
||||
|
||||
/** 显式拒绝PUT/HEAD/OPTIONS并说明允许方法,不依赖框架自动推导另一套接口行为。 */
|
||||
function methodNotAllowed() {
|
||||
return NextResponse.json({ error: "method_not_allowed" }, {
|
||||
status: 405,
|
||||
@@ -177,5 +222,6 @@ function methodNotAllowed() {
|
||||
});
|
||||
}
|
||||
|
||||
// 同一白名单/体积/会话策略覆盖允许的四种方法;具体操作是否合法仍由服务端端点决定。
|
||||
export { handler as GET, handler as POST, handler as PATCH, handler as DELETE };
|
||||
export { methodNotAllowed as PUT, methodNotAllowed as HEAD, methodNotAllowed as OPTIONS };
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
/**
|
||||
* 第三方登录的服务端跳转入口。只接受固定provider及单一PKCE challenge,
|
||||
* 重定向到配置的Foundry项目,不交换令牌、不读取verifier,也不在此创建会话Cookie。
|
||||
*/
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
/** await异步provider参数,challenge须为43字符base64url;多值或未知provider直接400。
|
||||
* 302显式no-store,避免浏览器把某一次登录challenge的跳转当成可复用响应。 */
|
||||
export async function GET(
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ provider: string }> },
|
||||
@@ -13,6 +19,7 @@ export async function GET(
|
||||
!/^[-_a-zA-Z0-9]{43}$/.test(challenge)
|
||||
)
|
||||
return NextResponse.json({ error: "invalid_request" }, { status: 400 });
|
||||
// 上游源站/项目由宿主环境装配,不让查询参数决定任意OAuth重定向目标。
|
||||
const origin = process.env.FOUNDRY_API_ORIGIN || "https://api.foundry.entroia.com";
|
||||
const project = process.env.NEXT_PUBLIC_FOUNDRY_PROJECT || "endoleap";
|
||||
return NextResponse.redirect(
|
||||
|
||||
@@ -1 +1,5 @@
|
||||
/**
|
||||
* 认证回调页面复用登录页。真正读取URL片段、邮箱验证或PKCE交换的逻辑集中在login/page,
|
||||
* 防止两条入口分别实现不同的令牌/Cookie处理规则。
|
||||
*/
|
||||
export { default } from "../../login/page";
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
/**
|
||||
* 工作台画布路由的服务器入口,只解析异步路径参数并装配GraphPage。
|
||||
* 具体board引用解析、账户作用域、新旧模型选择与编辑器生命周期由GraphPage及其加载器负责。
|
||||
*/
|
||||
import { Suspense } from "react";
|
||||
import GraphPage from "@/components/workspace/graph-page";
|
||||
|
||||
/** params按当前Next版本await;Suspense给客户端画布准备过程提供状态占位,不在此发模型请求。 */
|
||||
export default async function Page({ params }: { params: Promise<{ workspace: string; board: string }> }) {
|
||||
const { workspace, board } = await params;
|
||||
return (
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
/**
|
||||
* 工作台共享侧栏与浮动快捷条。管理账户/工作区目录、搜索、折叠、改名和账户面板,
|
||||
* 活动board元数据来自Context,画布业务状态由RuntimeHost拥有;这里不持有第二份元素文档。
|
||||
* 浮动条在画布路由或侧栏折叠时显示,展开按钮仅在折叠时显示,收起按钮留在展开侧栏中。
|
||||
*/
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
@@ -12,14 +17,17 @@ 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";
|
||||
import { BoardDirectory } from "@/components/workspace/board-directory";
|
||||
import styles from "@/components/workspace/workspace.module.css";
|
||||
|
||||
/** 路径/查询参数优先定位空间,目录解析真实ID,UI状态与账户/目录异步生命周期分别维护。 */
|
||||
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,19 +41,23 @@ 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);
|
||||
const expandButton = useRef<HTMLButtonElement>(null);
|
||||
const collapseButton = useRef<HTMLButtonElement>(null);
|
||||
const hasToggled = useRef(false);
|
||||
// 同步ref防重复点击创建,listVersion隔离迟到目录列表;二者都不是runtime账户authEpoch。
|
||||
const createLock = useRef(false);
|
||||
const listVersion = useRef(0);
|
||||
const accountPopup = useRef<HTMLDivElement>(null);
|
||||
// 路径workspace优先于查询和本地选择;slug由目录解析,活动board可补充该路由引用的真实ID。
|
||||
const workspaceRef = params.workspace || searchParams.get("workspace") || selectedWorkspace;
|
||||
const currentWorkspace = workspaces.find((item) => matchesRef(item, workspaceRef));
|
||||
const workspace = currentWorkspace?.id || (activeBoard?.workspace_ref === workspaceRef ? activeBoard.workspace_id : workspaceRef);
|
||||
|
||||
/** demo用本地资料,真实模式并行加载profile/空间;active+signal阻止卸载后的资料结果写入。 */
|
||||
useEffect(() => {
|
||||
if (demo) {
|
||||
setProfile({ id: "demo", username: "creator", display_name: "创作者", email: null });
|
||||
@@ -74,6 +86,7 @@ export default function DashboardSidebar() {
|
||||
return () => { active = false; controller.abort(); };
|
||||
}, [demo, router]);
|
||||
|
||||
/** 每次目录加载先留本空间项,再取完整分页;仅最新listVersion可采用列表,换空间时中断旧请求。 */
|
||||
useEffect(() => {
|
||||
if (!workspace || status !== "ready") return;
|
||||
const version = ++listVersion.current;
|
||||
@@ -90,13 +103,17 @@ export default function DashboardSidebar() {
|
||||
return () => { active = false; controller.abort(); };
|
||||
}, [workspace, demo, status, directoryRevision]);
|
||||
|
||||
/** 同工作区目录变化事件合并100ms后重读,清理时撤销计时和监听,不跨空间误刷新。 */
|
||||
useEffect(() => {
|
||||
let timer: ReturnType<typeof setTimeout>;
|
||||
/** 忽略其它workspace事件;多次事件只触发一次revision递增和后续完整目录读取。 */
|
||||
const refresh = (event: Event) => { if ((event as CustomEvent).detail?.workspace !== workspace) return; clearTimeout(timer); timer = setTimeout(() => setDirectoryRevision((value) => value + 1), 100); };
|
||||
window.addEventListener("endoleap:board-directory-changed", refresh);
|
||||
return () => { clearTimeout(timer); window.removeEventListener("endoleap:board-directory-changed", refresh); };
|
||||
}, [workspace]);
|
||||
|
||||
/** 就绪且未重复创建才执行,真实模式必要时先建个人空间,demo只写本地目录。
|
||||
* 成功递增listVersion防旧列表盖掉新项,再清搜索并导航;创建锁仅保护此UI操作,不替代账户身份护栏。 */
|
||||
async function newPage() {
|
||||
if (createLock.current || status !== "ready") return;
|
||||
createLock.current = true;
|
||||
@@ -118,6 +135,7 @@ export default function DashboardSidebar() {
|
||||
finally { createLock.current = false; setCreating(false); }
|
||||
}
|
||||
|
||||
/** 用户实际切换折叠后,下一帧聚焦新出现的展开/收起按钮;初始渲染不抢焦点。 */
|
||||
useEffect(() => {
|
||||
if (!hasToggled.current) return;
|
||||
const frame = requestAnimationFrame(() => {
|
||||
@@ -126,31 +144,38 @@ export default function DashboardSidebar() {
|
||||
return () => cancelAnimationFrame(frame);
|
||||
}, [collapsed]);
|
||||
|
||||
/** 先关闭账户popover再切布局,避免旧锚点残留弹层;焦点转移交给effect。 */
|
||||
function toggleSidebar(value: boolean) {
|
||||
accountPopup.current?.hidePopover();
|
||||
hasToggled.current = true;
|
||||
setCollapsed(value);
|
||||
}
|
||||
|
||||
/** 关闭popover后打开设置/新空间对话框,避免两层交互同时争焦点。 */
|
||||
function openPanel(mode: "settings" | "workspace") {
|
||||
accountPopup.current?.hidePopover();
|
||||
setPanel(mode);
|
||||
}
|
||||
|
||||
/** 真实退出先retireIdentity并完成本地关闭/保存,再请求服务登出;失败留在当前界面报告,不跳过保存。 */
|
||||
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)); }
|
||||
}
|
||||
|
||||
// 已解析活动board标题优先,退回目录标题或加载文案;不从另一工作区的活动记录借名。
|
||||
const currentTitle = activeBoard && matchesRef(activeBoard, params.board) && activeBoard.workspace_id === workspace
|
||||
? activeBoard.title
|
||||
: boards.find((board) => matchesRef(board, params.board))?.title || (params.board ? "正在读取图谱…" : "工作空间");
|
||||
const name = profile?.display_name || profile?.username || "账户";
|
||||
// 计数用标题直接匹配;树中保留的搜索祖先由BoardDirectory独立派生,因此数目不等于所有可见行数。
|
||||
const filtered = boards.filter((b) => b.title.toLocaleLowerCase().includes(query.trim().toLocaleLowerCase()));
|
||||
// UI能力只作提示/禁用,真实创建权限仍由服务端判定,未知空间不能当成已有编辑权限。
|
||||
const canCreate = status === "ready" && (!workspace || Boolean(currentWorkspace && currentWorkspace.role !== "viewer"));
|
||||
|
||||
/** 成功后同时更新本地目录与匹配的活动board元数据;错误转换成文案交EditableTitle保留草稿显示。 */
|
||||
async function renameTitle(title: string) {
|
||||
const id = activeBoard?.id || params.board;
|
||||
if (!id || !workspace) return;
|
||||
@@ -164,21 +189,24 @@ export default function DashboardSidebar() {
|
||||
} catch (error) { throw new Error(errorText(error)); }
|
||||
}
|
||||
|
||||
// 创建入口复用同一busy/权限状态,降低重复点击,但不在render中发请求。
|
||||
const createButton = <IconButton icon={FilePlus2} label={creating ? "正在创建图谱" : "新建页面"} disabled={!canCreate || creating} onClick={newPage} aria-busy={creating} />;
|
||||
|
||||
// 尊重减少动态效果偏好;折叠侧栏同时inert/aria-hidden,避免不可见内容仍接受键盘交互。
|
||||
const transition = { duration: reduceMotion ? 0 : 0.24, ease: [0.22, 1, 0.36, 1] as const };
|
||||
const roleLabel = currentWorkspace?.role === "admin" ? "管理员" : currentWorkspace?.role === "editor" ? "编辑者" : currentWorkspace?.role === "viewer" ? "查看者" : "工作空间";
|
||||
|
||||
// 展示分为浮动条、侧栏目录、共享账户popover和账户面板;路由/空间作为key让标题及目录局部状态重置。
|
||||
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 +217,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 +248,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 +256,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>
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
/**
|
||||
* 工作台共享布局,装配当前画布Context、侧栏及子路由,并加载React Flow默认样式。
|
||||
* 布局本身只放侧栏与children;根叶页面为空,画布子路由通过children提供主编辑区。
|
||||
*/
|
||||
import "@xyflow/react/dist/style.css";
|
||||
import { Suspense, type ReactNode } from "react";
|
||||
import { ActiveBoardProvider } from "@/components/workspace/active-board";
|
||||
import DashboardSidebar from "./dashboard";
|
||||
import styles from "@/components/workspace/workspace.module.css";
|
||||
|
||||
/** 当前board信息由ActiveBoardProvider跨侧栏/画布共享;Suspense只包侧栏,fallback保留其布局宽度。 */
|
||||
export default function DashboardLayout({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<ActiveBoardProvider>
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
/**
|
||||
* 工作台根路径的空叶页面。共享dashboard布局继续显示侧栏,
|
||||
* 这里返回null是路由占位,不代表工作区没有内容或需要创建一张空画布。
|
||||
*/
|
||||
/** 保留共享布局,不额外重复渲染工作台内容。 */
|
||||
export default function DashboardPage() {
|
||||
return null;
|
||||
}
|
||||
|
||||
+8
-1
@@ -1,8 +1,14 @@
|
||||
/**
|
||||
* 整个App Router的根布局:统一页面元数据、全局CSS、应用反馈和CanvasRuntimeHost装配。
|
||||
* 子页面/画布视图借用同一个宿主,普通路由切换不应各自创建另一份项目运行时。
|
||||
*/
|
||||
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";
|
||||
import "@/styles/globals.css";
|
||||
|
||||
/** 标题/描述及不同色彩偏好的favicon,交给Next元数据系统生成页面头,不在客户端effect中改DOM。 */
|
||||
export const metadata: Metadata = {
|
||||
title: "啡跃 Endoleap · 让灵感有形",
|
||||
description: "收藏灵感、连接知识,在自己的创作空间让想法慢慢成形。",
|
||||
@@ -16,10 +22,11 @@ export const metadata: Metadata = {
|
||||
},
|
||||
};
|
||||
|
||||
/** 服务端布局输出中文html/body壳;客户端反馈Provider包在runtime宿主外侧,供宿主状态提示使用。 */
|
||||
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,3 +1,7 @@
|
||||
/**
|
||||
* 登录、注册及认证回调共用的客户端页面。左侧品牌说明为展示内容,右侧管理表单/OAuth/验证状态。
|
||||
* 成功会话由同源代理写HttpOnly Cookie,组件不持有access_token;busy仅控制当前表单交互和加载提示。
|
||||
*/
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, type FormEvent } from "react";
|
||||
@@ -13,6 +17,7 @@ import {
|
||||
import { Brand } from "@/components/brand";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { api, errorText, startOAuth } from "@/lib/api";
|
||||
/** 状态分别表示登录/注册模式、邮箱密码、密码可见性、请求忙碌和成功/错误文案,均不存入业务文档。 */
|
||||
export default function Login() {
|
||||
const router = useRouter();
|
||||
const [register, setRegister] = useState(false),
|
||||
@@ -22,6 +27,8 @@ export default function Login() {
|
||||
[busy, setBusy] = useState(false),
|
||||
[message, setMessage] = useState(""),
|
||||
[error, setError] = useState("");
|
||||
/** 页面挂载读取回调片段,先清掉URL查询/片段再发验证或交换请求;这样重跑不会再次消费同一片段。
|
||||
* 此effect没有单独AbortController,不具备画布loader的导航代次隔离;结果由此页面的router/state回调处理。 */
|
||||
useEffect(() => {
|
||||
const hash = new URLSearchParams(location.hash.slice(1));
|
||||
const code = hash.get("code"),
|
||||
@@ -34,8 +41,10 @@ export default function Login() {
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
// verifier从本标签页sessionStorage取出即删除,避免后续登录误复用;verify_email分支优先于code交换。
|
||||
const verifier = sessionStorage.getItem("foundry_verifier");
|
||||
sessionStorage.removeItem("foundry_verifier");
|
||||
// 邮箱验证提交token;OAuth交换只用一次性code+verifier,请求代理负责把返回令牌改写成Cookie。
|
||||
(verify
|
||||
? api("auth/verify-email", {
|
||||
method: "POST",
|
||||
@@ -55,6 +64,8 @@ export default function Login() {
|
||||
.catch((e) => setError(errorText(e)))
|
||||
.finally(() => setBusy(false));
|
||||
}, [router]);
|
||||
/** 阻止原生表单跳转,提交当前模式对应端点;注册后提示验证邮件,登录成功才push工作台。
|
||||
* HTML输入限制给即时反馈,服务端仍负责真实凭证/注册校验。 */
|
||||
async function submit(e: FormEvent) {
|
||||
e.preventDefault();
|
||||
setBusy(true);
|
||||
@@ -74,6 +85,7 @@ export default function Login() {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
/** 清旧提示,创建PKCE后整页跳转;发起失败恢复表单状态,成功保持busy等待浏览器导航。 */
|
||||
async function oauth(p: "google" | "slack") {
|
||||
setError("");
|
||||
setMessage("正在前往 " + (p === "google" ? "Google" : "Slack") + "…");
|
||||
@@ -86,6 +98,8 @@ export default function Login() {
|
||||
setError("无法启动登录,请重新尝试。");
|
||||
}
|
||||
}
|
||||
// 模式切换只清提示,已有邮箱/密码和可见性独立保留。
|
||||
// 渲染分为品牌故事、模式切换、受控凭证表单、OAuth按钮与demo入口;错误用alert,非错误提示用status。
|
||||
return (
|
||||
<div className="auth-page">
|
||||
<aside className="auth-story">
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
/**
|
||||
* 公开首页与已登录工作台的服务器分流。Cookie存在不是有效会话证明,需向profile端点验证后才重定向。
|
||||
* 会话无效或服务暂不可达仍显示公开LandingPage,不因为一枚旧Cookie隐藏公开内容。
|
||||
*/
|
||||
import { cookies } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
import LandingPage from "@/components/landing-page";
|
||||
|
||||
/** 异步读取请求Cookie,带15秒时限/禁缓存验证上游;只需成功状态,不消费完整profile JSON。 */
|
||||
export default async function Home() {
|
||||
const session = (await cookies()).get("foundry_session")?.value;
|
||||
let authenticated = false;
|
||||
@@ -15,12 +20,14 @@ export default async function Home() {
|
||||
redirect: "manual",
|
||||
signal: AbortSignal.timeout(15000),
|
||||
});
|
||||
// 按响应状态确定是否已认证,再丢弃不需要的body;此处不解析profile字段。
|
||||
authenticated = response.ok;
|
||||
await response.body?.cancel();
|
||||
} catch {
|
||||
// An unverified session must not hide the public landing page.
|
||||
}
|
||||
}
|
||||
// 仅在确认成功后跳工作台,未认证时沿用同一静态展示组件。
|
||||
if (authenticated) redirect("/dashboard");
|
||||
return <LandingPage />;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
/**
|
||||
* 公用品牌首页链接。组合Logo、中文名与英文名,供登录页、落地页等位置复用;不读取用户或工作区状态。
|
||||
*/
|
||||
import Link from "next/link";
|
||||
/** light选择brand-light展示样式以及配套Logo资源:true用logo-dark.svg,false用logo-light.svg。
|
||||
* 链接的aria-label已经说明品牌与目的地,图片使用空alt避免重复朗读;固定尺寸减少加载时布局跳动。 */
|
||||
export function Brand({ light = false }: { light?: boolean }) {
|
||||
return (
|
||||
<Link
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
/**
|
||||
* 旧版画布的节点AI浮层。通过useCanvasAI返回的控制接口管理生成,模型目录由旧画布AI接口读取。
|
||||
* 配置草稿保存在本地state,save回调将配置写回旧节点;本组件不承担新版ledger调度与恢复。
|
||||
*/
|
||||
"use client";
|
||||
import { useEffect, useState } from "react";
|
||||
import { NodeToolbar, Position } from "@xyflow/react";
|
||||
@@ -8,6 +12,7 @@ import { CANVAS_ACCEPT, managedImageSource } from "@/lib/canvas-v2";
|
||||
import type { CanvasNode } from "@/lib/canvas";
|
||||
import type { CanvasModel, useCanvasAI } from "./use-canvas-ai";
|
||||
import styles from "./canvas.module.css";
|
||||
/** 以boardId+node.id定位run步骤,列表最后一项作为当前任务;NodeToolbar将面板附着在显示节点下方。 */
|
||||
export function AIPanel({ id, node, workspace, boardId, ai, save }: { id: string; node: CanvasNode; workspace: string; boardId: string; ai: ReturnType<typeof useCanvasAI>; save: (patch: NonNullable<CanvasNode["data"]["ai"]>) => void }) {
|
||||
const [models, setModels] = useState<CanvasModel[]>([]);
|
||||
const [model, setModel] = useState(node.data.ai?.modelId || "");
|
||||
@@ -19,7 +24,10 @@ export function AIPanel({ id, node, workspace, boardId, ai, save }: { id: string
|
||||
const image = ["image", "plain-image"].includes(node.data.kind);
|
||||
const relevant = [...ai.runs.values()].filter((run) => run.steps?.some((step) => step.board_id === boardId && step.node_id === node.id));
|
||||
const run = relevant.at(-1), step = run?.steps?.find((step) => step.board_id === boardId && step.node_id === node.id);
|
||||
// starting防止本地重复启动,之后以步骤queued/running判断生成忙碌;这不是整份画布的同步锁。
|
||||
const busy = starting || step?.status === "queued" || step?.status === "running";
|
||||
// 演示/非live路径不请求模型;真实路径按输出模态筛选,并保留仍兼容选择或回退到首个可用模型。
|
||||
// 清理时abort,失败和finally检查signal再更新;成功分支直接采用api返回的结果。
|
||||
useEffect(() => {
|
||||
if (!ai.live) { setLoading(false); return; }
|
||||
const controller = new AbortController();
|
||||
@@ -30,10 +38,16 @@ export function AIPanel({ id, node, workspace, boardId, ai, save }: { id: string
|
||||
}).catch((error) => { if (!controller.signal.aborted) setError(errorText(error)); }).finally(() => { if (!controller.signal.aborted) setLoading(false); });
|
||||
return () => controller.abort();
|
||||
}, [workspace, boardId, image, ai.live]);
|
||||
/** 构造当前输入配置,参数固定为空对象;状态切换事件需显式替换刚改变的值,避免读取旧React state。 */
|
||||
const config = () => ({ enabled: true, modelId: model, prompt, propagate, parameters: {} });
|
||||
/** 输入blur时,有模型且live才调用save;仅持久化配置,不启动服务端任务。 */
|
||||
const persist = () => { if (model && ai.live) save(config()); };
|
||||
// output取当前步骤资源,文本内容则从最后一个step.output事件取;两者按同一board/node限定。
|
||||
const output = run?.outputs?.find((item) => item.step_id === step?.step_id)?.output;
|
||||
const textOutput = run?.events?.filter((event) => event.type === "step.output" && event.node_id === node.id && event.board_id === boardId).at(-1)?.output?.text;
|
||||
// 模型和传播开关变化即时save,正文blur保存;执行由ai.run负责,starting在finally复位。
|
||||
// 上传中、演示或没有可用模型禁止启动;与新版不同,此处未要求prompt非空,也未在busy时禁用textarea。
|
||||
// 冲突时保留结果并给显式apply入口,错误优先显示本面板错误;关闭/取消任务的行为交由父层与ai接口决定。
|
||||
return <NodeToolbar nodeId={id} isVisible position={Position.Bottom} offset={26} style={{ zIndex: 110 }}><section className={`${styles.aiPanel} nodrag nopan nowheel`} aria-label="元素 AI 工具"
|
||||
onPointerDown={(event) => event.stopPropagation()} onClick={(event) => event.stopPropagation()} onDoubleClick={(event) => event.stopPropagation()} onKeyDown={(event) => event.stopPropagation()}>
|
||||
<header><Sparkles size={14} /><strong>{image ? "图像创作" : "文本创作"}</strong><span>{node.data.title}</span></header>
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
/**
|
||||
* 从节点连接点发起的分支类型选择菜单。Portal挂在body,使用屏幕坐标定位;选择结果交给choose创建内容与连线。
|
||||
*/
|
||||
"use client";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
|
||||
@@ -6,10 +9,14 @@ import { FileText, ImagePlus, StickyNote, X } from "lucide-react";
|
||||
import type { XYPosition } from "@xyflow/react";
|
||||
import type { NodeDirection } from "@/lib/canvas";
|
||||
import styles from "./canvas.module.css";
|
||||
/** id/direction标识发起节点和边,point是菜单屏幕位置,at可由调用方保存拟创建位置;菜单不解释at。 */
|
||||
export type BranchRequest = { id: string; direction: NodeDirection; point: XYPosition; at?: XYPosition };
|
||||
/** request为空时不显示,类型固定为文本/图片/脚本;这里只派发选择,不执行上传或文档修改。 */
|
||||
export function BranchMenu({ request, close, choose }: { request: BranchRequest | null; close: () => void; choose: (kind: "text" | "image" | "script") => void }) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const reduce = useReducedMotion();
|
||||
// 菜单打开后保存原焦点并聚焦首个类型按钮;点击外部关闭,Escape关闭且恢复原焦点。
|
||||
// 仅在request存在时安装全局监听,变化/卸载时拆除;普通选择或外部关闭不在此强制恢复焦点。
|
||||
useEffect(() => {
|
||||
if (!request) return;
|
||||
const focus = document.activeElement as HTMLElement | null;
|
||||
@@ -19,6 +26,8 @@ export function BranchMenu({ request, close, choose }: { request: BranchRequest
|
||||
window.addEventListener("pointerdown", outside); window.addEventListener("keydown", escape, true);
|
||||
return () => { window.removeEventListener("pointerdown", outside); window.removeEventListener("keydown", escape, true); };
|
||||
}, [request, close]);
|
||||
// 无document时不创建Portal。菜单位置按预设宽高限制在视口边距内;上下键循环所有按钮(含关闭)。
|
||||
// 减少动态效果偏好禁用位移/缩放动画;退出动画仍通过AnimatePresence管理。
|
||||
if (typeof document === "undefined") return null;
|
||||
return createPortal(<AnimatePresence>{request && <motion.div ref={ref} key={request.id + request.direction} initial={{ opacity: 0, scale: reduce ? 1 : 0.96, y: reduce ? 0 : 4 }} animate={{ opacity: 1, scale: 1, y: 0 }} exit={{ opacity: 0, scale: reduce ? 1 : 0.96 }} transition={{ duration: reduce ? 0 : 0.14 }} role="dialog" aria-label="选择连接节点类型" className={styles.branchMenu} style={{ left: Math.max(12, Math.min(request.point.x + 12, window.innerWidth - 222)), top: Math.max(12, Math.min(request.point.y + 12, window.innerHeight - 208)) }}
|
||||
onKeyDown={(event) => { event.stopPropagation(); if (event.key === "ArrowDown" || event.key === "ArrowUp") { event.preventDefault(); const buttons = Array.from(ref.current?.querySelectorAll<HTMLButtonElement>("button") || []); const index = buttons.indexOf(document.activeElement as HTMLButtonElement); buttons[(index + (event.key === "ArrowDown" ? 1 : -1) + buttons.length) % buttons.length]?.focus(); } }}>
|
||||
|
||||
@@ -1,26 +1,33 @@
|
||||
/**
|
||||
* 画布连线显示与贝塞尔链编辑。根据端点生成路径,叠加箭头、选中轮廓和动画,选中时提供曲线点及控制柄。
|
||||
* 指针拖动通过租约预览后提交;旧接口保留transient patch兼容路径。组件不自行同步服务端或管理撤销栈。
|
||||
*/
|
||||
"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;
|
||||
/** 旧节点用绝对位置替换局部position后,交compatConnector计算旋转/缩放等兼容端点。 */
|
||||
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]};
|
||||
}
|
||||
/** 生成可复用SVG端部标记;none不绘制,开放箭头不填充,其余按filled使用线色或画布底色。
|
||||
* userSpaceOnUse使大小不随线宽缩放,auto-start-reverse支持起终两端朝向。 */
|
||||
function Marker({ id, value, color }: { id: string; value?: EdgeMarker; color: string }) {
|
||||
const type = value?.type || "none";
|
||||
if (type === "none") return null;
|
||||
@@ -28,6 +35,7 @@ function Marker({ id, value, color }: { id: string; value?: EdgeMarker; color: s
|
||||
<path d={type === "triangle" ? "M1 1L11 6L1 11Z" : type === "open-arrow" ? "M1 1L11 6L1 11" : type === "diamond" ? "M1 6L6 1L11 6L6 11Z" : type === "square" ? "M1 1H11V11H1Z" : "M11 6a5 5 0 1 0-10 0a5 5 0 1 0 10 0"} fill={type === "open-arrow" ? "none" : value?.filled !== false ? color : "#fffefb"} stroke={color} strokeWidth={1.2} />
|
||||
</marker>;
|
||||
}
|
||||
/** 读取两端内部节点、视口与编辑上下文;selectedKnot仅是当前控件选择,不进入连线数据。 */
|
||||
export function CanvasEdge(props: EdgeProps<CanvasEdgeModel>) {
|
||||
const source = useInternalNode<CanvasNode>(props.source), target = useInternalNode<CanvasNode>(props.target);
|
||||
const editing = useContext(EdgeEditing);
|
||||
@@ -35,40 +43,68 @@ 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);
|
||||
// 手势记录控制点id/part与捕获目标;事件监听通过AbortController统一拆除,lease可缺省以兼容旧编辑器。
|
||||
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);
|
||||
// 原生监听通过ref使用最新move/stop/cancel,避免投影更新后继续用过期path或端点闭包。
|
||||
const pointerHandlers = useRef<{ move(event: PointerEvent<SVGCircleElement>): void; stop(event: PointerEvent<SVGCircleElement>): void; cancel(event: PointerEvent<SVGCircleElement>): void } | null>(null);
|
||||
// 只挂载一次的失焦/卸载监听经ref调用最新cancel,释放未完成手势。
|
||||
const interrupt=useRef(()=>{});interrupt.current=()=>cancel();
|
||||
useEffect(()=>{const blur=()=>interrupt.current();window.addEventListener('blur',blur);return()=>{window.removeEventListener('blur',blur);interrupt.current();};},[]);
|
||||
// 任一端点节点未就绪就不绘制;新版端点使用ReactFlow计算结果,旧版由兼容几何计算。
|
||||
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 || {};
|
||||
// 贝塞尔链生成可绘制path;工具栏锚点取中间一段的t=0.5,属于近似中心而非整条曲线的弧长中点。
|
||||
const segments = curveSegments(from, to, data.path), path = curvePath(segments);
|
||||
const midpoint = cubicPoint(segments[Math.floor(segments.length / 2)], .5);
|
||||
const color = appearance.color || "#aa968b", width = appearance.width || 1.5;
|
||||
// 清洗边ID供SVG marker引用;动画尊重减少动态效果,executing模式只在active集合含本边时播放。
|
||||
const markerId = `edge-${props.id.replace(/[^a-zA-Z0-9_-]/g, "_")}`;
|
||||
const animated = !reduce && (animation.mode === "always" || animation.mode === "executing" && editing.active.has(props.id));
|
||||
const knots = data.path?.knots || [];
|
||||
/** 最多64个中间曲线点;insertCurveKnot按给定点或默认位置拆分曲线,提交后选择新点。 */
|
||||
function add(point?: { x: number; y: number }) {
|
||||
if (knots.length >= 64) return;
|
||||
const next = insertCurveKnot(from, to, data.path, point);
|
||||
editing.patch(props.id, { ...data, path: next.path }); setSelectedKnot(next.knot.id);
|
||||
}
|
||||
/** 仅未处理的主指针左键可开始。已有手势不被重复接管,另一指针到来先取消原手势。
|
||||
* 捕获优先放在稳定的画布surface上,避免投影替换SVG控制点后丢掉后续事件。 */
|
||||
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;
|
||||
// begin返回false拒绝;返回对象则保存租约,其余返回值走旧接口,控制点选择仍是组件局部状态。
|
||||
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);
|
||||
/** 原生监听先按pointerId过滤,再交当前处理器;取消和lostpointercapture仅在手势仍存在时处理。 */
|
||||
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();
|
||||
}
|
||||
/** 屏幕点转画布点,并克隆当前path后更新选中部分;只接受当前捕获指针的事件。 */
|
||||
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: [] });
|
||||
// 端部控制柄保存相对端点的向量;中间点位置为绝对画布点,中间控制柄保存相对knot.position的向量。
|
||||
if (current.part === "startControl" || current.part === "endControl") next[current.part] = minus(point, current.part === "startControl" ? from.point : to.point);
|
||||
else {
|
||||
const knot = next.knots.find((knot) => knot.id === current.id);
|
||||
if (!knot) return;
|
||||
if (current.part === "position") knot.position = point;
|
||||
else {
|
||||
// corner两柄独立;smooth保持另一柄长度且方向共线反向;symmetric两柄长度也相同。
|
||||
const vector = minus(point, knot.position), other = current.part === "in" ? "out" : "in";
|
||||
const length = Math.hypot(knot[other].x, knot[other].y), nextLength = Math.hypot(vector.x, vector.y);
|
||||
knot[current.part] = vector;
|
||||
// Shift把该曲线点模式改为symmetric,这个mode会随本次path修改一起提交。
|
||||
if (event.shiftKey) knot.mode = "symmetric";
|
||||
if (knot.mode !== "corner") {
|
||||
const scale = knot.mode === "symmetric" ? 1 : nextLength ? length / nextLength : 0;
|
||||
@@ -76,14 +112,32 @@ export function CanvasEdge(props: EdgeProps<CanvasEdgeModel>) {
|
||||
}
|
||||
}
|
||||
}
|
||||
editing.patch(props.id, { ...data, path: next }, true);
|
||||
// 优先本次租约的preview;旧版以transient=true派发path修改,结束时另行finish/cancel。
|
||||
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);
|
||||
}
|
||||
// 每次渲染刷新原生事件转发目标;控制点自身也挂React事件,所有处理器均检查手势/捕获归属。
|
||||
pointerHandlers.current = { move, stop, cancel };
|
||||
/** 控制点屏幕半径与描边按zoom反向补偿。点击选择点,指针可拖;键盘仅移动曲线点位置或端部柄。
|
||||
* 方向键一步1单位、Shift为10单位;中间in/out柄的键盘分支直接返回,不修改曲线。 */
|
||||
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) };
|
||||
@@ -93,6 +147,9 @@ export function CanvasEdge(props: EdgeProps<CanvasEdgeModel>) {
|
||||
editing.patch(props.id, { ...data, path: next });
|
||||
}
|
||||
}} />;
|
||||
// 双击路径按点击位置插点;选中描边和动画不接收指针,BaseEdge提供较宽的交互区域。
|
||||
// flow用SVG animateMotion沿path运行;dash/pulse使用CSS动画,方向与时长来自连线数据。
|
||||
// 选中时绘制工具栏、所有knot位置和首尾柄,仅被选中knot显示自己的in/out柄。
|
||||
return <g onDoubleClick={(event) => { event.preventDefault(); event.stopPropagation(); add(flow.screenToFlowPosition({ x: event.clientX, y: event.clientY })); }}>
|
||||
<defs><Marker id={markerId + "-head"} value={appearance.head} color={color} /><Marker id={markerId + "-tail"} value={appearance.tail} color={color} /></defs>
|
||||
{props.selected && <path d={path} fill="none" stroke="#a47764" strokeOpacity={.18} strokeWidth={width + 7 / zoom} pointerEvents="none" />}
|
||||
@@ -100,6 +157,7 @@ export function CanvasEdge(props: EdgeProps<CanvasEdgeModel>) {
|
||||
{animated && (animation.effect === "flow" || !animation.effect ? <g pointerEvents="none"><circle r={Math.max(3, width * 1.5)} fill={animation.color || "#fff"}><animateMotion path={path} dur={`${animation.durationMs || 1600}ms`} repeatCount="indefinite" keyPoints={animation.direction === "reverse" ? "1;0" : "0;1"} keyTimes="0;1" calcMode="linear" /></circle></g> : <path d={path} fill="none" pointerEvents="none" stroke={animation.color || "#fff"} strokeWidth={width + 1} strokeDasharray={animation.effect === "dash" ? "6 14" : undefined} className={animation.effect === "dash" ? styles.edgeDashAnimation : styles.edgePulseAnimation} style={{ animationDuration: `${animation.durationMs || 1600}ms`, animationDirection: animation.direction === "reverse" ? "reverse" : "normal" }} />)}
|
||||
{props.selected && <>
|
||||
<EdgeToolbarControls id={props.id} x={midpoint.x} y={midpoint.y - 24 / zoom} data={data} change={(data) => editing.patch(props.id, data)} count={knots.length} knotMode={(knots.find((knot) => knot.id === selectedKnot) || knots.at(-1))?.mode} changeKnotMode={(mode) => {
|
||||
// 工具栏模式应用于选中knot,找不到时退到最后一点;平滑/镜像优先以out柄为方向基准重算in柄。
|
||||
const next = structuredClone(data.path!); const knot = next.knots.find((knot) => knot.id === selectedKnot) || next.knots.at(-1); if (!knot) return;
|
||||
knot.mode = mode; if (mode !== "corner") {
|
||||
if (!Math.hypot(knot.out.x, knot.out.y)) knot.out = { x: -knot.in.x, y: -knot.in.y };
|
||||
@@ -117,6 +175,8 @@ export function CanvasEdge(props: EdgeProps<CanvasEdgeModel>) {
|
||||
</>}
|
||||
</g>;
|
||||
}
|
||||
/** 连接拖动中的临时预览线。两端命中Handle时使用兼容连接点,尚未命中时以指针位置作终点并反转起点法向。
|
||||
* 只绘制单段connectorBezier,不创建或保存实际连线;调用方样式可覆盖默认线色/线宽。 */
|
||||
export function CanvasConnectionLine(props: ConnectionLineComponentProps<CanvasNode>) {
|
||||
const from = connector(props.fromNode, props.fromHandle.id, "right");
|
||||
const to = props.toNode && props.toHandle ? connector(props.toNode, props.toHandle.id, "left") : { point: { x: props.toX, y: props.toY }, normal: { x: -from.normal.x, y: -from.normal.y } };
|
||||
|
||||
@@ -1,11 +1,21 @@
|
||||
/**
|
||||
* 按名称异步加载画布SVG图标。先读取共享缓存,再订阅图标加载器,展示加载中/失败/已就绪三种图形。
|
||||
* 组件负责显示和重试入口;网络并发、缓存以及订阅取消的实现位于canvas-icon-loader。
|
||||
*/
|
||||
"use client";
|
||||
import { createElement, useEffect, useState } from "react";
|
||||
import type { LucideProps } from "lucide-react";
|
||||
import { loadIcon, peekIcon, type IconPaths } from "@/lib/canvas-icon-loader";
|
||||
|
||||
/** 兼容Lucide尺寸与描边属性,name默认Star;width/height未提供时跟随size。
|
||||
* 成功后按加载器返回的SVG标签与属性创建子元素,避免把整段SVG作为HTML插入。 */
|
||||
export function CanvasIcon({ name = "Star", size = 24, width = size, height = size, strokeWidth = 2, absoluteStrokeWidth, ...props }: LucideProps & { name?: string }) {
|
||||
// 结果携带图标名,渲染时只采用当前name对应的内容,换名时不会短暂展示上一个图标。
|
||||
const [result, setResult] = useState<{ name: string; paths?: IconPaths; error?: boolean }>(() => ({ name, paths: peekIcon(name) }));
|
||||
// retry仅用于触发同名图标重新订阅;它不是全局缓存版本或网络请求ID。
|
||||
const [retry, setRetry] = useState(0);
|
||||
// 换名或点击重试后,先采用可同步取得的缓存,再等待loadIcon。清理时取消本组件订阅;
|
||||
// AbortError不显示失败,其它错误显示重试占位。底层决定共享请求是否仍有其它使用者。
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
setResult({ name, paths: peekIcon(name) });
|
||||
@@ -14,8 +24,12 @@ export function CanvasIcon({ name = "Star", size = 24, width = size, height = si
|
||||
});
|
||||
return () => controller.abort();
|
||||
}, [name, retry]);
|
||||
// effect可能还没处理新name,故读取结果前再次比对名称;失败标记也必须属于当前图标。
|
||||
const paths = result.name === name ? result.paths : undefined;
|
||||
const failed = result.name === name && result.error;
|
||||
// 以24×24为标准坐标;absoluteStrokeWidth在size为数值时按尺寸反向补偿描边宽度。
|
||||
// 成功时保留调用方onClick;失败时点击改为停止冒泡并重试,避免同时触发外层节点点击。
|
||||
// 加载中用虚线方框,失败用叉号与title,data-icon-state供样式或检查观察显示状态。
|
||||
return <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width={width} height={height} fill="none" stroke="currentColor" strokeWidth={absoluteStrokeWidth && typeof size === "number" ? Number(strokeWidth) * 24 / size : strokeWidth} strokeLinecap="round" strokeLinejoin="round"
|
||||
{...props} data-icon-id={name} data-icon-state={paths ? "loaded" : failed ? "error" : "loading"}
|
||||
onClick={failed ? (event) => { event.stopPropagation(); setRetry((value) => value + 1); } : props.onClick}>
|
||||
|
||||
@@ -1,9 +1,20 @@
|
||||
/**
|
||||
* 画布图片显示层:远端图片异步加载时,允许用Provider提供的本地预览覆盖,减轻上传或资源切换时的空白。
|
||||
* 组件不上传文件、不管理Blob URL寿命;这些职责留给提供sources与loaded回调的上层。
|
||||
*/
|
||||
"use client";
|
||||
import { createContext, useContext } from "react";
|
||||
/** 按元素id查找预览URL,并在底层图片加载成功时通知上层;默认空Map与空回调支持独立渲染。 */
|
||||
export const ImagePreviews = createContext<{ sources: Map<string,string>; loaded: (id: string) => void }>({ sources: new Map(), loaded: () => {} });
|
||||
/** src用于底层正式图片,title作为其替代文本;图片禁止原生拖拽,避免干扰画布手势。
|
||||
* 底图使用懒加载、异步解码并禁止发送Referer;onLoad只通知上层,不直接删除预览。 */
|
||||
export function CanvasImage({ id, src, title, className }: { id: string; src?: string; title: string; className: string }) {
|
||||
// 本地源按元素id而非远端URL索引,同一资源在不同节点中可拥有各自的显示交接状态。
|
||||
const previews = useContext(ImagePreviews), local = previews.sources.get(id);
|
||||
const remote = <img loading="lazy" decoding="async" className={className} src={src} alt={title} draggable={false} referrerPolicy="no-referrer" onLoad={() => previews.loaded(id)} />;
|
||||
// 没有预览或预览已等于正式源时,只绘制一张图片,避免冗余叠层。
|
||||
if (!local || local === src) return remote;
|
||||
// 正式图片继续在底层加载,本地预览绝对定位覆盖在上方;Provider清掉该id后自然移除覆盖。
|
||||
// 覆盖图是装饰性的、忽略指针事件,读屏与节点交互都交给底层正式图片和外层节点。
|
||||
return <div style={{position:"relative",width:"100%",height:"100%",minHeight:0,flex:1,borderRadius:"inherit"}}>{remote}<img className={className} src={local} alt="" aria-hidden draggable={false} style={{position:"absolute",inset:0,pointerEvents:"none"}} /></div>;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* 将Portal中的节点浮层注册到视图OverlayIndex,提供屏幕边界、焦点及输入法组合态。
|
||||
* 索引用于输入路由与浮层归属判断;DOM标记与事件监听在ref更换或卸载时清理,不写入文档。
|
||||
*/
|
||||
"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';
|
||||
/** 新版Provider提供视图索引与engine;null允许旧编辑器或无engine的展示组件直接复用Hook。 */
|
||||
export const CanvasOverlays=createContext<{index:OverlayIndex;engine:ProjectEngine}|null>(null);
|
||||
/** Optional for legacy/engine-less renderers; a portal keeps its explicit element target. */
|
||||
/** 返回DOM ref回调。注册目标包含id/incarnation,避免同ID元素重建后仍沿用旧浮层身份。
|
||||
* useId区分组件实例,placement区分用途;Portal位置不影响目标归属。 */
|
||||
export function useCanvasOverlay(targetId:string,placement:string){
|
||||
const scope=useContext(CanvasOverlays),instance=useId(),refresh=useRef<()=>void>(()=>{}),cleanup=useRef<()=>void>(()=>{});
|
||||
// 订阅视口变化触发重渲染,后续layout effect再读取浮层屏幕矩形。
|
||||
useViewport();
|
||||
const incarnation=scope?.engine.getSnapshot().document.elements.get(targetId)?.incarnation;
|
||||
// 每次换DOM或作用域先清理旧注册;缺少DOM/Provider/元素生命周期时保持空注册。
|
||||
const ref=useCallback((element:HTMLElement|null)=>{cleanup.current();refresh.current=()=>{};cleanup.current=()=>{};if(!element||!scope||incarnation===undefined)return;
|
||||
// 随机owner标记区分同一DOM上的新旧绑定,旧清理不能删除新绑定写入的数据属性。
|
||||
const marker=crypto.randomUUID();element.dataset.canvasOverlayView=scope.index.viewId;element.dataset.canvasOverlayOwner=marker;element.dataset.canvasOverlayTarget=targetId;element.dataset.canvasOverlayPlacement=placement;
|
||||
// getBoundingClientRect给出屏幕坐标,四周扩大8px作为输入命中边界;焦点读取当前activeElement。
|
||||
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};};
|
||||
// 注册返回lease,后续测量只更新这一条;composition事件同步更新组合态供快捷键路由参考。
|
||||
const lease=scope.index.register(instance,value()),measure=()=>lease.update(value()),start=()=>{composing=true;measure();},end=()=>{composing=false;measure();};refresh.current=measure;
|
||||
// focusout后等微任务再测量,让新焦点落定;ResizeObserver与首帧测量补充尺寸/挂载后的变化。
|
||||
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);
|
||||
// 清除本绑定拥有的DOM标记、首帧任务、观察器和所有监听,再释放索引租约;不删除目标元素。
|
||||
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]);
|
||||
// 每次布局提交刷新边界;卸载清理使用ref中的最新函数,兼容期间发生的DOM/目标替换。
|
||||
useLayoutEffect(()=>{refresh.current();});useLayoutEffect(()=>()=>cleanup.current(),[]);return ref;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* 内置画布类型到React组件的装配表。把bindings层的renderer描述绑定到实际节点/连线组件,生成默认React渲染注册表。
|
||||
* 这张表保存可复用的显示定义,不保存每个元素的数据、选择或编辑状态。
|
||||
*/
|
||||
"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';
|
||||
/** 新版分区复用FrameNode,但开启directAnchors,使它使用该模式的直接连接点。
|
||||
* 其它NodeProps透传,包装层不再创建一份分区模型。 */
|
||||
function HeadlessFrameNode(props:NodeProps<CanvasNode>){return <FrameNode {...props} directAnchors />;}
|
||||
/** rendererId到节点组件的固定映射;这里的键与builtinRendererDefinitions中节点的rendererId配套。 */
|
||||
const nodes={material:MaterialNode,plainText:PlainTextNode,plainImage:PlainImageNode,frame:HeadlessFrameNode,icon:IconNode};
|
||||
/** 每项保留原始类型/结构描述,node按rendererId查表,connection绑定CanvasEdge。
|
||||
* render与preview目前共用同一个组件;createReactRenderers负责生成供ReactFlow使用的注册结果。
|
||||
* 本表没有自定义类型的通配组件,兼容占位由投影/渲染选择链路处理。 */
|
||||
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,33 +1,47 @@
|
||||
/**
|
||||
* 卡片与纯文本共用的正文编辑交互。展示态用覆盖层接收选择/双击,编辑态让textarea接收原生文本操作。
|
||||
* 正文直接来自data.text,每次输入交给actions.edit;本组件只保存是否正在编辑,不另存正文副本。
|
||||
*/
|
||||
"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. */
|
||||
/** plain选择纯文本样式、标签和占位文案;非plain按kind区分脚本/笔记占位。
|
||||
* 对外动作经过渲染目标身份检查,具体文本事务与history分组由Provider实现。 */
|
||||
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);
|
||||
// 按下位置和移动标记只用于区分拖动与双击;移动超过4px后不因双击事件进入编辑。
|
||||
const press = useRef({ x: 0, y: 0, moved: false });
|
||||
// 失去选择退出本地编辑态;此effect不直接调用endEditing,正常输入框blur另走结束回调。
|
||||
useEffect(() => { if (!selected) setEditing(false); }, [selected]);
|
||||
// 进入编辑后聚焦到文本末尾,并禁止浏览器自动滚动,以免画布视口跳动。
|
||||
useEffect(() => {
|
||||
if (!editing) return;
|
||||
const element = textarea.current;
|
||||
element?.focus({ preventScroll: true });
|
||||
element?.setSelectionRange(element.value.length, element.value.length);
|
||||
}, [editing]);
|
||||
/** 通知控制器开始文本编辑会话,再开启本地输入态;真正可写还要求节点保持选中。 */
|
||||
function begin() { actions.begin(id); setEditing(true); }
|
||||
// 同时满足editing与selected才允许输入;展示时textarea只读且不进入Tab顺序。
|
||||
const active = editing && selected;
|
||||
const label = plain ? "纯文本" : data.title;
|
||||
// 输入最多100000字符;onChange立即发动作,blur退出并通知endEditing。Escape只结束编辑,不回滚已输入文本。
|
||||
// 非编辑态覆盖层不接管单击逻辑,让外层画布选择/拖动;双击使用浏览器原生时序并按移动标记过滤。
|
||||
// 键盘Enter/F2仅在已选中时进入;双击未选中节点会先发select,再开始编辑。
|
||||
return <div className={`${styles.textEditor} ${active ? "nodrag" : ""}`}>
|
||||
<textarea ref={textarea} style={textAppearance(data)} className={`${plain ? styles.plainText : styles.cardText} nodrag nowheel`}
|
||||
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,6 +1,11 @@
|
||||
/**
|
||||
* 通用内容节点的四向连接点。只提供稳定方向ID和几何位置,实际连线命令由画布层接收。
|
||||
*/
|
||||
"use client";
|
||||
import { Handle, Position } from "@xyflow/react";
|
||||
import styles from "./canvas.module.css";
|
||||
/** 四边均使用source Handle以配合画布连接模式;selected控制能否开始连线以及读屏可见性。
|
||||
* 不在这里创建端点记录,也不判断跨分区连线的归属。 */
|
||||
export function ContentHandles({ selected }: { selected: boolean }) {
|
||||
return <>{([["top", Position.Top], ["right", Position.Right], ["bottom", Position.Bottom], ["left", Position.Left]] as const).map(([id, position]) => <Handle key={id} id={id} type="source" position={position} className={styles.centerHandle} isConnectableStart={selected} aria-hidden={!selected} aria-label={`从 ${id} 连接元素`} />)}</>;
|
||||
}
|
||||
|
||||
@@ -1,10 +1,17 @@
|
||||
/**
|
||||
* 旧版画布未同步草稿的选择对话框。通过原生dialog提供模态展示,把恢复、导出或使用服务端版本交给调用方。
|
||||
* 本组件不读取草稿、不写服务端,也不判断本地内容是否能安全覆盖;canRestore由上层计算。
|
||||
*/
|
||||
"use client";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import styles from "./canvas.module.css";
|
||||
/** 可恢复时显示恢复按钮,否则提示子图关系变化;导出按钮仅在提供exportDraft回调时出现。 */
|
||||
export function DraftRecovery({ restore, discard, canRestore = true, exportDraft }: { restore: () => void; discard: () => void; canRestore?: boolean; exportDraft?: () => void }) {
|
||||
const dialog = useRef<HTMLDialogElement>(null);
|
||||
// 挂载时showModal启用浏览器模态行为,卸载时close;不额外保存一份open状态。
|
||||
useEffect(() => { const element = dialog.current; element?.showModal(); return () => element?.close(); }, []);
|
||||
// 无document不创建Portal;取消事件阻止默认Escape关闭,要求用户通过明确的版本选择按钮处理。
|
||||
if (typeof document === "undefined") return null;
|
||||
return createPortal(<dialog ref={dialog} className={styles.recoveryDialog} aria-labelledby="draft-recovery-title" onCancel={(event) => event.preventDefault()}>
|
||||
<h2 id="draft-recovery-title">发现未同步的本地草稿</h2><p>{canRestore ? "服务端画布已读取。恢复草稿会以本地内容替换当前服务端版本,请选择要继续编辑的版本。" : "画布的子图关系已更新,这份旧草稿不能直接覆盖。可以先导出草稿,再使用服务端版本继续编辑。"}</p>
|
||||
|
||||
@@ -1,4 +1,13 @@
|
||||
/**
|
||||
* 连线编辑的React动作上下文。让连线组件调用begin/patch/finish/cancel,并读取当前活动集合。
|
||||
* 真正的命令、事务和history由Provider注入,默认空实现仅支持未接入编辑器的渲染环境。
|
||||
*/
|
||||
"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>() });
|
||||
/** begin可拒绝、使用旧版无返回值模式,或返回绑定手势的EditingLease;patch的transient区分临时预览。
|
||||
* finish/cancel可选,active为当前活动连线ID集合;默认Set不代表全项目状态仓库。 */
|
||||
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>() };
|
||||
/** Context的类型保留可选结束钩子,兼容旧画布与新版租约式交互,不在此创建编辑任务队列。 */
|
||||
export const EdgeEditing = createContext<Omit<typeof defaultEditing, 'finish' | 'cancel'> & Partial<Pick<typeof defaultEditing, 'finish' | 'cancel'>>>(defaultEditing);
|
||||
|
||||
@@ -1,15 +1,27 @@
|
||||
/**
|
||||
* 连线的受控样式与曲线工具栏。接收当前data及回调,编辑外观、动画、传播开关和曲线点,不直接持有文档状态。
|
||||
*/
|
||||
"use client";
|
||||
import { EdgeToolbar } from "@xyflow/react";
|
||||
import { Plus, Trash2 } from "lucide-react";
|
||||
import type { EdgeData, EdgeMarker, MarkerShape } from "@/lib/canvas-v2";
|
||||
import styles from "./canvas.module.css";
|
||||
|
||||
/** x/y是工具栏锚点;count与knotMode来自外层曲线编辑器。add/remove/changeKnotMode委托外层选择具体曲线点。
|
||||
* EdgeToolbar只负责屏幕定位,外层仅在选中连线时挂载本组件。 */
|
||||
export function EdgeToolbarControls({ id, x, y, data, change, add, remove, count, knotMode, changeKnotMode }: { id: string; x: number; y: number; data: EdgeData; change: (data: EdgeData) => void; add: () => void; remove: () => void; count: number; knotMode?: string; changeKnotMode: (mode: "corner" | "smooth" | "symmetric") => void }) {
|
||||
const a = data.appearance || {}, animation = data.animation || {};
|
||||
/** 合并appearance子对象并保留其它连线数据;箭头/箭尾各自更新时仍需在事件内保留其嵌套字段。 */
|
||||
const appearance = (patch: NonNullable<EdgeData["appearance"]>) => change({ ...data, appearance: { ...a, ...patch } });
|
||||
/** 动画属性单独合并,调整一个设置不覆盖已有方向、效果等其它字段。 */
|
||||
const animate = (patch: NonNullable<EdgeData["animation"]>) => change({ ...data, animation: { ...animation, ...patch } });
|
||||
/** 箭头与箭尾共用的六种形状;实际SVG绘制由CanvasEdge中的Marker实现。 */
|
||||
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`}
|
||||
// 宽度校验0.5—20,端部尺寸4—32,动画时长250—30000ms;透明度范围由range控件给出。
|
||||
// head/tail分别合并type/size/filled;传播开关将execution替换为当前enabled对象。
|
||||
// 无曲线点时禁用模式和删除,达到64点时禁用添加;外层仍负责相应操作的实际合法性。
|
||||
// 所有指针/键盘冒泡止于工具栏,避免颜色或输入操作同时触发画布编辑。
|
||||
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,18 @@
|
||||
/**
|
||||
* 编辑手势的能力对象:一次交互取得preview/commit/cancel,再由有效性护栏限制其后续调用。
|
||||
* 这里只校验调用当刻是否仍有效,不维护手势状态机,也不自行保证commit只能调用一次。
|
||||
*/
|
||||
/** A captured pointer may only finish the exact interaction that acquired it. */
|
||||
/** preview产生临时显示,commit结束并提交,cancel撤销本次预览;具体语义由创建租约的编辑器实现。 */
|
||||
export type EditingLease<T> = { preview(value: T): void; commit(): void; cancel(): void };
|
||||
|
||||
/** 冻结包装后的方法表,每次调用重新检查isCurrent;过期事件静默忽略,不能结束另一场交互。 */
|
||||
export function guardEditingLease<T>(isCurrent: () => boolean, actions: EditingLease<T>): EditingLease<T> {
|
||||
return Object.freeze({
|
||||
// 指针移动可能持续产生事件,必须逐次检查,不能只在创建租约时检查一次。
|
||||
preview(value: T) { if (isCurrent()) actions.preview(value); },
|
||||
// 提交与取消共用同一有效性判断;原actions负责释放手势与处理重复结束。
|
||||
commit() { if (isCurrent()) actions.commit(); },
|
||||
cancel() { if (isCurrent()) actions.cancel(); },
|
||||
});
|
||||
}
|
||||
@@ -1,3 +1,6 @@
|
||||
/**
|
||||
* 分区成员拖放的展示上下文。提供名称、成员计数与当前候选归属变化,供节点显示提示;不负责执行成员迁移。
|
||||
*/
|
||||
"use client";
|
||||
|
||||
import { createContext, useContext } from "react";
|
||||
@@ -5,14 +8,18 @@ import { Frame } from "lucide-react";
|
||||
import type { frameDropFeedback } from "@/lib/canvas-frames";
|
||||
import styles from "./canvas.module.css";
|
||||
|
||||
/** 缺少Provider时使用空展示数据;drops是frameDropFeedback产生的当前预览,不能代替持久归属。 */
|
||||
export const FrameFeedback = createContext({ names: new Map<string, string>(), counts: new Map<string, number>(), drops: [] as ReturnType<typeof frameDropFeedback> });
|
||||
|
||||
/** 优先展示拖动候选to,否则使用既有frameId;只有被选中或有拖放变化时才考虑显示徽标。 */
|
||||
export function MembershipBadge({ id, frameId, selected }: { id: string; frameId?: string; selected: boolean }) {
|
||||
const { names, drops } = useContext(FrameFeedback);
|
||||
const drop = drops.find((item) => item.id === id);
|
||||
// drop.to可能为空,表示准备移出;不能用“drop.to || frameId”退回原分区,否则会隐藏移出意图。
|
||||
const target = drop ? drop.to : frameId;
|
||||
const name = target ? names.get(target) : undefined;
|
||||
if (!drop && !selected) return null;
|
||||
// 目标有名称时区分“松开加入”与“属于”;没有目标且原来有归属则提示移出,否则不绘制。
|
||||
const label = name ? `${drop && drop.to !== drop.from ? "松开加入" : "属于"}:${name}` : drop?.from ? `松开移出:${names.get(drop.from) || "分区"}` : null;
|
||||
return label && <div className={styles.membershipBadge} title={label}><Frame size={11} />{label}</div>;
|
||||
}
|
||||
|
||||
@@ -1,16 +1,25 @@
|
||||
/**
|
||||
* 分区名称显示模式的受控单选控件。显示当前值并通过change请求修改,组件本身不拥有分区模型。
|
||||
*/
|
||||
"use client";
|
||||
import { useRef } from "react";
|
||||
import { EyeOff, Scaling, Type } from "lucide-react";
|
||||
import type { FrameLabelMode } from "@/lib/canvas-elements";
|
||||
import styles from "./canvas.module.css";
|
||||
/** 固定三种模式,数组顺序同时决定视觉滑块位置与键盘导航顺序。 */
|
||||
const options = [
|
||||
{ value: "hidden", label: "隐藏名称", hint: "在画布上隐藏分区名称", Icon: EyeOff },
|
||||
{ value: "fixed", label: "固定字号", hint: "缩放画布时保持名称大小", Icon: Type },
|
||||
{ value: "scaled", label: "随画布缩放", hint: "名称随画布一起放大缩小", Icon: Scaling },
|
||||
] as const;
|
||||
/** 鼠标点击切换模式;方向键循环移动,Home/End跳到首尾,并把焦点移至目标按钮。
|
||||
* radio角色、aria-checked和单一Tab入口表达单选语义;SVG仅提供视觉提示。 */
|
||||
export function FrameLabelMenu({ value, change }: { value: FrameLabelMode; change: (mode: FrameLabelMode) => void }) {
|
||||
// 以数组保存三个真实按钮引用,键盘选择后直接聚焦;滑块索引在未知value时回退0。
|
||||
const items = useRef<(HTMLButtonElement | null)[]>([]);
|
||||
const current = Math.max(0, options.findIndex((option) => option.value === value));
|
||||
// 左/上为-1,右/下为+1,通过加3再取模实现循环;Home/End直接使用0/2。
|
||||
// 阻止默认滚动并停止冒泡,避免选择模式的按键同时移动画布;滑块每项28px。
|
||||
return <div className={styles.frameLabelSegments} role="radiogroup" aria-label="分区名称显示模式" onKeyDown={(event) => {
|
||||
const delta = { ArrowRight: 1, ArrowDown: 1, ArrowLeft: -1, ArrowUp: -1 }[event.key];
|
||||
if (delta !== undefined || event.key === "Home" || event.key === "End") {
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
/**
|
||||
* 分区节点:展示名称、边框、成员拖入/移出反馈,以及子画布折叠与概览设置。
|
||||
* 局部状态只存名称输入草稿;保存与分区属性修改都交给作用域编辑接口,成员归属不由本组件直接变更。
|
||||
*/
|
||||
"use client";
|
||||
|
||||
import { useContext, useEffect, useRef, useState } from "react";
|
||||
@@ -5,37 +9,54 @@ 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);
|
||||
/** directAnchors用于新版分区四向连接点;子画布ports则按输入/输出方向单独布局。
|
||||
* 分区使用自己的设置浮层,因此隐藏TransformableNode的通用样式栏,仍保留公共变换行为。 */
|
||||
export function FrameNode(props: NodeProps<CanvasNode> & { directAnchors?: boolean }) {
|
||||
const { directAnchors = false, ...nodeProps } = props;
|
||||
const { id, data, selected } = nodeProps;
|
||||
// 设置浮层注册到当前视图的overlay索引,让Portal中的控件也能正确参与焦点与输入路由。
|
||||
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;
|
||||
const { zoom } = useViewport();
|
||||
const reduce = useReducedMotion();
|
||||
// 名称是受控输入草稿;外部data.title变化时同步覆盖,实际提交发生在blur。
|
||||
const [title, setTitle] = useState(data.title);
|
||||
useEffect(() => setTitle(data.title), [data.title]);
|
||||
// Escape恢复模型标题并标记跳过随后的blur保存;Enter排除输入法组合态后通过blur统一提交。
|
||||
const cancelBlur = useRef(false);
|
||||
const mode = data.labelMode || "fixed";
|
||||
/** 去除首尾空白,空名称回退“未命名分区”;只有与模型不同才派发修改,再归一化输入显示。 */
|
||||
function saveTitle() {
|
||||
const name = title.trim() || "未命名分区";
|
||||
if (name !== data.title) editing.frame(id, { title: name });
|
||||
setTitle(name);
|
||||
}
|
||||
// 选中时显示设置栏,动画尊重减少动态效果偏好;栏内事件停止冒泡,避免触发画布快捷键或拖动。
|
||||
// 折叠切换前按viewScale还原当前尺寸,存入原状态对应的expandedSize/collapsedSize供后续恢复。
|
||||
// 概览比例输入限定5%—400%,只更新presentation.contentScale;链接单独打开子画布。
|
||||
// fixed名称与拖放提示用1/(zoom*viewScale)抵消缩放,scaled跟随画布,hidden不绘制名称。
|
||||
// 四侧边框提供拖动命中区域;ports按同方向均匀分布,输出在右侧可起连线,输入在左侧可收连线。
|
||||
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,14 +66,16 @@ 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) => {
|
||||
// 只在同方向端口之间均分高度,index+1与length+1让首尾都留出边距。
|
||||
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,13 @@
|
||||
/**
|
||||
* 旧版主画布的界面装配与交互控制。useCanvasTree拥有旧快照、writer、历史和子图事务,组件把其投影交给ReactFlow。
|
||||
* 本地state管理工具、模式、菜单与提示,refs保存拖动/连线起点及文件选择上下文;真实上传与演示Data URL分别处理。
|
||||
* 节点/连线组件通过Context调用适配动作,不直接修改投影;子图归属、端口连线和解散优先使用服务端能力与事务。
|
||||
* 迁移门闩暂停旧编辑和创作入口,给迁移协调组件提供检查、保存排空与备份;本文件不自行切换到新版engine。
|
||||
*/
|
||||
"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";
|
||||
@@ -39,15 +47,21 @@ import type { NodeTransform } from "@/lib/canvas-geometry";
|
||||
import { MaterialNode, NodeEditing } from "./material-node";
|
||||
import styles from "./canvas.module.css";
|
||||
|
||||
/** 旧边统一使用可编辑贝塞尔组件,节点按旧type名称分派;类型映射本身不持有元素实例数据。 */
|
||||
const edgeTypes = { default: CanvasEdge };
|
||||
/** 图标面板延迟在客户端加载,避免首屏加载目录面板及其交互代码;加载中提供可访问状态。 */
|
||||
const IconPicker = dynamic(() => import("./icon-picker").then((module) => module.IconPicker), { ssr: false, loading: () => <div role="status" className={styles.iconLoading}>正在加载图标面板…</div> });
|
||||
/** ReactFlow type到节点展示组件的固定表,须与makeCanvasNode/投影生成的type保持一致。 */
|
||||
const nodeTypes = { material: MaterialNode, plainText: PlainTextNode, icon: IconNode, frame: FrameNode, plainImage: PlainImageNode };
|
||||
|
||||
function CanvasEditor({ board, snapshot }: { board: Board; snapshot?: CanvasSnapshot }) {
|
||||
/** 每个挂载对应一个board身份,初始快照/草稿只在useState初始化时采用;切画布应由上层key重挂载。 */
|
||||
function CanvasEditor({ board, snapshot, migration }: { board: Board; snapshot?: CanvasSnapshot; migration?:LegacyMigrationContext }) {
|
||||
const flow = useReactFlow<CanvasNode>();
|
||||
const { zoom } = useViewport();
|
||||
const reduceMotion = useReducedMotion();
|
||||
const storageKey = canvasStorageKey(board.workspace_id, board.id);
|
||||
// 服务端快照优先,另有不同本地内容则等待恢复选择;无快照可直接用草稿。
|
||||
// 视口独立读本地键并限制范围,损坏视口不阻断文档;草稿解码失败回退快照/空文档并提示。
|
||||
const [initial] = useState(() => {
|
||||
try {
|
||||
const local = readLocalRecovery(localStorage, storageKey, snapshot);
|
||||
@@ -60,6 +74,7 @@ function CanvasEditor({ board, snapshot }: { board: Board; snapshot?: CanvasSnap
|
||||
} catch { return { document: snapshot?.document || emptyCanvas(), recovery: null, error: "无法读取此浏览器的图谱草稿,可通过导入恢复备份。" }; }
|
||||
});
|
||||
|
||||
// 工具与变换模式是界面状态,不放入文档;draggedIds仅驱动成员预览,membershipNotice短暂展示结果。
|
||||
const [tool, setTool] = useState<"select" | "hand">("select");
|
||||
const [error, setError] = useState(initial.error);
|
||||
const feedback = useContext(FeedbackContext);
|
||||
@@ -70,25 +85,35 @@ function CanvasEditor({ board, snapshot }: { board: Board; snapshot?: CanvasSnap
|
||||
const [rotating, setRotating] = useState<string | null>(null);
|
||||
const [draggedIds, setDraggedIds] = useState<string[]>([]);
|
||||
const [membershipNotice, setMembershipNotice] = useState("");
|
||||
// 防节点拖动与多选拖动回调重复进入;起点按显示ID保存,连线屏幕起点用于区分点击与拉出新分支。
|
||||
const dragSession = useRef<"idle" | "active" | "finishing">("idle");
|
||||
const dragOrigins = useRef(new Map<string, XYPosition>());
|
||||
const draggingFrame = useRef(false);
|
||||
const connectionStart = useRef<{ id: string; direction: NodeDirection; point: XYPosition } | null>(null);
|
||||
// 图片分支先保留源节点/方向/位置,等文件选择返回;cancel事件要清它,不能沿用上次创建意图。
|
||||
const pendingImage = useRef<BranchRequest | null>(null);
|
||||
/** 稳定关闭回调供Portal菜单与画布事件共用,避免菜单因每次渲染重新安装监听。 */
|
||||
const closeBranch = useCallback(() => setBranchRequest(null), []);
|
||||
const imageKind = useRef<"image" | "plain-image">("image");
|
||||
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);
|
||||
// 根恢复待决时暂停保存;tree负责真实owner文档,UI显示节点来自其树投影。子图恢复按Map首项逐个处理。
|
||||
const tree = useCanvasTree(board.id, board.workspace_id, initial.document, snapshot, Boolean(recovery), board.parent_board_id, migration);
|
||||
const childRecovery = [...tree.recoveries][0];
|
||||
/** 下载单份内存文档JSON备份,稍后释放Blob URL;不修改草稿选择,也不保证包含所有子图。 */
|
||||
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;
|
||||
// snapshot存在才执行真实AI;state.present组合根元数据与整树显示节点/边,不能把它当单board持久快照。
|
||||
const ai = useCanvasAI(board.workspace_id, board.id, !!snapshot, tree);
|
||||
const state = { present: { ...tree.rootDocument, nodes: tree.views.nodes, edges: tree.views.edges } };
|
||||
const treeRef = useRef(tree); treeRef.current = tree;
|
||||
// 上传完成回调通过treeRef定位当前归属,并更新当前文档与历史;job.owner可能已不再是最新owner。
|
||||
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);
|
||||
// 上传任务与文档都可能变化:按pendingUpload重新找owner,补资源或纯图片默认尺寸,避免移动/撤销后遗漏占位。
|
||||
useEffect(() => {
|
||||
const current = treeRef.current;
|
||||
for (const job of uploads.jobs) {
|
||||
@@ -99,38 +124,48 @@ function CanvasEditor({ board, snapshot }: { board: Board; snapshot?: CanvasSnap
|
||||
if (node.data.kind==="plain-image"&&node.width===320&&node.height===240&&(job.node.width!==320||job.node.height!==240)) current.resolveImageUpload(job.node.id,undefined,{width:job.node.width!,height:job.node.height!});
|
||||
}
|
||||
}, [uploads.jobs,tree.documents]);
|
||||
/** undo/redo走异步树历史接口并显示失败,其余动作走根document reducer;不把服务端事务撤销当普通reducer动作。 */
|
||||
function dispatch(action: CanvasAction) {
|
||||
if (action.type === "undo" || action.type === "redo") { void treeRef.current[action.type]().catch((error) => setError(error.message)); return; }
|
||||
treeRef.current.dispatch(action);
|
||||
}
|
||||
// 浏览器文件选择取消不触发普通onChange,单独监听cancel清理待建图片分支;卸载拆监听。
|
||||
useEffect(() => {
|
||||
const input = imageInput.current;
|
||||
/** 取消本次选择意图,不删除已经存在的图片节点或中止已启动上传。 */
|
||||
const cancel = () => { pendingImage.current = null; };
|
||||
input?.addEventListener("cancel", cancel);
|
||||
return () => input?.removeEventListener("cancel", cancel);
|
||||
}, []);
|
||||
const { nodes: displayNodes, edges } = state.present;
|
||||
// 对成员任务保护节点关闭拖动/删除/连接;tree.update内部仍保护相应数据,界面禁用不是唯一护栏。
|
||||
const nodes = displayNodes.map((node) => { const ref = tree.views.nodeRefs.get(node.id); const blocked = ref && tree.protectedNodes(ref.boardId).has(ref.id); return blocked ? { ...node, draggable: false, deletable: false, connectable: false } : node; });
|
||||
/** 上传任务的本地nodeID映射到当前场景ID;尚无投影时按根/原owner构造兼容显示ID。 */
|
||||
const uploadDisplayID = (job: typeof uploads.jobs[number]) => [...tree.views.nodeRefs].find(([,ref])=>ref.id===job.node.id)?.[0] || displayID(board.id,job.owner,job.node.id);
|
||||
// 预览URL按显示ID提供;正式图片onLoad再映射回job,上传完成的任务才回收本地URL。
|
||||
const imagePreviews = { sources: new Map(uploads.jobs.map((job) => [uploadDisplayID(job),job.localURL])), loaded: (id: string) => { const job = uploads.jobs.find((job) => uploadDisplayID(job)===id);if(job) uploads.loaded(job.node.id); } };
|
||||
|
||||
// 从场景节点派生分区名称、已有frameId计数与本次拖入/拖出候选,只作显示,不在此改变owner。
|
||||
const frameFeedback = useMemo(() => {
|
||||
const names = new Map(nodes.filter((node) => node.data.kind === "frame").map((node) => [node.id, node.data.title]));
|
||||
const counts = new Map<string, number>();
|
||||
for (const node of nodes) if (node.data.frameId) counts.set(node.data.frameId, (counts.get(node.data.frameId) || 0) + 1);
|
||||
return { names, counts, drops: frameDropFeedback(nodes, draggedIds) };
|
||||
}, [nodes, draggedIds]);
|
||||
// 成员提示四秒后清理;重新提示重置计时器,卸载取消。
|
||||
useEffect(() => {
|
||||
if (!membershipNotice) return;
|
||||
const timer = setTimeout(() => setMembershipNotice(""), 4000);
|
||||
return () => clearTimeout(timer);
|
||||
}, [membershipNotice]);
|
||||
// 全局反馈可用时把本地错误转成稳定ID通知并清本地error;无Provider时保留底部内联提示。
|
||||
useEffect(() => {
|
||||
if (!feedback || !error) return;
|
||||
const id = `canvas:${board.id}:error`;
|
||||
notify({ id, level: "error", title: error });
|
||||
setError("");
|
||||
}, [feedback, error, board.id]);
|
||||
// 成员通知使用board级稳定ID,内容变化替换旧通知,清理时显式dismiss。
|
||||
useEffect(() => {
|
||||
if (!feedback || !membershipNotice) return;
|
||||
const id = `canvas:${board.id}:membership`;
|
||||
@@ -138,29 +173,40 @@ function CanvasEditor({ board, snapshot }: { board: Board; snapshot?: CanvasSnap
|
||||
return () => dismissNotification(id);
|
||||
}, [feedback, membershipNotice, board.id]);
|
||||
const duration = reduceMotion ? 0 : 180;
|
||||
/** 视口派发给根并独立写本地键;存储失败不打断浏览,内容writer自行判断是否需要服务端保存。 */
|
||||
const rememberViewport = (viewport: import("@xyflow/react").Viewport) => {
|
||||
treeRef.current.dispatch({ type: "viewport", viewport });
|
||||
try { localStorage.setItem(storageKey + ":viewport", JSON.stringify(viewport)); } catch { /* View changes do not require server writes. */ }
|
||||
};
|
||||
/** 工具栏视口动画完成后保存实际视口;错误转界面提示,不提前记录尚未完成的目标值。 */
|
||||
const changeView = (operation: () => Promise<boolean>) => {
|
||||
void operation().then(() => rememberViewport(flow.getViewport())).catch((error) => setError(error.message));
|
||||
};
|
||||
|
||||
// latest保留当前显示几何,拖动结束基于最新节点计算成员反馈,避免只使用开始时的闭包。
|
||||
const latest = useRef(state.present);
|
||||
latest.current = state.present;
|
||||
|
||||
/** 显示ID的选择交tree解析owner;稳定回调通过treeRef避免每次文档更新重建编辑上下文。 */
|
||||
const select = useCallback((id: string) => treeRef.current.select(id), []);
|
||||
/** 对目标owner建立checkpoint,把后续连续编辑归到同一撤销起点;缺目标时使用根board。 */
|
||||
const beginEdit = useCallback((id?: string) => { const current = treeRef.current; const ref = id ? current.views.nodeRefs.get(id) || current.views.edgeRefs.get(id) : undefined; current.dispatch({ type: "checkpoint" }, ref?.boardId || board.id); }, [board.id]);
|
||||
/** 正文动作按投影ID定位原节点,reducer处理文本值与历史。 */
|
||||
const edit = useCallback((id: string, text: string) => treeRef.current.nodeAction(id, (id) => ({ type: "edit", id, text })), []);
|
||||
/** 打开分支类型菜单并退出变换模式,point用于屏幕定位。 */
|
||||
const branch = useCallback((id: string, direction: NodeDirection, point: XYPosition) => { setBranchRequest({ id, direction, point }); setRotating(null); setResizing(null); }, []);
|
||||
/** 将场景位置减owner原点再除scale,宽高也除scale;旋转等其余字段直接交旧transform动作。 */
|
||||
const transform = useCallback((id: string, transform: NodeTransform) => {
|
||||
const ref = treeRef.current.views.nodeRefs.get(id); if (!ref) return;
|
||||
treeRef.current.nodeAction(id, (id) => ({ type: "transform", id, transform: { ...transform,
|
||||
...(transform.position ? { position: { x: (transform.position.x - ref.origin.x) / ref.scale, y: (transform.position.y - ref.origin.y) / ref.scale } } : {}),
|
||||
...(transform.width !== undefined ? { width: transform.width / ref.scale, height: transform.height! / ref.scale } : {}) } }));
|
||||
}, []);
|
||||
/** 样式适配只构造旧style动作,具体字段合并由reducer处理。 */
|
||||
const style = useCallback((id: string, style: TextStyle, fontSize?: number) => treeRef.current.nodeAction(id, (id) => ({ type: "style", id, style, fontSize })), []);
|
||||
/** 节点名称通过owner映射更新,避免将显示复合ID当持久ID。 */
|
||||
const rename = useCallback((id: string, title: string) => treeRef.current.nodeAction(id, (id) => ({ type: "rename-node", id, title })), []);
|
||||
/** 分区属性以replace提交;折叠切换从已保存expanded/collapsedSize或默认值恢复尺寸,并写node与style。 */
|
||||
const frame = useCallback((id: string, patch: { title?: string; labelMode?: FrameLabelMode; presentation?: import("@/lib/canvas-v2").FramePresentation }) => {
|
||||
const current = treeRef.current, ref = current.views.nodeRefs.get(id); if (!ref) return;
|
||||
const document = current.documents.get(ref.boardId)!;
|
||||
@@ -171,7 +217,9 @@ function CanvasEditor({ board, snapshot }: { board: Board; snapshot?: CanvasSnap
|
||||
return { ...node, ...(size ? { ...size, style: { ...node.style, ...size } } : {}), data: { ...node.data, ...patch } };
|
||||
}) } }, ref.boardId);
|
||||
}, []);
|
||||
// 注入节点动作及本地旋转/拉伸模式,两模式互斥;旧版接口没有新版手势token租约。
|
||||
const editing = useMemo(() => ({ begin: beginEdit, select, edit, branch, transform, style, rename, frame, workspace: board.workspace_id, schema: 2 as const, rotating, resizing, resize: (id: string | null) => { setResizing(id); setRotating(null); }, rotate: (id: string | null) => { setRotating(id); setResizing(null); } }), [beginEdit, select, edit, branch, transform, style, rename, frame, rotating, resizing, board.workspace_id, state.present.schema_version]);
|
||||
/** 在源owner文档创建节点与连线,at从场景坐标转局部;发布后尝试把视口移向新节点,异常留作提示。 */
|
||||
function createBranch(request: BranchRequest, kind: "text" | "image" | "script", src?: string) {
|
||||
try {
|
||||
const document = branchCanvasNode(tree.documents.get(tree.views.nodeRefs.get(request.id)!.boardId)!, tree.views.nodeRefs.get(request.id)!.id, request.direction, kind, src, request.at ? { x: (request.at.x - tree.views.nodeRefs.get(request.id)!.origin.x) / tree.views.nodeRefs.get(request.id)!.scale, y: (request.at.y - tree.views.nodeRefs.get(request.id)!.origin.y) / tree.views.nodeRefs.get(request.id)!.scale } : undefined);
|
||||
@@ -181,11 +229,13 @@ function CanvasEditor({ board, snapshot }: { board: Board; snapshot?: CanvasSnap
|
||||
setBranchRequest(null);
|
||||
} catch (error) { setError(error instanceof Error ? error.message : "无法创建节点。"); }
|
||||
}
|
||||
/** 图片分支需要文件选择才能创建,先保存request并关菜单;文本/脚本立即创建。 */
|
||||
function chooseBranch(kind: "text" | "image" | "script") {
|
||||
if (!branchRequest) return;
|
||||
if (kind === "image") { pendingImage.current = branchRequest; imageKind.current = "image"; setBranchRequest(null); imageInput.current?.click(); }
|
||||
else createBranch(branchRequest, kind);
|
||||
}
|
||||
/** 无有效目标的连接拖动若移动至少5px并落到空白pane,且源仍选中,则在落点打开分支菜单。 */
|
||||
const connectionEnd: OnConnectEnd = (event, connection) => {
|
||||
const start = connectionStart.current;
|
||||
connectionStart.current = null;
|
||||
@@ -198,11 +248,13 @@ function CanvasEditor({ board, snapshot }: { board: Board; snapshot?: CanvasSnap
|
||||
setBranchRequest({ id: start.id, direction: start.direction, point: { x: point.clientX, y: point.clientY }, at });
|
||||
};
|
||||
|
||||
/** 按窗口中心转换为场景点并加六档偏移,减少连续添加完全重叠;不是按编辑器容器中心计算。 */
|
||||
const center = () => {
|
||||
const point = flow.screenToFlowPosition({ x: window.innerWidth / 2, y: window.innerHeight / 2 });
|
||||
const offset = (nodes.length % 6) * 18;
|
||||
return { x: point.x - 130 + offset, y: point.y - 80 + offset };
|
||||
};
|
||||
/** 根节点上限500;真实分区走subgraphs命令创建子画布,其它节点本地加入,演示frame也走本地模型。 */
|
||||
function addNode(kind: CanvasNode["data"]["kind"], src?: string, size?: { width: number; height: number }, title?: string) {
|
||||
if (tree.rootDocument.nodes.length >= 500) { setError("当前图谱已达到 500 个节点,请新建图谱继续创作。"); return; }
|
||||
const node = makeCanvasNode(kind, center(), src);
|
||||
@@ -215,6 +267,7 @@ function CanvasEditor({ board, snapshot }: { board: Board; snapshot?: CanvasSnap
|
||||
dispatch({ type: "nodes", changes: [{ type: "add", item: node }] });
|
||||
setTool("select");
|
||||
}
|
||||
/** 一次replace插入三张预填卡片和两条连线,预留三个节点容量并切回选择工具;不触发AI生成。 */
|
||||
function addTemplate() {
|
||||
if (tree.rootDocument.nodes.length > 497) { setError("当前图谱没有足够空间插入模板。"); return; }
|
||||
const position = center();
|
||||
@@ -230,6 +283,7 @@ function CanvasEditor({ board, snapshot }: { board: Board; snapshot?: CanvasSnap
|
||||
] } });
|
||||
setTool("select");
|
||||
}
|
||||
/** idle才建立checkpoint与拖动起点;有frame参与则不显示普通拖入预览,防止移动分区误收纳覆盖内容。 */
|
||||
function beginDrag(node: CanvasNode, dragged: CanvasNode[]) {
|
||||
if (dragSession.current !== "idle") return; dragSession.current = "active"; tree.setDragging(true);
|
||||
beginEdit(node.id); closeBranch(); setRotating(null); setResizing(null);
|
||||
@@ -239,6 +293,8 @@ function CanvasEditor({ board, snapshot }: { board: Board; snapshot?: CanvasSnap
|
||||
setDraggedIds(draggingFrame.current ? [] : [...dragOrigins.current.keys()]);
|
||||
setMembershipNotice("");
|
||||
}
|
||||
/** 只结束active拖动,先清拖动/保存暂停标记,再按真实位移计算归属;真实子图与旧frameId归属分路处理。
|
||||
* 异步移动失败保留错误提示,不宣称所有跨分区步骤一起回滚。 */
|
||||
async function finishDrag(node: CanvasNode, dragged: CanvasNode[]) {
|
||||
if (dragSession.current !== "active") return;
|
||||
const origins = new Map(dragOrigins.current);
|
||||
@@ -249,19 +305,23 @@ function CanvasEditor({ board, snapshot }: { board: Board; snapshot?: CanvasSnap
|
||||
if (ids.length) {
|
||||
const positions = new Map(items.map((item) => [item.id, item.position]));
|
||||
const dropped = latest.current.nodes.map((item) => positions.has(item.id) ? { ...item, position: positions.get(item.id)! } : item);
|
||||
// 先生成“加入/移出”展示文案;实际owner由下面的真实子图路径重新判定,成功后才显示结果通知。
|
||||
const changed = frameDropFeedback(dropped, ids).filter((item) => item.from !== item.to);
|
||||
const messages = changed.map((item) => item.to ? `已加入「${frameFeedback.names.get(item.to) || "分区"}」` : `已移出「${frameFeedback.names.get(item.from!) || "分区"}」`);
|
||||
const notice = messages.length ? `${changed.length > 1 ? `${changed.length} 项内容:` : ""}${[...new Set(messages)].join(";")}` : "";
|
||||
if (snapshot && tree.rootDocument.schema_version === 2) {
|
||||
try {
|
||||
// 按source/target分组,同组节点一起迁移;候选分区按面积升序,优先最小包围分区。
|
||||
const byOwner = new Map<string, { ids: string[]; target: string }>();
|
||||
const parents = subgraphParents(tree.current.current);
|
||||
for (const item of items.filter((item) => ids.includes(item.id))) {
|
||||
const ref = tree.views.nodeRefs.get(item.id); if (!ref) continue;
|
||||
const center = { x: item.position.x + (item.width || 260) / 2, y: item.position.y + (item.height || 200) / 2 };
|
||||
/** 沿子图父链防环检查,不能把分区入口移动进自己的后代,形成结构循环。 */
|
||||
const inMovingSubtree = (child: string) => { const seen = new Set<string>(); let cursor: string | undefined = child; while (cursor && !seen.has(cursor)) { if (cursor === item.data.subgraph?.boardId) return true; seen.add(cursor); cursor = parents.get(cursor)?.boardId; } return false; };
|
||||
const candidates = dropped.filter((candidate) => candidate.id !== item.id && candidate.data.subgraph && !inMovingSubtree(candidate.data.subgraph.boardId) && center.x >= candidate.position.x && center.y >= candidate.position.y && center.x <= candidate.position.x + (candidate.width || 640) && center.y <= candidate.position.y + (candidate.height || 400)).sort((a, b) => (a.width || 640) * (a.height || 400) - (b.width || 640) * (b.height || 400));
|
||||
const target = candidates[0]?.data.subgraph?.boardId || (ref.boardId === board.id ? board.id : parents.get(ref.boardId)?.boardId) || board.id;
|
||||
// 未上传且满足条件的占位图片先走本地迁移;其它节点需要服务端成员事务。
|
||||
if (target !== ref.boardId && tree.current.current.get(ref.boardId)?.nodes.find((node)=>node.id===ref.id)?.pendingUpload) {
|
||||
tree.movePendingImage(ref.boardId,target,ref.id);continue;
|
||||
}
|
||||
@@ -269,6 +329,7 @@ function CanvasEditor({ board, snapshot }: { board: Board; snapshot?: CanvasSnap
|
||||
const key = ref.boardId + "/" + target, group = byOwner.get(key) || { ids: [], target }; group.ids.push(ref.id); byOwner.set(key, group);
|
||||
}
|
||||
}
|
||||
// 不同owner组并行提出任务,每组沿父子路径逐跳等待;tree内部队列仍串行发送成员请求。
|
||||
await Promise.all([...byOwner].map(async ([key, group]) => {
|
||||
let source = key.split("/")[0];
|
||||
for (const hop of subgraphMovePath(tree.current.current, source, group.target)) {
|
||||
@@ -284,10 +345,12 @@ function CanvasEditor({ board, snapshot }: { board: Board; snapshot?: CanvasSnap
|
||||
}
|
||||
}
|
||||
}
|
||||
/** 先把入口端口解析成实际内部节点;同owner本地加边,跨owner优先服务端connections事务,缺能力时尝试暴露端口适配。 */
|
||||
async function connect(connection: Connection) {
|
||||
const source = tree.views.nodeRefs.get(connection.source), target = tree.views.nodeRefs.get(connection.target);
|
||||
if (!source || !target) return;
|
||||
try {
|
||||
/** 按深度计数0—8解析子图入口,每层必须找到明确port映射;仍未到实际节点或缺端口则拒绝。 */
|
||||
const resolve = async (ref: typeof source, handle: string | null | undefined): Promise<{ boardId: string; id: string; handle: string }> => {
|
||||
let endpoint = { boardId: ref!.boardId, id: ref!.id, handle: handle || "right" };
|
||||
for (let depth = 0; depth <= 8; depth++) {
|
||||
@@ -300,6 +363,7 @@ function CanvasEditor({ board, snapshot }: { board: Board; snapshot?: CanvasSnap
|
||||
throw new Error("subgraph_cycle");
|
||||
};
|
||||
const realSource = await resolve(source, connection.sourceHandle), realTarget = await resolve(target, connection.targetHandle || "left");
|
||||
// 同一canvas快照可直接加边;跨canvas需要共同上层/边界端口关系,不能把显示端点直接写进任一子快照。
|
||||
if (realSource.boardId === realTarget.boardId) {
|
||||
const document = tree.current.current.get(realSource.boardId)!;
|
||||
tree.dispatch({ type: "replace", document: { ...document, schema_version: 2, edges: addEdge({ id: crypto.randomUUID(), source: realSource.id, target: realTarget.id, sourceHandle: realSource.handle, targetHandle: realTarget.handle }, document.edges) } }, realSource.boardId);
|
||||
@@ -313,20 +377,24 @@ function CanvasEditor({ board, snapshot }: { board: Board; snapshot?: CanvasSnap
|
||||
return;
|
||||
}
|
||||
}
|
||||
// 兼容路径由exposedConnection提供可寻址端点与owner,失败提示跨画布连接暂不可用。
|
||||
const exposed = exposedConnection(tree.current.current, realSource, realTarget);
|
||||
const document = tree.current.current.get(exposed.boardId)!;
|
||||
tree.dispatch({ type: "replace", document: { ...document, schema_version: 2, edges: addEdge({ ...connection, source: exposed.source.id, target: exposed.target.id, sourceHandle: exposed.source.handle, targetHandle: exposed.target.handle, id: crypto.randomUUID() }, document.edges) } }, exposed.boardId);
|
||||
} catch (error) { setError(error instanceof Error && error.message === "boundary_port_required" ? "跨画布连接暂不可用,请稍后重试。" : errorText(error)); }
|
||||
}
|
||||
/** 校验MIME与大小并清文件input;迁移期间拒绝。真实画布通过上传占位,演示以Data URL直接加入。 */
|
||||
async function readImage(event: ChangeEvent<HTMLInputElement>) {
|
||||
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;
|
||||
}
|
||||
const request = pendingImage.current; pendingImage.current = null;
|
||||
const kind = imageKind.current;
|
||||
// 真实图片上传先取得本地Blob预览并构造draft,随后把pendingUpload节点及可选分支边发布到owner。
|
||||
if (snapshot) {
|
||||
try { const job = uploads.start(file, (src) => {
|
||||
const current = treeRef.current;
|
||||
@@ -339,12 +407,14 @@ function CanvasEditor({ board, snapshot }: { board: Board; snapshot?: CanvasSnap
|
||||
const node=makeCanvasNode(kind,center(),src);if(kind==='plain-image')node.data.title=file.name.slice(0,300);
|
||||
return {owner:board.id,node};
|
||||
});
|
||||
// pendingUpload以node.id标记,后续资源完成/跨区移动/历史恢复都按这个身份找到占位。
|
||||
const current=treeRef.current,doc=current.current.current.get(job.owner)!;
|
||||
const node={...job.node,pendingUpload:job.node.id};
|
||||
current.dispatch({type:"replace",document:{...doc,nodes:[...doc.nodes,node],edges:[...doc.edges,...(job.edge?[job.edge]:[])]}},job.owner);
|
||||
setTool("select"); } catch(error) {setError(errorText(error));}
|
||||
return;
|
||||
}
|
||||
// 演示不上传服务端,FileReader读取全部字节为Data URL;纯图片另解码初始尺寸,finally释放读取中UI状态。
|
||||
setImageUploading(true);
|
||||
try {
|
||||
const src = await new Promise<string>((resolve, reject) => {
|
||||
@@ -366,10 +436,14 @@ function CanvasEditor({ board, snapshot }: { board: Board; snapshot?: CanvasSnap
|
||||
} catch (error) { pendingImage.current = null; setError(errorText(error)); }
|
||||
finally { setImageUploading(false); }
|
||||
}
|
||||
/** 旧版导入最多5MB并全量读取解码,替换当前根快照;任一方含真实子图时拒绝,避免破坏跨board关系。
|
||||
* ioActive用于阻止迁移交接;这条路径不是新版渐进导入,也不会自动重建bundle子图。 */
|
||||
async function importProject(event: ChangeEvent<HTMLInputElement>) {
|
||||
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,7 +453,10 @@ 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;}
|
||||
}
|
||||
/** 有上传占位则先等待用户完成;树导出完整可达文档,多board写bundle,单board写当前视口。
|
||||
* 下载名清理文件名非法字符,Blob URL延迟释放;不额外打包File/Blob,已有演示Data URL仍随src导出。 */
|
||||
async function exportProject() {
|
||||
let documents: Map<string, import("@/lib/canvas").CanvasDocument>;
|
||||
try { if([...tree.current.current.values()].some((doc)=>doc.nodes.some((node)=>node.pendingUpload))) throw new Error("图片仍在上传,完成后即可导出。"); documents = await tree.exportDocuments(); } catch (error) { setError(error instanceof Error ? error.message : "导出未完成"); return; }
|
||||
@@ -392,7 +469,9 @@ function CanvasEditor({ board, snapshot }: { board: Board; snapshot?: CanvasSnap
|
||||
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
||||
}
|
||||
|
||||
// 旧版全局快捷键:跳过输入框、文本编辑、对话框和打开的popover,支持撤销/重做与V/H;卸载移除。
|
||||
useEffect(() => {
|
||||
/** 按DOM目标过滤后处理键;此旧路径没有新版routeCanvasKey的组合输入/认领优先级模型。 */
|
||||
const keydown = (event: KeyboardEvent) => {
|
||||
const target = event.target;
|
||||
if (!(target instanceof HTMLElement)) return;
|
||||
@@ -410,15 +489,22 @@ function CanvasEditor({ board, snapshot }: { board: Board; snapshot?: CanvasSnap
|
||||
return () => window.removeEventListener("keydown", keydown);
|
||||
}, []);
|
||||
|
||||
// Provider把图片预览、节点动作、分区反馈及活动连线接到renderer;迁移时surface与底部创作栏设inert。
|
||||
// ReactFlow删除由onBeforeDelete拦截真实子图/暴露端口节点,避免默认删除破坏结构;普通元素沿tree变化入口。
|
||||
// 选择工具允许拖动/连接,抓手只平移;Space临时平移、Shift部分框选,滚轮默认平移、修饰键缩放。
|
||||
// 工具栏创建/模板/导入走上述入口,顶部视口操作保存本地viewport;AI只给选中且支持的节点显示。
|
||||
// 恢复弹窗先根再子,逐项核查canRestore;迁移组件检查草稿、文件IO和未完成上传再排空writer。
|
||||
return (
|
||||
<div className={`${styles.editor} ${tool === "hand" ? styles.handMode : ""}`}>
|
||||
<ImagePreviews.Provider value={imagePreviews}>
|
||||
<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;
|
||||
// 选中分区入口要先dissolve保留内容;内部节点若被父port引用,先移回父board再删,清理边界引用。
|
||||
const linked = removing.filter((node) => node.data.subgraph);
|
||||
const parents = subgraphParents(tree.current.current);
|
||||
const referenced = removing.filter((node) => {
|
||||
@@ -428,6 +514,7 @@ function CanvasEditor({ board, snapshot }: { board: Board; snapshot?: CanvasSnap
|
||||
});
|
||||
if (!linked.length && !referenced.length) return true;
|
||||
try {
|
||||
// 分区逐个事务解散,参与者包含父、子、孙及需要调整引用的祖先;并非一批选择全体原子操作。
|
||||
for (const node of linked) {
|
||||
const ref = tree.views.nodeRefs.get(node.id)!;
|
||||
await tree.ensure(node.data.subgraph!.boardId);
|
||||
@@ -437,6 +524,7 @@ function CanvasEditor({ board, snapshot }: { board: Board; snapshot?: CanvasSnap
|
||||
if (ancestor && subgraphMoveParticipants(tree.current.current, ref.boardId, node.data.subgraph!.boardId, ref.boardId, child.nodes.map((item) => item.id)).includes(ancestor)) participants.add(ancestor);
|
||||
await tree.command(ref.boardId, `subgraphs/${ref.id}/dissolve`, {}, [...participants]);
|
||||
}
|
||||
// 解散后owner可能变化,按原ID重新查唯一归属;找不到唯一目标就跳过,不误删同ID的其它节点。
|
||||
for (const node of removing.filter((node) => !node.data.subgraph)) {
|
||||
const ref = tree.views.nodeRefs.get(node.id); if (!ref) continue;
|
||||
let owner = ref.boardId;
|
||||
@@ -453,6 +541,7 @@ function CanvasEditor({ board, snapshot }: { board: Board; snapshot?: CanvasSnap
|
||||
}
|
||||
if (linked.length) setMembershipNotice("已解散选框,子画布内容已保留");
|
||||
} catch (error) { setError(error instanceof Error ? error.message : "删除未完成"); }
|
||||
// 结构分支已自行执行删除/解散,阻止ReactFlow再按旧投影默认删一遍。
|
||||
return false;
|
||||
}}
|
||||
onNodesChange={tree.nodesChanged}
|
||||
@@ -478,11 +567,13 @@ function CanvasEditor({ board, snapshot }: { board: Board; snapshot?: CanvasSnap
|
||||
defaultEdgeOptions={{ style: { stroke: "#aa968b", strokeWidth: 1.5 } }}>
|
||||
<Background variant={BackgroundVariant.Dots} gap={22} size={1} color="#c9bfb5" />
|
||||
{ai.enabled && nodes.filter((node) => node.selected && ["text", "script", "plain-text", "image", "plain-image"].includes(node.data.kind)).map((node) => {
|
||||
// AIPanel收到owner原始节点,显示ID仅用于NodeToolbar定位;save仍经tree.nodeAction回写owner。
|
||||
const ref = tree.views.nodeRefs.get(node.id)!;
|
||||
const owned = tree.documents.get(ref.boardId)?.nodes.find((item) => item.id === ref.id);
|
||||
return owned && <AIPanel key={node.id} id={node.id} node={owned} workspace={board.workspace_id} boardId={ref.boardId} ai={ai} save={(config) => tree.nodeAction(node.id, (id) => ({ type: "node-data", id, patch: { ai: config } }))} />;
|
||||
})}
|
||||
</ReactFlow>
|
||||
</div>
|
||||
</EdgeEditing.Provider>
|
||||
</FrameFeedback.Provider>
|
||||
</NodeEditing.Provider>
|
||||
@@ -500,7 +591,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,13 +633,14 @@ 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>
|
||||
</div>}
|
||||
{tree.memberJobs.map((job, index) => job.ids.map((id) => {
|
||||
// 成员任务提示跟随当前source/target中的场景节点,队首同步、后续等待,失败通过retryMembers继续。
|
||||
const node = [...tree.views.nodeRefs].find(([, ref]) => ref.id === id && [job.source, job.target].includes(ref.boardId));
|
||||
return node && <NodeToolbar key={`${job.source}/${id}`} nodeId={node[0]} isVisible position={Position.Top}>
|
||||
<div className={`${styles.memberSync} nodrag nopan`} role="status" aria-label="元素同步状态" data-node-id={node[0]}>
|
||||
@@ -556,7 +648,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 +660,7 @@ 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>;
|
||||
/** 为单个旧编辑器建立独立ReactFlow store;board/snapshot身份切换的重挂载由路由调用方负责。 */
|
||||
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,79 @@
|
||||
/**
|
||||
* 新版元素AI工具面板。配置、生成开始、取消和结果采用均通过engine命令进入既有状态与同步链路。
|
||||
* 任务事实读取runtime.runs/ledger,局部state只保存输入、模型目录和界面错误;关闭面板不等于取消生成。
|
||||
*/
|
||||
"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";
|
||||
|
||||
/** 只接受非数组JSON对象作为配置/输出包络,其它值退为空对象,避免直接按对象读取标量。 */
|
||||
const object = (value: Json | undefined) => value && typeof value === 'object' && !Array.isArray(value) ? value : {};
|
||||
/** 配置输入从元素当前ai字段初始化;模型输出模态按image/plain-image与其它组件区分。
|
||||
* 元素数据来自父层投影,本组件直接读取engine根用于显示任务,不再创建额外轮询器。 */
|
||||
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);
|
||||
// 用runContainsTarget核对目标身份,选择相关任务列表的最后一项;不是按时间戳重新排序。
|
||||
const relevant = [...root.runtime.runs.values()].filter(run => runContainsTarget(run,element));
|
||||
const run = relevant.at(-1);
|
||||
// ledger里的run.start可尚未得到服务端run,必须单独显示等待/阻断,不能仅看runs来判定空闲。
|
||||
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;
|
||||
// 同一run未解决的cancel/apply也占忙碌状态;hasMore/pendingPage表示任务事件尚未完整读取。
|
||||
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));
|
||||
// 展示当前元素最后一个输出事件;是否可采用还要检查output_id、任务冲突状态及已应用集合。
|
||||
const outputEvent = run?.outputEvents.filter(event => event.node_id === element.id).at(-1), output = object(outputEvent?.output);
|
||||
// 按当前ownerCanvasId读取可用模型,保留仍兼容的选择,否则选首个available;目录不可用原因单独提示。
|
||||
// 切作用域/卸载时abort;所有异步结果先检查取消状态,不把旧目录写回新面板。
|
||||
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]);
|
||||
/** 将同步命令的rejected结果显示为错误;返回布尔值供“先保存配置再启动”短路。 */
|
||||
function check(result: Result) { if(result.status === 'rejected') { setError(errorText(new Error(result.code)));return false; }return true; }
|
||||
/** 有模型且不忙时提交ai配置;否则不发修改并返回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]) : '结果将显示在当前元素';
|
||||
// 输入blur保存配置,执行按钮再保存一次且成功后startRun;忙碌时锁定配置和启动,关闭按钮仍可用。
|
||||
// queued/running提供取消请求入口;冲突输出保留展示,只有事件读完且未采用时才允许显式替换。
|
||||
// 跟踪重试调用engine.retry,不新建run.start;pointer/keydown止于面板,避免触发画布手势或快捷键。
|
||||
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,427 @@
|
||||
/**
|
||||
* 新版主画布的React/ReactFlow装配。engine拥有文档、历史和运行事实,sessions拥有每视图选择/工具/手势;组件只保存显示缓存与面板状态。
|
||||
* 文档提交先推进场景索引,再按事件类型立即或逐帧发布;几何预览通过session进入投影,提交才产生文档修改。
|
||||
* 画布适配层负责DOM测量、命中、裁剪、焦点和资源显示,core不读取DOM;渲染组件通过动作上下文调用editor/runtime。
|
||||
* 外层只借用项目engine:挂载attach视图,卸载detach,不关闭工作区持有的项目或持久任务。
|
||||
*/
|
||||
"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";
|
||||
|
||||
/** 把适配层样式等可序列化值规范化成JSON Data,去掉undefined等JSON不保留的属性;不处理循环或任意非JSON对象。 */
|
||||
const json = (value: unknown): Data => JSON.parse(JSON.stringify(value));
|
||||
/** 已attach视图的内容组件;需要外层ReactFlowProvider与CanvasProvider。root/session是发布快照,current供回调读取最新投影。 */
|
||||
function CanvasView({ engine, board, viewId, renderers,fitLoading=true }: { engine: ProjectEngine; board: Board; viewId: Id; renderers:ReactRenderers;fitLoading?:boolean }) {
|
||||
// 场景索引按engine+view复用,提交订阅先advance变更,再通知显示,避免每次渲染重建整图索引。
|
||||
const sceneCache=useMemo(()=>createSceneProjection(),[engine,viewId]);
|
||||
/** 用户字段修改/替换删除及undo/redo立即发布,保证受控输入及时回显;批量创建和后台修改允许合并到下一帧。 */
|
||||
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]);
|
||||
// rootFrames只调度呈现通知,不延迟editor真实提交;sessionFrames独立合并手势/选择等视图变化。
|
||||
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);
|
||||
// 视图被detach后保留最近view供本轮Hook计算,最终返回“画布已关闭”;不以lastView恢复已关闭会话。
|
||||
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>();
|
||||
// 完整加载后允许普通创建;加载中要求loading版本支持且当前canvas覆盖完整,最终命令层仍再校验准入。
|
||||
const canCreate=!loading||loading.version>=4&&engine.editor.getQueries(root.document).coverage.completeCanvasIds.includes(view.canvasId);
|
||||
// 加载预览首次适配只取当前canvas直接节点,避免展开后代把视口拉得过远;有交接视口时不再自动fit。
|
||||
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]);
|
||||
// 复制准备任务来自runtime状态;copying防本地重复点击,两者共同禁用复制入口。
|
||||
const preparingCopy = [...(root.runtime.imports?.values() ?? [])].some(job => job.status === 'preparing' && job.source?.targetCanvasId === view.canvasId);
|
||||
// 焦点与epoch用于保留活动输入和排除迟到blur;切engine/view复位,移除renderer未触发blur时另检查isConnected。
|
||||
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);
|
||||
// 选择转成id/incarnation后查询深复制可用性;渲染时的可用性仅用于UI,点击时再次捕获实际选择交runtime。
|
||||
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("");
|
||||
// resource显示状态带engine/view身份,旧显示器迟到回调不能为当前画布提供错误图片;sources不是文档资源表。
|
||||
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);
|
||||
// 拖动保存起始场景坐标、指针ID与会话token,positions只含顶层选中节点,防分区与成员重复位移。
|
||||
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);
|
||||
// 复用投影对象与测量结果,避免ReactFlow把纯元数据变化误当节点卸载,影响输入及连接点。
|
||||
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 } }>());
|
||||
// groups按元素生命周期保存一次文本编辑的history分组ID,元素重建后清旧分组,不能跨incarnation合并编辑。
|
||||
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]);
|
||||
// 加载期使用受限renderer catalog,完整后回到正常定义;sceneCache把文档、会话预览与查询组成显示场景。
|
||||
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));
|
||||
// 仅在尺寸一致且投影缓存确认身份/变换允许时复用measured;离开场景的节点清测量条目。
|
||||
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]);
|
||||
// 每个view独立OverlayIndex,Portal控件通过索引贡献边界与焦点;不会把浮层几何存入项目文档。
|
||||
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();
|
||||
|
||||
// 绘制范围索引用于可见性筛选;sceneViewport把ReactFlow平移/缩放反算为场景矩形。
|
||||
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};
|
||||
// 过滤元素生命周期已变的浮层;缺surface矩形时只按身份保守保留,不凭缺失测量断言不可见。
|
||||
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)):[];
|
||||
// 未知绘制范围必须保留,加上矩形查询命中;不能把unknown当成屏幕外。
|
||||
const paintedNodes=[...paint.unknown,...paint.query(sceneViewport)];
|
||||
const nativeNodes=useStore(state=>state.nodes);
|
||||
const connectionPaintCache=useMemo(()=>createConnectionPaintIndex(),[engine,view.canvasId]);
|
||||
// 连线范围依赖真实Handle测量:新版/未知连线使用measuredConnector,兼容节点使用旧几何适配。
|
||||
// 订阅nativeNodes让测量更新后重算连线路径范围,不把DOM测量要求下放core。
|
||||
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)];
|
||||
// 保留调用方renderer表,并显式安装unsupported节点/边占位,未知内容仍可在场景中呈现。
|
||||
const nodeTypes=useMemo(()=>({...renderers.nodeTypes,unsupported:UnsupportedNode}),[renderers]),edgeTypes=useMemo(()=>({...renderers.edgeTypes,unsupported:UnsupportedEdge}),[renderers]);
|
||||
// 稳定动作闭包从current/engine读取最新状态,runtime-only发布不必重建全部卡片编辑上下文。
|
||||
const current = useRef({ root, projection, view, engine, sessions }); current.current = { root, projection, view, engine, sessions };
|
||||
/** 将命令拒绝或能力不可用显示为错误;成功结果无需额外复制进本地state。 */
|
||||
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)));
|
||||
}, []);
|
||||
/** 发命令前读取当前incarnation,元素缺失即抛错;渲染侧更早的身份检查由useScopedNodeEditing等负责。 */
|
||||
const target = (id: Id) => { const element = engine.getSnapshot().document.elements.get(id); if (!element) throw new Error('element_missing'); return { id, incarnation: element.incarnation }; };
|
||||
/** 统一元素data补丁入口,可携history groupId;schema、能力和事务合法性留editor验证。 */
|
||||
const data = (id: Id, patch: Data, groupId?: Id) => report(engine.editor.dispatch({ type: 'element.data', target: target(id), patch }, { groupId }));
|
||||
/** 把ReactFlow选择变化合到session当前Set,成员完全相同则不重复发布;不把selected写入文档。 */
|
||||
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]);
|
||||
}
|
||||
/** 空白点击补充core投影命中:12px容差、预览目标及实际Handle/layer适配;只选择confirmed命中。
|
||||
* 命中计算后再次比较document/session快照身份,过期帧不能选中新incarnation;抓手模式点击清选择。 */
|
||||
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]:[]);
|
||||
}
|
||||
/** session.begin成功后捕获gesture token,以guardEditingLease约束后续preview/commit/cancel只作用于该交互。 */
|
||||
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;
|
||||
/** 每次租约动作实时核对token,旧pointerup不能结束后来新建的手势。 */
|
||||
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') });
|
||||
}
|
||||
/** 将显示空间位置/尺寸转成owner局部Geometry:尺寸除scale,以中心toLocal后再求左上角,旋转减去父旋转。
|
||||
* 这里只更新session预览,不直接dispatch文档;后续commit统一形成history与同步修改。 */
|
||||
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));
|
||||
}
|
||||
/** 创建内置节点,可加入调用方已有事务;frame同时创建以节点为入口的子canvas,保证结构一起提交。
|
||||
* 默认位置从首个data-headless-canvas的DOM矩形取屏幕中心再转场景坐标,at可显式覆盖。 */
|
||||
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 });
|
||||
// 各类型使用固定默认尺寸及初始incarnation/dataVersion;资源引用由已stage/adopt的ref注入,不在这里上传。
|
||||
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 } : {}) } };
|
||||
/** tx内创建元素及可选canvas关系;无外部tx时包装为一个Add element事务。 */
|
||||
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;
|
||||
}
|
||||
/** 选择工具才可建边;拒绝unsupported端点,将owner设为两端所属canvas的共同上级,端口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 } }));
|
||||
}
|
||||
// 用节点ID+assetId序列作为显示订阅键,资源分辨率/URL交资源视图决定;组件不把Blob URL写入元素data。
|
||||
const resourceKey = JSON.stringify(projection.nodes.filter(node => typeof node.data.assetId==='string'&&node.data.assetId).map(node => [node.id, node.data.assetId]));
|
||||
// 每个engine/view建立资源显示器,回调只接受当前实例;清理dispose解除订阅及显示资源占用。
|
||||
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)]);
|
||||
// 资源目标集合变化才sync,映射失败在本面板报错,不擅自修改文档中的assetId。
|
||||
useEffect(()=>{try{resourceView.current?.sync(JSON.parse(resourceKey) as [Id,Id][]);}catch(error){setError(errorText(error));}},[engine,viewId,resourceKey,Boolean(liveView)]);
|
||||
// 再按engine/view及当前assetId过滤结果,节点切换资源时不显示上一个资源的迟到URL。
|
||||
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]);
|
||||
/** 一次事务删除当前选择中仍存在的元素;关联结构处理由editor命令实现,不循环修改投影数组。 */
|
||||
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' })); }
|
||||
// 同步显示来自ledger:未resolved算待处理;waiting_digest单独等待不算阻断,无定时重试的错误和conflicts要提示处理。
|
||||
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;
|
||||
// 全局失焦或Escape仅取消本视图拥有的节点/连接手势;不干扰同engine其它视图的交互。
|
||||
useEffect(() => {
|
||||
/** 核对viewId后取消session并清本地拖动记录;window blur不要求指针事件。 */
|
||||
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.
|
||||
/** 冒泡阶段让输入控件优先认领Escape,再按统一路由决定是否取消手势。 */
|
||||
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.
|
||||
// 保留模式目标、可见绘制对象、浮层目标和本视图手势;完整图仍留ReactFlow查询表,不以伪造拖动避裁剪。
|
||||
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);
|
||||
// 活动输入可来自节点、连线、Portal浮层或独立UI;无法证明其归属时保守关闭可见性裁剪。
|
||||
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);}}
|
||||
// 所有保留对象仍须存在于文档,且视口尺寸已知,才启用native onlyRenderVisibleElements。
|
||||
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);
|
||||
// 单选时才启用图片适配/AI工具;图片要求已知组件、resize能力和声明的assetId资源字段,不能猜同名业务字段。
|
||||
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;
|
||||
// 新版资源URL寿命由resourceView管理,CanvasImage的loaded回调这里无需删除旧版上传任务。
|
||||
const imagePreviews = useMemo<ContextType<typeof ImagePreviews>>(() => ({sources,loaded:()=>{}}),[sources]);
|
||||
// 给旧节点renderer注入新版动作:begin/end维护文本history分组与editing mode,正文按同incarnation使用groupId。
|
||||
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'); },
|
||||
// 样式和分区补丁走data,模式rotate/resize只在sessions中切换;开启一个前先清同视图该类旧模式。
|
||||
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'); },
|
||||
// 分支直接在当前canvas按方向偏移创建文本,再同一事务创建连线;与旧版弹类型菜单的行为不同。
|
||||
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]);
|
||||
// 曲线编辑同样捕获connectionGesture token;active暂为空集合,连线renderer在此不从run状态获取执行动画。
|
||||
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)!;
|
||||
/** token护栏绑定该条连接手势,预览路径先从场景空间变回owner局部空间。 */
|
||||
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'); },
|
||||
// 临时path只更新session;正式修改把data和path分开命令,在同一个事务提交,避免把路径写进业务data。
|
||||
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;
|
||||
// 工具栏可能传回整个显示data;只有显式含path且与显示路径不同才提交connection.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]);
|
||||
// 自定义renderer的dispatch/transaction也经过editor,并统一report拒绝;选项只传label/groupId。
|
||||
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]);
|
||||
// 本组件Hook全部调用后才判断视图关闭,保留React Hook调用顺序;已detach时不挂载交互surface。
|
||||
if (!liveView) return <p role="status">画布已关闭</p>;
|
||||
// surface集中处理焦点epoch、拖动起点和键盘路由:blur微任务只清旧epoch,多触点取消当前手势。
|
||||
// 快捷键按输入上下文优先级处理Escape、H/V、撤销重做及删除;只有认领成功才阻止默认行为。
|
||||
// ReactFlow位移变化只转session预览,测量单独缓存;拖动结束依据起始位置补最后位移后commit。
|
||||
// 工具栏撤销/深复制/导出走各自editor/runtime接口,视口缩放只改view;导出完整文档JSON,不含文件字节。
|
||||
// 文件选择调用stageFiles,引用与图片节点在回调事务里一起创建;导入/AI/资源/恢复面板按加载与选择条件挂载。
|
||||
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 => {
|
||||
// 仅结束匹配本视图和token的拖动,迟到dragStop不提交当前其它手势。
|
||||
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={() => {
|
||||
// 深复制点击时捕获选择身份,异步完成只更新同engine/view的面板;runtime负责进度与已加入内容。
|
||||
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>;
|
||||
}
|
||||
/** 挂载期生成稳定viewId并attach,借用initialViewport只初始化一次;detach只释放视图,不关闭engine。
|
||||
* 交接视口存在时禁用加载初次fit,避免从只读预览切入后可视位置突然跳变。 */
|
||||
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);
|
||||
// attach完成后才挂载CanvasView所需Provider;清理时detach视图,CanvasView卸载清理自身订阅。
|
||||
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,58 @@
|
||||
/**
|
||||
* 新版画布的导入/复制任务显示入口。订阅engine已发布状态,选择文件后启动runtime任务,并展示已加入前缀、等待项与失败原因。
|
||||
* 面板不保存独立任务事实;暂停/继续/停止由imports实现,停止后已加入内容仍保留,不能把它当成整批回滚。
|
||||
*/
|
||||
"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] ?? "这条记录暂时无法加入";
|
||||
|
||||
/** 只显示目标canvas最近三个任务,倒序展示;任务数量与汇总仅覆盖这个显示窗口,不代表项目全部导入。 */
|
||||
export function HeadlessImports({ engine, canvasId, viewId }: { engine: ProjectEngine; canvasId: string; viewId:string }) {
|
||||
// React订阅唯一发布根,不在组件里复制任务状态;同一个getSnapshot也作为服务端快照入口。
|
||||
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("");
|
||||
// 每次渲染更新engine/canvas身份,用于异步文件探测和控制回调完成后避免更新另一画布的错误显示。
|
||||
const scope = useRef({ engine, canvasId }); scope.current = { engine, canvasId };
|
||||
const jobs = [...(snapshot.runtime.imports?.values() ?? [])].filter(job => job.source?.targetCanvasId === canvasId).slice(-3).reverse();
|
||||
/** 统一承接runtime控制Promise的成功/失败;这里只更新面板错误,不自行重排或重启任务。 */
|
||||
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)); }); };
|
||||
// canStart决定文件入口可用性;input每次先清value,允许再次选择同一个文件。
|
||||
// .json读取前64KiB探测首个非空白字符:对象走文档导入,数组走记录数组;其它后缀走NDJSON。
|
||||
// 探测前捕获canvas父关系及入口生命周期,await后重新核对,防止向已被替换/移走的目标启动任务。
|
||||
// integrityVersion=1源校验未完成显示校验中;=2允许内容继续编辑,但提示校验完成后同步。
|
||||
// 任务按钮按runtime状态显示;失败详情最多列前10项,停止提示明确已加入内容继续同步。
|
||||
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,109 @@
|
||||
/**
|
||||
* 新版画布的同步冲突处理界面。取得engine提供的恢复会话,按操作逐项选择保留/放弃,先预览再应用。
|
||||
* 真正的冻结、回执核对、重放及备份由runtime完成;面板只保存选择与方案展示,不自行覆盖当前文档。
|
||||
*/
|
||||
"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 '处理未完成,当前修改仍已保留。请重试,或恢复编辑后稍后处理。';
|
||||
}
|
||||
/** undefined明确显示未设置,字符串直接显示,其它JSON序列化;超过240字符可展开全文。
|
||||
* React文本节点负责转义内容,不将导入内容或服务端值作为HTML执行。 */
|
||||
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>;
|
||||
}
|
||||
/** 仅对常见字段提供具体中文名,其它data字段与结构关系使用类别名,不改变字段寻址规则。 */
|
||||
const fieldLabel=(field:string)=>({'data.title':'标题','data.text':'正文',geometry:'位置与尺寸',path:'连线路径'}[field]??(field.startsWith('data.')?'内容字段':'元素关系'));
|
||||
|
||||
/** 创建/删除/替换类变更并排显示服务端当前记录与本地after;字段变更由主列表另行渲染。 */
|
||||
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>;
|
||||
}
|
||||
/** 展示方案内结构影响:记录创建删除,以及owner/端点/路径字段;统计删除数量便于核对关联范围。 */
|
||||
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>;
|
||||
}
|
||||
|
||||
/** owner保存清理时要取消的会话,session用于显示;records按操作序列展示并排除上传任务。
|
||||
* choices每个operationId一个决定;plan是会话生成的预览,任何选择修改都使旧plan失效。 */
|
||||
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[]>([]);
|
||||
// busy防重复动作;frozen在开始应用后锁住选择,保存异常时允许原方案重试,明确拒绝才解除冻结。
|
||||
const [choices,setChoices]=useState(new Map<Id,RecoveryChoice>()),[plan,setPlan]=useState<ConflictPlan>(),[error,setError]=useState(''),[busy,setBusy]=useState(true),[frozen,setFrozen]=useState(false);
|
||||
// 微任务中准备会话,组件已清理时跳过;会话晚到则立即cancel,不再认领为当前面板。
|
||||
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);});
|
||||
// 卸载尝试取消自己取得的会话;失败时engine仍保留持久屏障,后续retry/cancelRecovery继续收尾。
|
||||
return()=>{live=false;void owner.current?.cancel().catch(()=>{/* Engine retains the failed durable barrier; retry/cancelRecovery can finish it. */});};
|
||||
},[engine]);
|
||||
/** 已有session且正在操作时不允许取消;否则等待engine解除恢复屏障,成功才关闭,失败留窗提示。 */
|
||||
async function cancel(){if(busy&&session)return;setBusy(true);try{await engine.cancelRecovery();close();}catch(error){setError(message(error));}finally{setBusy(false);}}
|
||||
/** 有plan则resolve逐项方案,无待处理操作则apply采用最新根;拒绝结果展示原因,异常保留冻结方案供重试。 */
|
||||
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);}}
|
||||
// canonical是恢复会话捕获的服务端基准,不直接读working当成服务端值。
|
||||
const canonical=session?.canonical.document;
|
||||
// Dialog关闭统一走cancel,session忙碌时抑制Escape;初次准备期间仍可请求恢复编辑。
|
||||
// 逐项显示服务端、本地与方案值:afterPresent=false明确表示移除字段,不等同undefined值。
|
||||
// 仅document.change/run.apply提供保留选项,生成开始/取消等动作可放弃但不能在此自动重新执行。
|
||||
// 选择齐全才允许plan,存在本地记录则必须先有plan才允许应用;方案汇总同时显示undo/redo数量变化。
|
||||
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,84 @@
|
||||
/**
|
||||
* 从同工作区已打开的其它项目中发现可复用图片,授权接纳资源后在目标画布创建新图片元素。
|
||||
* 面板不重新上传原文件;资源接纳与元素创建的事务、持久化和授权由engine.adoptResource负责。
|
||||
*/
|
||||
"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";
|
||||
|
||||
/** 懒加载候选缩略图,失败后以通用图标代替;failed是该预览组件的局部显示状态,不修改资源记录。 */
|
||||
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)}/>;}
|
||||
/** 列表只保存资源ID和展示标题;插入时重新由engine取得资源元数据,不能信任缩略图即代表可访问。 */
|
||||
type Candidate = { id: Id; title: string };
|
||||
/** position是打开面板时捕获的画布坐标,插入图片以此为中心;onAdded让调用方选择新元素。 */
|
||||
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("");
|
||||
// controller取消本次面板操作,requesting同步防重复点击,busy用于禁用按钮和展示当前候选进度。
|
||||
const lifecycle=useRef<AbortController|null>(null),requesting=useRef(false),descriptionId=useId();
|
||||
/** 关闭前发取消信号;已经提交的事务不会由这个UI回调自动撤销。 */
|
||||
const dismiss=()=>{lifecycle.current?.abort();close();};
|
||||
// 初次挂载捕获目标canvas及父入口记录,异步接纳资源后仍须核对入口身份与生命周期。
|
||||
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');
|
||||
// 单个项目打开失败不阻断其它候选,allSettled保留每项结果;排除本项目避免重复从自身取资源。
|
||||
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');
|
||||
// 按资源ID去重,排除本项目已有资源;其它项目若有本地未stored资源也跳过,避免复用未可靠保存的文件。
|
||||
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;
|
||||
// 只从明确声明assetId资源字段的组件提取,不能把任意同名业务字段当图片资源。
|
||||
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]);
|
||||
/** 一次只接纳一个候选。生成新元素ID,在adoptResource提供的事务内校验目标并创建图片。
|
||||
* 失败保持面板以便重试;关闭或卸载后不再更新错误/选中状态,取消的实际边界由engine处理。 */
|
||||
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;
|
||||
// 父入口ID变化或原入口incarnation变化都拒绝,避免资源授权等待期间目标已经重建。
|
||||
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');
|
||||
// 按原始宽高等比装入260×200边界,再以position为中心计算左上角;小图也可能被放大。
|
||||
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});
|
||||
// engine拒绝与异常统一进入错误展示;仅在面板仍活跃时通知onAdded并关闭。
|
||||
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);}
|
||||
}
|
||||
// Dialog关闭入口都走dismiss;缩略图使用工作区资源预览接口,标题截断仅影响显示。
|
||||
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>;
|
||||
}
|
||||
@@ -1,11 +1,21 @@
|
||||
/**
|
||||
* 图标目录数据与搜索入口。JSON保存名称、中文标签、关键词及分类等检索元数据;SVG路径另由图标加载器取得。
|
||||
*/
|
||||
import records from "./icon-catalog-data.json" with { type: "json" };
|
||||
|
||||
/** 分类展示顺序;common/all/other是搜索时有特殊含义的选项,其余按categories成员匹配。 */
|
||||
export const iconCategories = [
|
||||
["common", "常用"], ["all", "全部"], ["arrows", "箭头与导航"], ["people", "人物与表情"], ["office", "文件与办公"], ["media", "媒体与设计"], ["tech", "科技与设备"], ["business", "图表与商业"], ["nature", "自然与天气"], ["travel", "出行与地点"], ["shapes", "形状与符号"], ["other", "其他"],
|
||||
] as const;
|
||||
/** 导出静态记录数组供选择器统计与筛选,不在运行时下载整套图标路径。 */
|
||||
export const iconCatalog = records;
|
||||
/** 按图标name建Map供调用方直接定位记录;与目录共享记录对象,不复制或冻结内容。 */
|
||||
export const iconsById = new Map(iconCatalog.map((icon) => [icon.name, icon]));
|
||||
/** 来源过滤支持全部、Lucide和Tabler;分类与来源是独立条件。 */
|
||||
export type IconSource = "all" | "lucide" | "tabler";
|
||||
/** 去掉首尾空白、连字符、下划线及空白并转小写,再在名称+标签+关键词中做子串匹配。
|
||||
* 筛选同时满足来源和分类;common看common标记,other指没有分类,all跳过对应限制。
|
||||
* 保留目录原顺序,不计算相关性分数,也不限制结果数量;虚拟列表由调用方实现。 */
|
||||
export function searchIcons(query: string, category = "all", source: IconSource = "all") {
|
||||
const needle = query.trim().toLowerCase().replace(/[-_\s]/g, "");
|
||||
return iconCatalog.filter((icon) => (source === "all" || icon.source === source)
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
/**
|
||||
* 图标节点的轻量显示装配。图标名称与颜色来自节点data,交互与几何变换委托公共节点容器。
|
||||
*/
|
||||
"use client";
|
||||
import type { NodeProps } from "@xyflow/react";
|
||||
import type { CanvasNode } from "@/lib/canvas";
|
||||
import { TransformableNode } from "./transformable-node";
|
||||
import { CanvasIcon } from "./canvas-icon";
|
||||
import styles from "./canvas.module.css";
|
||||
/** 前景色默认棕色、背景默认透明;SVG填满容器并用节点title命名,实际路径由CanvasIcon异步加载。
|
||||
* 此处的100%宽高跟随外层尺寸;固定strokeWidth传给图标层,不在本组件修改节点几何。 */
|
||||
export function IconNode(props: NodeProps<CanvasNode>) {
|
||||
return <TransformableNode {...props}><div className={styles.iconContent} style={{ color: props.data.textStyle?.color || "#78523f", background: props.data.textStyle?.background || "transparent" }}><CanvasIcon name={props.data.icon} aria-label={props.data.title} width="100%" height="100%" strokeWidth={1.5} /></div></TransformableNode>;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
/**
|
||||
* 图标选择面板。按来源、分类与文本查询筛选目录,使用固定五列虚拟列表只挂载视窗附近的图标按钮。
|
||||
* 仅保存搜索/滚动/焦点状态;choose把名称和标签交给画布创建节点,实际SVG按需加载。
|
||||
*/
|
||||
"use client";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { motion, useReducedMotion } from "motion/react";
|
||||
@@ -7,7 +11,9 @@ import { CanvasIcon } from "./canvas-icon";
|
||||
import { iconCatalog, iconCategories, searchIcons, type IconSource } from "./icon-catalog";
|
||||
import styles from "./canvas.module.css";
|
||||
|
||||
/** 固定网格参数:每行五项、每行48px;占位总高与绝对定位必须使用同一组参数。 */
|
||||
const columns = 5, rowHeight = 48;
|
||||
/** 通过Portal挂载到body,调用方在浏览器中打开;Escape或关闭按钮只触发close。 */
|
||||
export function IconPicker({ close, choose }: { close: () => void; choose: (name: string, label: string) => void }) {
|
||||
const [query, setQuery] = useState("");
|
||||
const [category, setCategory] = useState("common");
|
||||
@@ -18,7 +24,9 @@ export function IconPicker({ close, choose }: { close: () => void; choose: (name
|
||||
const search = useRef<HTMLInputElement>(null);
|
||||
const grid = useRef<HTMLDivElement>(null);
|
||||
const reduce = useReducedMotion();
|
||||
// 有非空查询时搜索当前来源的全部分类;无查询才使用分类按钮值,useMemo避免无关渲染重复过滤。
|
||||
const matches = useMemo(() => searchIcons(query, query.trim() ? "all" : category, source), [query, category, source]);
|
||||
// 挂载时聚焦搜索框并观察列表实际高度;卸载断开观察器,尝试把焦点还给打开前的元素。
|
||||
useEffect(() => {
|
||||
const previous = document.activeElement as HTMLElement | null;
|
||||
search.current?.focus();
|
||||
@@ -26,11 +34,15 @@ export function IconPicker({ close, choose }: { close: () => void; choose: (name
|
||||
if (grid.current) observer.observe(grid.current);
|
||||
return () => { observer.disconnect(); previous?.focus(); };
|
||||
}, []);
|
||||
// 任一筛选条件变化都回到列表顶部与首项焦点索引,避免沿用上次结果中的越界位置。
|
||||
useEffect(() => { if (grid.current) grid.current.scrollTop = 0; setScrollTop(0); setFocused(0); }, [query, category, source]);
|
||||
// 根据总行数撑起滚动区域,上下各多挂载一行缓冲;slice只取可见范围的目录项。
|
||||
const rows = Math.ceil(matches.length / columns);
|
||||
const firstRow = Math.max(0, Math.floor(scrollTop / rowHeight) - 1);
|
||||
const lastRow = Math.min(rows, Math.ceil((scrollTop + height) / rowHeight) + 1);
|
||||
const visible = matches.slice(firstRow * columns, lastRow * columns);
|
||||
/** 先钳制目标索引,再按行把目标滚入视口;等下一帧虚拟按钮挂载后调用focus。
|
||||
* 空结果没有可聚焦按钮,最终可选链自然不执行focus;不凭空创建占位项目。 */
|
||||
function focusIcon(index: number) {
|
||||
const next = Math.max(0, Math.min(matches.length - 1, index));
|
||||
setFocused(next);
|
||||
@@ -42,6 +54,9 @@ export function IconPicker({ close, choose }: { close: () => void; choose: (name
|
||||
}
|
||||
requestAnimationFrame(() => grid.current?.querySelector<HTMLButtonElement>(`[data-index="${next}"]`)?.focus());
|
||||
}
|
||||
// 分类点击同时清空搜索,来源选择保留查询;网格按钮按全局结果索引绝对定位,不按切片局部索引。
|
||||
// 左右移动一项、上下移动一行,Home/End跳至结果首尾;只有当前focused项进入Tab顺序。
|
||||
// 每个按钮由CanvasIcon加载路径,页脚结果数来自筛选数组;choose后是否关闭由调用方决定。
|
||||
return createPortal(<motion.aside initial={{ opacity: 0, x: reduce ? 0 : -12 }} animate={{ opacity: 1, x: 0 }} transition={{ duration: reduce ? 0 : 0.16 }} className={styles.iconPicker} role="dialog" aria-label="添加图标" onKeyDown={(event) => { event.stopPropagation(); if (event.key === "Escape") close(); }}>
|
||||
<header><strong>图标</strong><button aria-label="关闭图标面板" onClick={close}><X size={16} /></button></header>
|
||||
<label className={styles.iconSource}>图标库<select aria-label="图标库" value={source} onChange={(event) => setSource(event.target.value as IconSource)}><option value="all">全部图标库</option><option value="lucide">Lucide · 2313</option><option value="tabler">Tabler · 24 个试用</option></select></label>
|
||||
@@ -49,6 +64,7 @@ export function IconPicker({ close, choose }: { close: () => void; choose: (name
|
||||
<div className={styles.iconCategories} aria-label="图标分类">{iconCategories.map(([id, label]) => <button key={id} aria-pressed={!query.trim() && category === id} onClick={() => { setCategory(id); setQuery(""); }}>{label}</button>)}</div>
|
||||
<div ref={grid} className={styles.virtualIconGrid} aria-label="图标列表" onScroll={(event) => setScrollTop(event.currentTarget.scrollTop)}>
|
||||
<div style={{ height: rows * rowHeight, position: "relative" }}>{visible.map(({ name, label, source }, offset) => {
|
||||
// 加回首行偏移取得完整结果索引,保证滚动后的定位、Tab状态与键盘导航仍对应同一条记录。
|
||||
const index = firstRow * columns + offset;
|
||||
return <button key={name} type="button" data-index={index} tabIndex={focused === index ? 0 : -1} title={`${label} · ${source === "tabler" ? "Tabler" : "Lucide"}`} aria-label={`添加${label}图标`} style={{ position: "absolute", width: "20%", height: rowHeight, left: `${index % columns * 20}%`, top: Math.floor(index / columns) * rowHeight }} onFocus={() => setFocused(index)} onClick={() => choose(name, label)}
|
||||
onKeyDown={(event) => { const delta = { ArrowRight: 1, ArrowLeft: -1, ArrowDown: columns, ArrowUp: -columns, Home: -index, End: matches.length - 1 - index }[event.key]; if (delta !== undefined) { event.preventDefault(); focusIcon(index + delta); } }}><CanvasIcon name={name} size={24} /></button>;
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
/**
|
||||
* 旧画布迁往canonical模型的界面协调层。持有本页旧编辑租约,迁移前停止新编辑并排空旧writer,结果确认后重开新版。
|
||||
* “升级”指数据与协议迁移;本地记录和浏览器锁用于跨页协调,服务端版本校验与幂等仍由迁移服务负责。
|
||||
*/
|
||||
"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));
|
||||
}
|
||||
/** hold控制旧编辑入口;prepare排空指定根的保存工作,check检查当前是否允许迁移。
|
||||
* canLeave保护重开页面前的未同步内容,exportDraft提供本页备份,context提供账户与迁移根身份。 */
|
||||
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};
|
||||
/** 只保存迁移展示状态,实际请求/本地记录由createBrowserCanvasMigration管理。
|
||||
* attempt是同步防重入标记,running只用于渲染,finished表示本页已进入成功或外部变更后的交接阶段。 */
|
||||
export function LegacyCanvasMigration(props:Props){
|
||||
// 回调经ref读取最新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);
|
||||
// lease是本页共享旧编辑锁;life取消本次挂载工作;previous用来识别其它页面删除待确认记录的变化。
|
||||
const lease=useRef<{release():Promise<void>}|undefined>(undefined),life=useRef<AbortController|undefined>(undefined),attempt=useRef(false),finished=useRef(false),previous=useRef<CanvasMigrationJob|undefined>(undefined);
|
||||
// localStorage迁移记录按账户+工作区隔离,canvasId用于判断该任务是否阻断当前画布。
|
||||
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);
|
||||
/** 迁移尝试进行中不抢租约;已有pending或finished就保持hold,正常情形取得共享锁后开放旧编辑。 */
|
||||
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.
|
||||
// 不支持Web Locks仅禁止安全迁移;没有阻断记录时仍允许旧编辑,不能把这类环境错误当数据已迁移。
|
||||
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);
|
||||
// 卸载取消检查、拆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]);
|
||||
/** 新尝试先check并hold,在共享锁仍持有时prepare;释放共享锁后由迁移器申请独占锁。
|
||||
* pending复用原记录resume,其它情况按rootId重新准备,不能把每次点击都变成新的迁移请求。 */
|
||||
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);
|
||||
// 工作区已有另一根pending时禁止挤占;同一根结果未知则继续原请求查询,不重复做初始准备。
|
||||
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});
|
||||
// start独占阶段再次check/prepare/check,覆盖共享锁释放到独占锁取得之间可能出现的旧写入。
|
||||
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;
|
||||
// 只有确认applied或已迁移才通知外层重开;拒绝/未知保留记录,交finally重新决定是否能恢复旧编辑。
|
||||
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);}}
|
||||
// 不论尝试结果都清重入标记;仅当前身份有效才更新UI,并从持久记录重新判断是否仍需hold。
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
// pending表示本页在迁移范围内;other表示工作区另一画布待确认,按钮禁用但不篡改其任务。
|
||||
const pending=migrationBlocksCanvas(job,props.canvasId),other=job?.phase==='pending'&&!pending;
|
||||
if(!props.context.rootId&&!pending&&!error&&!props.held)return null;
|
||||
// 入口依次呈现检查中/执行中/外部变更重开/开始或继续迁移;旧撤销记录不迁移,提示写在按钮title。
|
||||
// 本页被hold时提供备份;外部变化后的重开仍须check及canLeave,不能直接丢弃尚未同步的内存内容。
|
||||
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,57 @@
|
||||
/**
|
||||
* 模型读取前缀的只读预览。直接把已校验document投影给ReactFlow,允许浏览视口,不创建可写engine或临时history/ledger。
|
||||
* 这个组件不负责持久化前缀;后续可编辑partial engine的建立与交接由路由及runtime处理。
|
||||
*/
|
||||
"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';
|
||||
|
||||
/** 已知类型缺少专用preview renderer时展示准备占位;保留已投影连线需要的Handle,禁止新连接。 */
|
||||
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>;}
|
||||
/** 所有预览命令统一拒绝为model_loading,避免复用的renderer意外修改数据。 */
|
||||
const reject=()=>({status:'rejected' as const,code:'model_loading',targets:[]});
|
||||
/** 冻结命令接口仅提供拒绝结果;inert与ReactFlow只读设置之外,命令层再提供一道限制。 */
|
||||
const commands:CanvasRendererCommands=Object.freeze({dispatch:reject,transaction:reject});
|
||||
/** 首次存在节点且视口初始化后fitView一次,限制最大缩放为1,并把结果交给外层用于视图交接。 */
|
||||
function InitialPreviewViewport({onViewport,nodeCount}:{onViewport(viewport:Viewport):void;nodeCount:number}){
|
||||
// fitted是本组件挂载期标记;清理只禁止迟到通知,不取消已经发起的fitView,也不重置fitted。
|
||||
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. */
|
||||
/** renderer catalog限制加载期可用渲染路径;无法解释引用关系时沿用opaque占位,不能猜测缺失内容。 */
|
||||
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]);
|
||||
// 对已收到文档做只读投影;从现有边补齐各节点Handle描述,不推断尚未读取到的连线。
|
||||
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);}
|
||||
// 节点禁用拖动/选择/焦点/连接及指针,边禁用选择/焦点;已知组件声明assetId资源字段才设置预览URL。
|
||||
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]);
|
||||
// 普通rendererId优先使用专用preview,否则节点PreparingNode、边UnsupportedEdge;节点再套inert隔离内部控件。
|
||||
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]);
|
||||
// 仍允许拖动或滚动平移,关闭滚轮缩放和Delete删除;onMoveEnd记录视口,进度只显示读取数量。
|
||||
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>;
|
||||
}
|
||||
@@ -1,31 +1,46 @@
|
||||
/**
|
||||
* 文本、脚本、图片卡片共用的节点组件。组合标题、内容、通用变换容器与四向连线入口。
|
||||
* 编辑通过作用域动作接口发出,不直接写文档;旧连线的无名连接点仍保留用于兼容定位。
|
||||
*/
|
||||
"use client";
|
||||
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";
|
||||
|
||||
/** 方向字符串到ReactFlow几何方位的映射,与nodeDirections的稳定Handle ID配套。 */
|
||||
const positions = { top: Position.Top, right: Position.Right, bottom: Position.Bottom, left: Position.Left };
|
||||
/** 四向连接入口的中文可访问名称,不参与端点数据编码。 */
|
||||
const labels = { top: "上方", right: "右侧", bottom: "下方", left: "左侧" };
|
||||
|
||||
/** 按kind选择图片或文本编辑器,并显示对应标题图标;selected影响样式及连接操作入口。
|
||||
* 指针点击连接点可打开分支菜单,拖动则留给ReactFlow创建连接。 */
|
||||
export function MaterialNode(props: NodeProps<CanvasNode>) {
|
||||
const { id, data, selected } = props;
|
||||
const editing = useContext(NodeEditing);
|
||||
// Headless目标携带元素生命周期与归属;旧渲染回调经过作用域检查,避免迟到事件编辑已替换的对象。
|
||||
const editing = useScopedNodeEditing(id,data.__headlessTarget);
|
||||
const { zoom } = useViewport();
|
||||
// 以卡片宽度(默认260)×视口缩放×设备像素比估算显示成本;1024以内优先预览源。
|
||||
const preview = (props.width || 260) * zoom * (typeof window === "undefined" ? 1 : window.devicePixelRatio || 1) <= 1024;
|
||||
// 仅记录连接点按下的屏幕坐标,用小于5px的位移区分点击与拖动;不把它写进文档或history。
|
||||
const pointerStart = useRef({ x: 0, y: 0 });
|
||||
const Icon = data.kind === "image" ? ImageIcon : data.kind === "script" ? FileText : Type;
|
||||
// 旧target/source Handle禁用新连接但保留定位;四向Handle使用方向作ID,选中时允许开始连线。
|
||||
// 指针点击还检查branchable能力和位移;键盘Enter/空格使用Handle中心打开菜单,由动作层再处理合法性。
|
||||
// 点击/按键停止冒泡,避免分支操作同时触发节点或画布交互;文本内容的编辑生命周期由子组件负责。
|
||||
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,70 @@
|
||||
/**
|
||||
* 节点组件与画布控制器之间的动作上下文。Provider实现文本、名称、样式、几何、分区及编辑模式修改。
|
||||
* useScopedNodeEditing为新版渲染回调追加目标身份/归属检查,旧版无Overlay上下文时沿用原接口。
|
||||
* 本模块不拥有文档、history或队列;它只把界面动作交给正确的控制器。
|
||||
*/
|
||||
"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';
|
||||
/** 默认动作为空实现,便于节点展示独立挂载;可选手势钩子兼容旧版调用与新版EditingLease。
|
||||
* rotating/resizing是Provider提供的当前模式标记,workspace用于构造导航,schema区分适配路径。 */
|
||||
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/select/edit分别供编辑会话、选择与正文更新使用;branch取得屏幕坐标以定位分支菜单。
|
||||
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 }) => {},
|
||||
// 传元素id开启相应模式,null请求退出;是否确属当前元素在作用域包装中进一步检查。
|
||||
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. */
|
||||
/** 捕获本次渲染目标的incarnation、ownerCanvasId与视图canvasId,再为每次动作读取最新状态。
|
||||
* 捕获值代表界面当时展示的对象,最新值用来拒绝过期回调;不能只凭相同元素id就认为仍是同一对象。 */
|
||||
export function useScopedNodeEditing(id:string,rendered?:RenderedTarget){
|
||||
// 只有rendered.id匹配当前节点才采用incarnation;有新版scope却缺少合法rendered身份时拒绝编辑。
|
||||
const editing=useContext(NodeEditing),scope=useContext(CanvasOverlays),incarnation=rendered?.id===id?rendered.incarnation:undefined,owner=rendered?.ownerCanvasId,canvas=rendered?.canvasId;
|
||||
// 无新版scope直接返回旧接口;否则只在接口、视图作用域或目标身份变化时重建包装。
|
||||
return useMemo(()=>{if(!scope)return editing;
|
||||
// 动作当刻检查engine未关闭、视图仍存在且仍在原canvas、元素生命周期与owner未变。
|
||||
// candidate必须是本节点(或允许的空参数),阻止一个节点的包装替其它目标发起操作。
|
||||
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);};
|
||||
// finish/cancel没有id参数,额外确认当前手势属于本视图,且目标列表含本id/incarnation。
|
||||
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);},
|
||||
// 退出模式用null表示,但只准退出本节点当前拥有的rotate/resize模式,避免关闭其它节点的模式。
|
||||
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);},
|
||||
};
|
||||
// 保持可选钩子的存在性:失效时beginTransform返回false,其余动作静默忽略。
|
||||
// begin取得的租约由创建者管理;这里不把旧事件转给当前另一个手势。
|
||||
if(editing.beginTransform)scoped.beginTransform=candidate=>current(candidate)?editing.beginTransform!(candidate):false;
|
||||
if(editing.endEditing)scoped.endEditing=candidate=>{if(current(candidate))editing.endEditing!(candidate);};
|
||||
// 结束手势要同时通过对象有效性与手势归属检查,防止迟到的pointerup结束新交互。
|
||||
if(editing.finishTransform)scoped.finishTransform=()=>{if(current()&&ownGesture())editing.finishTransform!();};
|
||||
if(editing.cancelTransform)scoped.cancelTransform=()=>{if(current()&&ownGesture())editing.cancelTransform!();};
|
||||
// 包装只负责入口检查;事务合法性、持久化和失败报告仍由Provider对应的动作实现负责。
|
||||
return scoped;
|
||||
},[editing,scope,id,incarnation,owner,canvas]);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* ReactFlow节点内部几何更新的Hook适配。将同一个store内的更新交给共享批处理器合并,卸载时释放节点租约。
|
||||
*/
|
||||
"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.
|
||||
/** WeakMap按实际store的getState函数区分画布;不按projectId共享,避免多个视图误用另一视图的更新器。 */
|
||||
const batches=new WeakMap<object,ReturnType<typeof createNodeInternalsBatch>>();
|
||||
/** 返回稳定的invalidate回调;注册尚未建立或已卸载时调用无效,不直接触发布局读取。 */
|
||||
export function useNodeInternalsUpdate(id:string){
|
||||
// useStoreApi的包装对象可能每次不同,稳定getState函数作为store身份;update交给批处理器实际刷新。
|
||||
const scope=useStoreApi().getState,update=useUpdateNodeInternals();
|
||||
const lease=useRef<ReturnType<ReturnType<typeof createNodeInternalsBatch>['acquire']>|undefined>(undefined);
|
||||
// 首个节点创建store级batch,每次挂载取得本id的owner;清理仅释放该owner。
|
||||
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]);
|
||||
// 回调通过ref读取当前租约,避免调用方保存旧闭包后继续刷新已卸载节点。
|
||||
return useCallback(()=>lease.current?.invalidate(),[]);
|
||||
}
|
||||
@@ -1,24 +1,43 @@
|
||||
/**
|
||||
* 选中节点的样式浮层。把颜色、文字排版、背景与旋转/拉伸模式转换成作用域编辑动作,不在此保存样式副本。
|
||||
*/
|
||||
"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";
|
||||
|
||||
/** NodeToolbar负责定位,AnimatePresence负责选中/取消选中时的进出动画,减少动态效果时关闭位移与时长。
|
||||
* 浮层注册到overlay索引,保证Portal中的输入也能归属于正确视图与元素。 */
|
||||
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 || {};
|
||||
// 只有text/script/plain-text展示文字排版;图片不展示前景/背景色,图标可调颜色但不展示拉伸开关。
|
||||
const text = data.kind === "text" || data.kind === "script" || data.kind === "plain-text";
|
||||
/** 统一样式动作入口;字号作为独立可选参数,避免把它混进textStyle的字段空间。 */
|
||||
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="元素样式"
|
||||
// 颜色输入用现有值或默认色;字号仅提交8—160范围,透明背景在取色器中显示白色但不自动写回模型。
|
||||
// 加粗/斜体/下划线按当前值取反,对齐和行高直接提交所选值;清除背景明确写transparent。
|
||||
// 常规事件停止冒泡;Escape只在非组合输入、非文本编辑且未被处理时关闭当前节点的变换模式。
|
||||
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) => {
|
||||
// 其它按键留给当前控件并阻断全局快捷键;Escape让输入上下文先决定是否可用于退出模式。
|
||||
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 +48,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,18 +1,28 @@
|
||||
/**
|
||||
* 节点标题的局部编辑交互。展示态与输入态切换,提交时调用rename;本地value只是输入草稿。
|
||||
*/
|
||||
"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);
|
||||
/** 双击先选择节点再编辑,键盘F2/Enter也可进入;选中时标题才加入正常Tab顺序。
|
||||
* renderedTarget把后续rename绑定到当前渲染的元素生命周期与位置。 */
|
||||
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);
|
||||
// cancelled用于Escape触发blur时抑制保存;input用于进入编辑后聚焦并选中全部文字。
|
||||
const input = useRef<HTMLInputElement>(null), cancelled = useRef(false);
|
||||
// 展示态跟随外部标题,输入态保留当前草稿;失去选择会退出编辑,进入编辑则集中处理焦点。
|
||||
useEffect(() => { if (!editing) setValue(title); }, [title, editing]);
|
||||
useEffect(() => { if (!selected) setEditing(false); }, [selected]);
|
||||
useEffect(() => { if (editing) { input.current?.focus(); input.current?.select(); } }, [editing]);
|
||||
/** blur是统一提交入口:忽略取消、空名称与无变化名称,随后复位取消标记并退出输入态。 */
|
||||
function save() {
|
||||
if (!cancelled.current) { const name = value.trim(); if (name && name !== title) actions.rename(id, name); }
|
||||
cancelled.current = false; setEditing(false);
|
||||
}
|
||||
// Enter在非输入法组合态触发blur保存,Escape先置取消标记再blur;按键不继续冒泡到画布。
|
||||
// 标题长度限制300字符,nodrag/nopan/nowheel避免文本操作被当成画布手势。
|
||||
if (editing) return <input ref={input} className={`${styles.nodeTitleInput} nodrag nopan nowheel`} value={value} maxLength={300} aria-label="节点名称" onChange={(event) => setValue(event.target.value)} onBlur={save}
|
||||
onKeyDown={(event) => { event.stopPropagation(); if (event.key === "Enter" && !event.nativeEvent.isComposing) event.currentTarget.blur(); if (event.key === "Escape") { cancelled.current = true; event.currentTarget.blur(); } }} />;
|
||||
return <span className={styles.nodeTitle} title="双击编辑名称" role="button" tabIndex={selected ? 0 : -1} onDoubleClick={(event) => { event.preventDefault(); event.stopPropagation(); actions.select(id); setEditing(true); }}
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
/**
|
||||
* 无卡片标题的图片节点。用通用变换容器承载图片,随视口缩放选择预览或正式资源地址。
|
||||
*/
|
||||
"use client";
|
||||
import { CanvasImage } from "./canvas-image";
|
||||
import { managedImageSource } from "@/lib/canvas-v2";
|
||||
@@ -5,8 +8,12 @@ import { useViewport, type NodeProps } from "@xyflow/react";
|
||||
import type { CanvasNode } from "@/lib/canvas";
|
||||
import { TransformableNode } from "./transformable-node";
|
||||
import styles from "./canvas.module.css";
|
||||
/** 依赖视口zoom估算图片在屏幕上的像素宽度;CanvasImage负责本地预览与正式图片的显示交接。 */
|
||||
export function PlainImageNode(props: NodeProps<CanvasNode>) {
|
||||
const { zoom } = useViewport();
|
||||
// 节点宽度未提供时按320估算,再乘zoom与设备像素比;不超过1024时请求预览源。
|
||||
// 这是显示宽度启发式,不检查图片原始尺寸;无window时像素比按1处理。
|
||||
const preview = (props.width || 320) * zoom * (typeof window === "undefined" ? 1 : window.devicePixelRatio || 1) <= 1024;
|
||||
// managedImageSource按资源格式选择可用地址,移动/缩放等编辑行为仍由TransformableNode处理。
|
||||
return <TransformableNode {...props} className={styles.plainImageFrame}><CanvasImage id={props.id} className={styles.plainImage} src={managedImageSource(props.data.src, preview)} title={props.data.title} /></TransformableNode>;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
/**
|
||||
* 纯文本节点的显示装配。外层复用节点变换与选择交互,内层使用文本编辑器的plain模式;不在此持有第二份文本数据。
|
||||
*/
|
||||
"use client";
|
||||
|
||||
import type { NodeProps } from "@xyflow/react";
|
||||
@@ -5,6 +8,7 @@ import type { CanvasNode } from "@/lib/canvas";
|
||||
import { TransformableNode } from "./transformable-node";
|
||||
import { CanvasTextEditor } from "./canvas-text-editor";
|
||||
|
||||
/** NodeProps整体交给TransformableNode;文本编辑器取得同一个id/data/selected,plain决定其纯文本呈现方式。 */
|
||||
export function PlainTextNode(props: NodeProps<CanvasNode>) {
|
||||
return (
|
||||
<TransformableNode {...props}>
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* ReactFlow适配层的保留标记缓存。为需要保留的节点/边复制一份带__endoleapRetain的投影记录,原对象不变。
|
||||
* 标记供应用固定版本的编译扩展使用,不是模型属性,也不应进入history、存储或业务renderer数据。
|
||||
*/
|
||||
/** Adapter-only flags consumed by the app's pinned React Flow compiler extension.
|
||||
* They never enter the document, history, persistence or renderer business data. */
|
||||
/** 每个投影使用者创建一份缓存:WeakMap复用同一原对象的带标记副本,previous复用最近一次整个输出。 */
|
||||
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;
|
||||
/** 输入数组和保留ID集合生成节点/边数组;ids为空时直接返回原数组并清除整批缓存。 */
|
||||
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};}
|
||||
// 数组引用及ID成员都相同才复用;快照复制ids,避免调用方之后原地修改Set影响比较基准。
|
||||
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;
|
||||
};
|
||||
// 非空集合下会映射两张数组,即使没有命中也生成数组;下一次相同输入再复用该result。
|
||||
const result={nodes:nodes.map(retain),edges:edges.map(retain)};
|
||||
previous={nodes,edges,ids:new Set(ids),result};return result;
|
||||
};
|
||||
}
|
||||
@@ -1,5 +1,11 @@
|
||||
/**
|
||||
* 将节点文本数据转换为React内联CSS的纯函数。统一卡片与纯文本的默认外观,不修改原数据或编辑状态。
|
||||
*/
|
||||
import type { CSSProperties } from "react";
|
||||
import type { CanvasNodeData } from "@/lib/canvas-elements";
|
||||
/** 字号优先data.fontSize,纯文本默认24、卡片默认13;布尔样式映射为明确的CSS值。
|
||||
* 字体家族只识别serif/mono,其它继承外层;颜色、背景、对齐与行高使用各自默认值。
|
||||
* 使用||回退,因此0和空字符串也走默认;这里是显示映射,不承担schema校验。 */
|
||||
export function textAppearance(data: CanvasNodeData): CSSProperties {
|
||||
const style = data.textStyle || {};
|
||||
return { fontSize: data.fontSize || (data.kind === "plain-text" ? 24 : 13), color: style.color || "#37352f", fontWeight: style.bold ? 700 : 400, fontStyle: style.italic ? "italic" : "normal", textDecoration: style.underline ? "underline" : "none", textAlign: style.align || "left", lineHeight: style.lineHeight || 1.5,
|
||||
|
||||
@@ -1,83 +1,118 @@
|
||||
/**
|
||||
* 节点共用的几何交互容器:绘制旋转后的内容、选择样式、拉伸手柄及模式提示,协调一次手势的预览/提交/取消。
|
||||
* 拖动节点位置仍由外层ReactFlow处理;此处主要处理旋转与尺寸变化,通过NodeEditing而非直接写文档。
|
||||
*/
|
||||
"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";
|
||||
/** 保存手势起始节点快照与画布坐标,防止每帧以已预览结果为基准累积误差;pointerId/capture限定事件所有权。
|
||||
* 新版lease绑定具体交互,旧版则使用上下文的begin/transform/finish/cancel适配。 */
|
||||
type Gesture = { lease?: EditingLease<NodeTransform>; node: CanvasNode; start: XYPosition; direction: ResizeDirection | "rotate"; capture: HTMLElement; pointerId: number };
|
||||
|
||||
/** children决定内容显示,hideStyleToolbar允许分区使用专属设置栏;className补充类型外观。
|
||||
* 能否缩放/旋转由elementCapabilities和当前选中/模式共同决定,不在每个节点组件重复实现。 */
|
||||
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);
|
||||
// 用ref让只挂载一次的window blur监听调用最新cancelGesture;失焦和卸载均取消尚未结束的交互。
|
||||
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]);
|
||||
// 几何、旋转或选中变化后请求批量更新ReactFlow连接点内部数据,避免端点仍留在旧位置。
|
||||
useEffect(() => { update(); }, [id, rotation, width, height, selected, update]);
|
||||
// 仅在变换模式启用时监听外部点击与Escape;退出/切换模式拆除监听。
|
||||
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); } };
|
||||
/** 按统一输入路由判断Escape是否归工具,尊重文本/组合输入与已处理事件;认领后取消手势并退出模式。 */
|
||||
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);
|
||||
// false表示动作层拒绝启动;缺少专用beginTransform时回退旧版begin,随后按需选中节点。
|
||||
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();
|
||||
}
|
||||
/** 只接受持有捕获的指针,把屏幕坐标变为画布坐标,再基于起始快照算旋转或尺寸变化。
|
||||
* 旋转Shift吸附由geometry函数处理;八向拉伸模式把Shift传入比例约束,普通角落拖动沿用默认行为。 */
|
||||
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);
|
||||
// 新版只预览当前lease,旧适配发transform;历史单元何时发布由Provider控制。
|
||||
if (gesture.current!.lease) gesture.current!.lease!.preview(change); else editing.transform(id, change);
|
||||
}
|
||||
function cancelGesture() {
|
||||
/** 无事件参数可由失焦/卸载强制取消;有事件时先校验捕获归属,避免其它指针干扰。
|
||||
* 优先lease.cancel,其次cancelTransform;最旧接口缺少取消钩子时把起始几何写回,最后释放捕获。 */
|
||||
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);
|
||||
}
|
||||
// 容器的CSS zoom使用viewScale,宽高同时除以viewScale,保持投影尺寸与内部显示坐标对应。
|
||||
// schema=2给纯文本/纯图片补四向连接点,非分区显示成员徽标;普通尺寸调整用右下角,模式内显示八向手柄。
|
||||
// 八向手柄的尺寸与边框反向抵消zoom和viewScale,使屏幕上大小稳定;方向决定位置及cursor。
|
||||
return <>
|
||||
{!props.hideStyleToolbar && <NodeStyleToolbar {...props} />}
|
||||
<div ref={frame} className={`${styles.transformFrame} ${selected ? styles.selectedFrame : ""} ${plain ? styles.plainFrame : ""} ${rotating ? styles.rotatingFrame + " nodrag nopan" : ""} ${className}`}
|
||||
@@ -94,15 +129,18 @@ export function TransformableNode(props: NodeProps<CanvasNode> & { children: Rea
|
||||
<Grip size={14} />
|
||||
</button>}
|
||||
{resizing && (["top-left", "top", "top-right", "right", "bottom-right", "bottom", "bottom-left", "left"] as const).map((direction) => {
|
||||
// 从方向名推导0%/50%/100%锚点:边中点只占一个方向,角点同时包含横纵方向。
|
||||
const left = direction.includes("left") ? "0%" : direction.includes("right") ? "100%" : "50%";
|
||||
const top = direction.includes("top") ? "0%" : direction.includes("bottom") ? "100%" : "50%";
|
||||
return <button type="button" key={direction} className={`${styles.resizeHandle} nodrag nopan`} data-resize-handle={direction} aria-label={`拉伸 ${direction}`} title="拖动拉伸 · Shift 固定比例" style={{ left, top, width: 10 / (zoom * (data.viewScale || 1)), height: 10 / (zoom * (data.viewScale || 1)), borderWidth: 1 / (zoom * (data.viewScale || 1)), cursor: direction === "top" || direction === "bottom" ? "ns-resize" : direction === "left" || direction === "right" ? "ew-resize" : direction === "top-left" || direction === "bottom-right" ? "nwse-resize" : "nesw-resize" }}
|
||||
onPointerDown={(event) => start(event, direction)} onPointerMove={move} onPointerUp={finish} onPointerCancel={cancelGesture}
|
||||
onKeyDown={(event) => {
|
||||
// 键盘每次方向键形成一次完整变换:默认一个viewScale单位,Ctrl/Meta放大十倍,Shift固定比例。
|
||||
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 +149,8 @@ export function TransformableNode(props: NodeProps<CanvasNode> & { children: Rea
|
||||
</>;
|
||||
}
|
||||
|
||||
export function ResizeToggle({ id }: { id: string }) {
|
||||
const editing = useContext(NodeEditing);
|
||||
/** 受控拉伸模式开关;aria-pressed反映当前节点是否处于模式,data标记供外部点击判定排除自身。 */
|
||||
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,32 @@
|
||||
/**
|
||||
* 兼容占位组件:显示可理解的公共几何与说明文字,不解析未知类型的业务payload。
|
||||
* 原始数据由模型保留;这些组件只提供占位绘制,不执行数据迁移,也不自行决定外层是否允许移动或选择。
|
||||
*/
|
||||
"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";
|
||||
/** 将投影提供的四边方位转换成ReactFlow Handle要求的枚举。 */
|
||||
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. */
|
||||
/** 使用投影已提供的标题、旋转和fallback元数据绘制占位节点,尺寸填满外层节点容器。
|
||||
* renderer-unavailable明确表示当前缺少显示实现,其它情形提示原内容保留。
|
||||
* 仍绘制已知连接点供现有连线定位,但禁止从这些Handle发起或接收新连接;不推断未知内容字段。 */
|
||||
export function UnsupportedNode({data,selected}:NodeProps<ProjectionNode>){
|
||||
// fallback来自bindings投影;缺失时仍显示通用提示,不尝试从data猜测组件结构。
|
||||
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>;
|
||||
}
|
||||
/** 直接使用ReactFlow推导的端点生成贝塞尔路径,附可访问标题;不调用未知类型的编辑/样式逻辑。
|
||||
* BaseEdge绘制统一颜色的可见线条,数据保留与版本兼容仍归模型层负责。 */
|
||||
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,10 +1,16 @@
|
||||
/**
|
||||
* 旧画布树的AI任务跟踪Hook。发现根及后代画布的活动任务,按事件游标增量读取,协调结果刷新与本地冲突显示。
|
||||
* 此路径使用内存Map/Set和定时轮询,不具备新版runtime ledger的持久请求恢复;卸载会停止本Hook跟踪。
|
||||
*/
|
||||
"use client";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { api } from "@/lib/api";
|
||||
import { CANVAS_ACCEPT } from "@/lib/canvas-v2";
|
||||
import { canvasPath } from "@/lib/canvas-sync";
|
||||
import type { useCanvasTree } from "./use-canvas-tree";
|
||||
/** 服务端模型目录条目:输入/输出模态、可用性与可选参数schema;本Hook只传递模型选择。 */
|
||||
export type CanvasModel = { id: string; label?: string; display_name?: string; output_modalities: string[]; input_modalities: string[]; available: boolean; unavailable_reason?: string; parameters_schema?: unknown };
|
||||
/** 旧AI接口的任务分页结果;next_seq/has_more跟踪事件页,steps/outputs/events供面板和活动连线显示。 */
|
||||
export type AIRun = {
|
||||
run_id: string; board_id: string; status: string; next_seq: number; has_more?: boolean; error?: string;
|
||||
steps?: { step_id: string; board_id: string; node_id: string; status: string; error?: string }[];
|
||||
@@ -12,7 +18,9 @@ export type AIRun = {
|
||||
events?: { seq: number; type: string; board_id?: string; node_id?: string; logical_edge_id?: string; canvas_version?: number; output?: { output_id: string; text?: string; src?: string } }[];
|
||||
applied_versions?: Record<string, number>;
|
||||
};
|
||||
/** 只有queued/running视为执行中;终态任务仍可能因has_more继续拉取剩余事件。 */
|
||||
const active = (status: string) => status === "queued" || status === "running";
|
||||
/** enabled仅控制画布AI模式显示;live才启用服务请求。tree通过ref保持最新,轮询effect按作用域重建。 */
|
||||
export function useCanvasAI(workspace: string, root: string, live: boolean, tree: ReturnType<typeof useCanvasTree>) {
|
||||
const [enabled, setEnabled] = useState(false);
|
||||
const [runs, setRuns] = useState(new Map<string, AIRun>());
|
||||
@@ -21,16 +29,22 @@ export function useCanvasAI(workspace: string, root: string, live: boolean, tree
|
||||
const [activeEdges, setActiveEdges] = useState(new Set<string>());
|
||||
const reference = useRef(tree); reference.current = tree;
|
||||
const wake = useRef<() => void>(() => {});
|
||||
// cache是跟踪结果缓存,runs是发布给React的副本;每个run有自己的活动边集合,合并后供连线动画使用。
|
||||
const cache = useRef(new Map<string, AIRun>());
|
||||
const edgeStates = useRef(new Map<string, Set<string>>());
|
||||
// 每个board只保留最高待刷版本,避免同一批step.applied触发多次重复整画布读取。
|
||||
const pendingRefresh = useRef(new Map<string, number>());
|
||||
/** 发布新的Map/Set引用触发React更新;不把可变ref集合原样暴露成受控state。 */
|
||||
const changed = () => { setRuns(new Map(cache.current)); setActiveEdges(new Set([...edgeStates.current.values()].flatMap((values) => [...values]))); };
|
||||
// 单个effect内polling防并发轮询,dirty合并UI发布;AbortController统一中止这次作用域的读取。
|
||||
useEffect(() => {
|
||||
if (!live) return;
|
||||
let alive = true, timer: ReturnType<typeof setTimeout> | undefined;
|
||||
let polling = false, dirty = false, nextDiscovery = 0;
|
||||
const controller = new AbortController();
|
||||
/** 所有发现/分页GET共享取消信号并协商旧v2响应格式。 */
|
||||
const request = <T,>(path: string) => api<T>(path, { signal: controller.signal, headers: { Accept: CANVAS_ACCEPT } });
|
||||
/** 分页发现根和后代活动run,每页50;已跟踪run不覆盖,重复cursor判为协议异常避免无限循环。 */
|
||||
async function discover() {
|
||||
let cursor = ""; const seen = new Set<string>();
|
||||
do {
|
||||
@@ -39,7 +53,9 @@ export function useCanvasAI(workspace: string, root: string, live: boolean, tree
|
||||
cursor = page.next_cursor || ""; if (seen.has(cursor)) throw new Error("invalid_run_cursor"); seen.add(cursor);
|
||||
} while (cursor && alive);
|
||||
}
|
||||
/** 是否仍需较快跟踪,包含终态但事件尚未读完的任务。 */
|
||||
const hasActive = () => [...cache.current.values()].some((run) => active(run.status) || run.has_more);
|
||||
/** 隐藏页面延后30秒;可见时串行发现、读事件、刷新已应用画布,再按活跃程度安排下一次。 */
|
||||
async function poll() {
|
||||
if (!alive || polling) return;
|
||||
if (document.visibilityState === "hidden") { timer = setTimeout(poll, 30000); return; }
|
||||
@@ -49,9 +65,11 @@ export function useCanvasAI(workspace: string, root: string, live: boolean, tree
|
||||
for (const [id, previous] of cache.current) {
|
||||
if (!active(previous.status) && !previous.has_more) continue;
|
||||
let current = previous;
|
||||
// 每页100事件,游标必须安全且不倒退,has_more时必须前进;每run最多保留最近1000条事件。
|
||||
do {
|
||||
const page = await request<Omit<AIRun, "board_id">>(canvasPath(workspace, previous.board_id) + `/ai/runs/${id}?after_seq=${current.next_seq}&limit=100`);
|
||||
if (!Number.isSafeInteger(page.next_seq) || page.next_seq < current.next_seq || page.has_more && page.next_seq === current.next_seq) throw new Error("invalid_run_cursor");
|
||||
// edge.active加入动画集合,completed/failed/skipped移除;任务终态清空剩余活动边。
|
||||
const edges = edgeStates.current.get(id) || new Set<string>();
|
||||
for (const event of page.events || []) {
|
||||
if (event.logical_edge_id) { if (event.type === "edge.active") edges.add(event.logical_edge_id); else if (["edge.completed", "edge.failed", "edge.skipped"].includes(event.type)) edges.delete(event.logical_edge_id); }
|
||||
@@ -63,6 +81,7 @@ export function useCanvasAI(workspace: string, root: string, live: boolean, tree
|
||||
cache.current.set(id, current);
|
||||
} while (current.has_more && alive);
|
||||
}
|
||||
// 等树不忙且不在本地事务中才刷新;版本冲突记录到conflicts,不直接用生成快照覆盖本地草稿。
|
||||
for (const [board, version] of pendingRefresh.current) {
|
||||
if (reference.current.busy || reference.current.transactionBusy) break;
|
||||
const writer = reference.current.writers.current.get(board);
|
||||
@@ -79,27 +98,34 @@ export function useCanvasAI(workspace: string, root: string, live: boolean, tree
|
||||
polling = false;
|
||||
if (alive) timer = setTimeout(poll, hasActive() || pendingRefresh.current.size ? 1200 : 30000);
|
||||
}
|
||||
/** 被唤醒时清计时器并强制下一次重新发现;已有poll运行时由其收尾继续调度。 */
|
||||
const resume = () => { if (timer) clearTimeout(timer); nextDiscovery = 0; void poll(); };
|
||||
wake.current = resume;
|
||||
/** 页面重新可见时立即唤醒,避免继续等待后台长间隔。 */
|
||||
const visible = () => { if (document.visibilityState === "visible") resume(); };
|
||||
document.addEventListener("visibilitychange", visible);
|
||||
void poll();
|
||||
// effect清理中止GET、定时器和可见性监听,wake退为空实现;此处不清除缓存或自动取消服务端任务。
|
||||
return () => { alive = false; wake.current = () => {}; controller.abort(); if (timer) clearTimeout(timer); document.removeEventListener("visibilitychange", visible); };
|
||||
}, [workspace, root, live]);
|
||||
/** 真实画布经tree.command启动任务,先插queued占位供UI显示,再唤醒跟踪;演示模式明确拒绝。 */
|
||||
async function run(boardId: string, node: string, model: string, prompt: string, propagate: boolean) {
|
||||
if (!live) throw new Error("demo_ai_unavailable");
|
||||
const result = await reference.current.command(boardId, "ai/runs", { node_id: node, model_id: model, prompt, parameters: {}, propagate }, undefined, false);
|
||||
const id = result.run_id as string; cache.current.set(id, { run_id: id, board_id: boardId, status: "queued", next_seq: 0, steps: [{ step_id: "pending", board_id: boardId, node_id: node, status: "queued" }] }); changed(); wake.current();
|
||||
}
|
||||
/** 以新幂等键发送取消命令后继续查询终态;暂记queued用于保持轮询,不表示服务端重新执行任务。 */
|
||||
async function cancel(run: AIRun) {
|
||||
await api(canvasPath(workspace, run.board_id) + `/ai/runs/${run.run_id}/cancel`, { method: "POST", headers: { Accept: CANVAS_ACCEPT, "Idempotency-Key": crypto.randomUUID() }, body: "{}" });
|
||||
const next = cache.current.get(run.run_id); if (next) { next.status = "queued"; } wake.current(); // Poll canonical terminal state, including already applied steps.
|
||||
}
|
||||
/** 先prepareApply协调目标本地状态,再发显式采用命令;成功后移除冲突提示并继续跟踪服务端最终状态。 */
|
||||
async function apply(run: AIRun, node: string, output: string, targetBoard: string) {
|
||||
await reference.current.prepareApply(targetBoard, node);
|
||||
await reference.current.command(run.board_id, `ai/runs/${run.run_id}/apply`, { node_id: node, output_id: output }, undefined, false, targetBoard);
|
||||
setConflicts((current) => { const next = new Set(current); next.delete(targetBoard); return next; });
|
||||
const previous = cache.current.get(run.run_id); if (previous) { previous.status = "queued"; changed(); } wake.current();
|
||||
}
|
||||
// 对外只暴露显示状态和三个动作;调用者不直接修改cache、分页游标或刷新等待表。
|
||||
return { enabled, setEnabled, runs, conflicts, activeEdges, error, run, cancel, apply, live };
|
||||
}
|
||||
|
||||
@@ -1,17 +1,26 @@
|
||||
/**
|
||||
* 旧版单画布的自动保存Hook:短延迟备份本地草稿,较长延迟把最新快照交给CanvasSync。
|
||||
* 它使用旧快照writer,不是新版冻结请求ledger;本地recovery记录也不是可靠保存文件字节的资源仓库。
|
||||
*/
|
||||
"use client";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { api } from "@/lib/api";
|
||||
import { canvasFingerprint, canvasPath, CanvasSync, type CanvasSnapshot, type SyncState } from "@/lib/canvas-sync";
|
||||
import { decodeCanvas, type CanvasDocument } from "@/lib/canvas";
|
||||
|
||||
/** snapshot存在才建立远端writer,无snapshot仍可本地备份;writer在挂载时初始化一次,调用方应按画布重挂载。
|
||||
* key区分草稿作用域,paused阻断这里的新备份/入队,不在此取消已交给writer的请求。 */
|
||||
export function useCanvasPersistence(document: CanvasDocument, workspace: string, board: string, key: string, snapshot: CanvasSnapshot | undefined, paused: boolean) {
|
||||
const [sync, setSync] = useState<SyncState>({ status: "saved", version: snapshot?.version || 1 });
|
||||
// alive限制writer通知更新已卸载组件;latest和pausedRef让异步回调读取当前文档/暂停值。
|
||||
const alive = useRef(false);
|
||||
const latest = useRef(document); latest.current = document;
|
||||
const pausedRef = useRef(paused); pausedRef.current = paused;
|
||||
// 同一挂载复用writer;状态变化时刷新sync并补本地备份,不随每次文档渲染新建同步器。
|
||||
const [writer] = useState(() => snapshot ? new CanvasSync(api, canvasPath(workspace, board), snapshot, (next) => {
|
||||
if (alive.current) { setSync(next); backup(); }
|
||||
}) : null);
|
||||
/** 保存规范化document、writer当前版本及内容指纹dirty;回调路径吞掉存储异常,定时effect路径会展示错误。 */
|
||||
function backup() {
|
||||
if (pausedRef.current) return;
|
||||
try {
|
||||
@@ -19,6 +28,7 @@ export function useCanvasPersistence(document: CanvasDocument, workspace: string
|
||||
localStorage.setItem(key + ":recovery", JSON.stringify({ document: decodeCanvas(JSON.stringify(current)), baseVersion: writer?.version || 1, dirty: writer ? canvasFingerprint(current) !== writer.saved : false }));
|
||||
} catch { /* Storage failure is surfaced by the effect below. */ }
|
||||
}
|
||||
// 文档变化重置250ms本地/800ms远端计时器,形成防抖;暂停不建立计时器,清理只取消尚未触发的任务。
|
||||
useEffect(() => {
|
||||
if (paused) return;
|
||||
const local = setTimeout(() => {
|
||||
@@ -28,13 +38,16 @@ export function useCanvasPersistence(document: CanvasDocument, workspace: string
|
||||
const remote = writer ? setTimeout(() => void writer.enqueue(latest.current), 800) : null;
|
||||
return () => { clearTimeout(local); if (remote) clearTimeout(remote); };
|
||||
}, [document, writer, key, paused]);
|
||||
// pagehide与卸载只执行同步本地backup,不保证远端请求在离页前完成;卸载后不再接收writer的UI通知。
|
||||
useEffect(() => {
|
||||
alive.current = true;
|
||||
/** 页面隐藏/离开时尽量保存当前内存文档,暂停状态由backup内部再次检查。 */
|
||||
const flush = () => backup();
|
||||
window.addEventListener("pagehide", flush);
|
||||
return () => { window.removeEventListener("pagehide", flush); backup(); alive.current = false; };
|
||||
// Writer and key are stable for this board's mounted editor.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [writer, key]);
|
||||
// retry先补本地备份再委托writer重试;没有远端writer时返回undefined,不伪造同步成功。
|
||||
return { ...sync, retry: () => { backup(); return writer?.retry(); } };
|
||||
}
|
||||
|
||||
@@ -1,61 +1,107 @@
|
||||
/**
|
||||
* 旧版画布树的状态与操作协调Hook。documents按boardId保存已加载快照,views把嵌套子画布投影为当前ReactFlow场景。
|
||||
* 每个board有独立CanvasSync writer,本地历史和服务端结构事务共用两个栈,成员迁移另用串行内存队列。
|
||||
* 该实现保留旧快照协议与本地草稿恢复,不等同于新版engine的统一提交根、持久ledger和恢复状态机。
|
||||
* 调用方应按根/工作区身份重挂载;refs提供异步操作读取的最新状态,React state负责发布界面变化。
|
||||
*/
|
||||
"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";
|
||||
|
||||
/** 命令返回的快照额外携带board_id,用于把多画布结果安装回各自writer。 */
|
||||
type OwnedSnapshot = CanvasSnapshot & { board_id: string };
|
||||
/** 兼容多个旧命令端点的结果包络,可能返回父/子/多快照、事务身份、归档列表和版本;各使用路径再解码快照。 */
|
||||
export type CanvasCommandResult = { transaction_id?: string; parent?: OwnedSnapshot; child?: OwnedSnapshot; snapshots?: OwnedSnapshot[]; children?: OwnedSnapshot[]; canvas_version?: number; applied_versions?: Record<string, number>; archived_board_ids?: string[]; undo_receipt?: { expires_at: string }; [key: string]: unknown };
|
||||
/** 本地历史保存操作前整份画布,可附关联画布;pendingMove标记尚未上传图片的跨画布移动,便于后来按服务端归属撤销。 */
|
||||
type LocalHistory = { boardId: string; before: CanvasDocument; related?: Map<string, CanvasDocument>; pendingMove?: { id: string; source: string; target: string } };
|
||||
/** 服务端结构事务历史保存可重放的路径/负载、事务ID和参与画布;撤销请求服务端undo,重做重新执行原命令。 */
|
||||
type TransactionHistory = { boardId: string; path: string; payload: Record<string, unknown>; id: string; participants: string[] };
|
||||
/** 两种历史条目共用undo/redo栈,通过是否有id字段区分;不是每次pointermove都直接创建条目。 */
|
||||
type Entry = LocalHistory | TransactionHistory;
|
||||
/** 成员归属迁移的FIFO任务;run在准备后捕获固定body/key供不确定失败重试,refresh表示确定冲突后需刷新基线。 */
|
||||
type MemberJob = { source: string; target: string; ids: string[]; parent: string; frame: string; participants: string[]; error?: string; refresh?: boolean; run?: () => Promise<void>; resolve: () => void };
|
||||
/** 只为一次reducer运算建立临时空历史,借其past是否变化判断该动作是否应进入本Hook自己的历史栈。 */
|
||||
const history = (document: CanvasDocument): CanvasHistory => ({ past: [], present: document, future: [] });
|
||||
|
||||
export function useCanvasTree(root: string, workspace: string, initial: CanvasDocument, snapshot?: CanvasSnapshot, paused = false, parentBoardId?: string) {
|
||||
/** initial建立根画布,snapshot决定是否连接服务端writer;paused阻止保存/独占事务等准入,migration额外限制旧写入。
|
||||
* parentBoardId用于补读祖先快照;导出、跨分区移动和事务可能按需加载更多画布。 */
|
||||
export function useCanvasTree(root: string, workspace: string, initial: CanvasDocument, snapshot?: CanvasSnapshot, paused = false, parentBoardId?: string, migration?: {accountId:string;isCurrentIdentity():boolean}) {
|
||||
// 有迁移上下文时先关旧编辑入口,等迁移协调组件取得共享租约再开放;flush只有迁移准备阶段临时启用。
|
||||
const migrationGate=useRef(!!migration);
|
||||
const migrationFlush=useRef(false);
|
||||
const [migrationHeld,setMigrationState]=useState(!!migration);
|
||||
/** 同步检查门闩和账号身份,事件处理无需等待React的migrationHeld重新渲染。 */
|
||||
const migrationBlocked=()=>migrationGate.current||!!migration&&!migration.isCurrentIdentity();
|
||||
/** 同时更新同步门闩与界面状态;只更新state会留下同一事件轮次中的旧写入窗口。 */
|
||||
function setMigrationHeld(value:boolean){migrationGate.current=value;setMigrationState(value);}
|
||||
/** 旧接口请求护栏:await前后检查账号,写请求受迁移门闩限制;迁移排空时只放行canvas PATCH。
|
||||
* X-Expected-Account要求代理校验目标账号,避免切号后旧请求误用新会话。 */
|
||||
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;
|
||||
};
|
||||
// documents是已加载board的模型表,current立即跟随发布供异步回调读取;status/error是旧writer汇总显示值。
|
||||
const [documents, setDocuments] = useState(new Map([[root, initial]]));
|
||||
const current = useRef(documents); current.current = documents;
|
||||
const [status, setStatus] = useState("saved");
|
||||
const [error, setError] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [memberJobs, setMemberJobs] = useState<MemberJob[]>([]);
|
||||
// capability缓存查询Promise;members是实际内存队列,memberJobs只是供React显示的数组副本。
|
||||
const memberCapability = useRef<Promise<boolean> | null>(null);
|
||||
const members = useRef<MemberJob[]>([]), memberRunning = useRef(false), dragging = useRef(false);
|
||||
/** 仅挂载期间发布队列副本;任务对象仍共享引用,修改error等字段后需显式通知。 */
|
||||
const showMembers = () => { if (mounted.current) setMemberJobs([...members.current]); };
|
||||
/** 拖动期间延后自动保存,结束时发布新Map触发防抖重新评估;不在这里执行成员归属变化。 */
|
||||
function setDragging(value: boolean) { dragging.current = value; if (!value) publish(new Map(current.current)); }
|
||||
/** 成员任务涉及的节点暂不能再被普通动作改动;队列非空时也保护子图入口几何及结构,避免请求基准漂移。 */
|
||||
function protectedNodes(boardId: string) {
|
||||
const ids = new Set(members.current.flatMap((job) => [job.source, job.target].includes(boardId) ? job.ids : []));
|
||||
if (members.current.length) for (const node of current.current.get(boardId)?.nodes || []) if (node.data.subgraph) ids.add(node.id);
|
||||
return ids;
|
||||
}
|
||||
const [pendingCommand, setPendingCommand] = useState(false);
|
||||
// 不确定命令失败时保留同一execute闭包,pendingCommand驱动UI;locked只管当前独占异步操作。
|
||||
const retryOperation = useRef<(() => Promise<CanvasCommandResult>) | null>(null);
|
||||
const locked = useRef(false), mounted = useRef(true), pause = useRef(paused); pause.current = paused;
|
||||
// writer持每画布ACK基线与保存队列,loading去重子画布读取;二者都不是documents的替代存储。
|
||||
const writers = useRef(new Map<string, CanvasSync>());
|
||||
const loading = useRef(new Set<string>());
|
||||
const [retryLoad, setRetryLoad] = useState(0);
|
||||
const [recoveries, setRecoveries] = useState(new Map<string, CanvasDocument>());
|
||||
const pendingRecovery = useRef(recoveries); pendingRecovery.current = recoveries;
|
||||
/** 以当前服务端快照筛选有内容差异的本地恢复候选,按board记录,等待用户决定;不证明可安全覆盖模型。 */
|
||||
function inspectRecovery(boardId: string, value: CanvasSnapshot) {
|
||||
const local = readLocalRecovery(localStorage, `endoleap_canvas_v1:${workspace}:${boardId}`, value);
|
||||
if (local) { const next = new Map(pendingRecovery.current).set(boardId, local); pendingRecovery.current = next; setRecoveries(next); }
|
||||
}
|
||||
/** 可编辑时按选择替换本地文档或采用现状,移除恢复候选和草稿记录;远端写入仍走后续writer。 */
|
||||
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);
|
||||
localStorage.removeItem(`endoleap_canvas_v1:${workspace}:${boardId}:recovery`);
|
||||
}
|
||||
// 两个ref数组作为历史栈,boardAliases记录重做子图创建后新旧boardID对应;tick用于仅栈变化时通知UI。
|
||||
const undo = useRef<Entry[]>([]), redo = useRef<Entry[]>([]);
|
||||
const boardAliases = useRef(new Map<string, string>());
|
||||
const [, tick] = useState(0);
|
||||
// 根据根与documents派生投影索引,nodeRefs/edgeRefs把场景ID映射回所属board、本地ID和坐标变换。
|
||||
const views = useMemo(() => projectCanvasTree(root, documents), [root, documents]);
|
||||
const view = useRef(views); view.current = views;
|
||||
/** 先更新同步ref,再在挂载期间发布React state;等待中的操作可立即读取新Map。 */
|
||||
function publish(next: Map<string, CanvasDocument>) { current.current = next; if (mounted.current) setDocuments(next); }
|
||||
/** 将服务端快照作为writer基线:已有writer采用新ACK,否则在真实快照模式建立writer;这里只安装,不发保存。 */
|
||||
function install(boardId: string, value: CanvasSnapshot) {
|
||||
const writer = writers.current.get(boardId);
|
||||
if (writer) writer.adopt(value);
|
||||
@@ -67,25 +113,33 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo
|
||||
// Initialization never writes a snapshot to the server.
|
||||
if (snapshot && !writers.current.has(root)) install(root, snapshot);
|
||||
useEffect(() => { mounted.current = true; return () => { mounted.current = false; }; }, []);
|
||||
/** 普通单画布动作入口:检查门闩,经reducer计算新文档,保留成员迁移保护字段,再按需要记历史并发布。 */
|
||||
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);
|
||||
// 迁移中的普通节点只允许选中变化;未作为移动目标的子图入口保留原几何、subgraph/ports/presentation。
|
||||
// reducer删掉的受保护节点也补回,避免异步成员请求到来时对象已被本地删除。
|
||||
if (protectedIDs.size) result.present = { ...result.present, nodes: result.present.nodes.map((node) => {
|
||||
const prior = before.nodes.find((item) => item.id === node.id);
|
||||
if (!prior || !protectedIDs.has(node.id)) return node;
|
||||
const moving = members.current.some((job) => [job.source, job.target].includes(boardId) && job.ids.includes(node.id));
|
||||
return prior.data.subgraph && !moving ? { ...node, position: prior.position, width: prior.width, height: prior.height, data: { ...node.data, subgraph: prior.data.subgraph, ports: prior.data.ports, presentation: prior.data.presentation } } : { ...prior, selected: node.selected };
|
||||
}).concat(before.nodes.filter((node) => protectedIDs.has(node.id) && !result.present.nodes.some((item) => item.id === node.id))) };
|
||||
// reducer产生历史才入undo,普通更新路径最多留40项并清redo;其它事务/刷新路径不一定使用同一截断逻辑。
|
||||
if (result.past.length) { undo.current.push({ boardId, before }); undo.current = undo.current.slice(-40); redo.current = []; }
|
||||
publish(new Map(current.current).set(boardId, result.present));
|
||||
};
|
||||
/** 默认对根发动作,也允许调用方显式指定已加载board。 */
|
||||
const dispatch = (action: CanvasAction, boardId = root) => update(boardId, action);
|
||||
/** 从投影节点ID解析实际owner,再用原始节点ID构造动作,避免把场景复合ID写回快照。 */
|
||||
const nodeAction = (id: string, action: (id: string) => CanvasAction) => { const ref = view.current.nodeRefs.get(id); if (ref) update(ref.boardId, action(ref.id)); };
|
||||
/** 按owner分组ReactFlow节点变化;忽略add/replace,将场景位置和尺寸还原为所属board局部坐标。 */
|
||||
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>[]>();
|
||||
// 同批父分区也在移动时先计算调整后的原点,避免对子节点重复施加父位移;尺寸再除投影scale。
|
||||
const moves = new Map(changes.flatMap((change) => change.type === "position" && change.position ? [[change.id, change.position] as const] : []));
|
||||
for (const change of changes) {
|
||||
if (change.type === "add" || change.type === "replace") continue;
|
||||
@@ -93,6 +147,7 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo
|
||||
const adjusted = referenceAfterMoves(root, current.current, view.current.boards, ref.boardId, moves);
|
||||
const next = { ...change, id: ref.id };
|
||||
if (next.type === "position" && next.position) next.position = { x: (next.position.x - adjusted.origin.x) / ref.scale, y: (next.position.y - adjusted.origin.y) / ref.scale };
|
||||
// ResizeObserver只更新测量信息;非显式resizing不得把测量宽高当模型尺寸写回。
|
||||
if (next.type === "dimensions") {
|
||||
// ResizeObserver measurements are editor state. Only an explicit resize
|
||||
// gesture may write node dimensions back to the owning snapshot.
|
||||
@@ -103,6 +158,7 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo
|
||||
}
|
||||
for (const [boardId, changes] of groups) update(boardId, { type: "nodes", changes });
|
||||
};
|
||||
/** 按edgeRefs分组选择/删除等变化,忽略add/replace;最终update仍统一检查写入准入。 */
|
||||
const edgesChanged = (changes: EdgeChange[]) => {
|
||||
const groups = new Map<string, EdgeChange[]>();
|
||||
for (const change of changes) {
|
||||
@@ -111,13 +167,17 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo
|
||||
}
|
||||
for (const [boardId, changes] of groups) update(boardId, { type: "edges", changes });
|
||||
};
|
||||
/** 曲线路径从场景坐标反变换回owner局部空间,再派发edge-data;transient交reducer决定历史语义。 */
|
||||
const patchEdge = (id: string, data: EdgeData, transient = false) => {
|
||||
const ref = view.current.edgeRefs.get(id); if (ref) update(ref.boardId, { type: "edge-data", id: ref.id, data: transformEdgeData(data, ref, true)!, transient });
|
||||
};
|
||||
/** 遍历已加载文档,只有目标owner/localID被选中,其余节点取消选择;不直接操作投影数组。 */
|
||||
const select = (id: string) => {
|
||||
for (const [boardId, document] of current.current) update(boardId, { type: "nodes", changes: document.nodes.map((node) => ({ type: "select", id: node.id, selected: view.current.nodeRefs.get(id)?.boardId === boardId && view.current.nodeRefs.get(id)?.id === node.id })) });
|
||||
};
|
||||
/** 读取并解码一个board的旧v2快照,可传取消信号;api层仍检查迁移账号身份。 */
|
||||
const read = async (boardId: string, signal?: AbortSignal) => decodeSnapshot(await api<CanvasSnapshot>(canvasPath(workspace, boardId), { headers: { Accept: CANVAS_ACCEPT }, signal }));
|
||||
/** 查询服务端是否支持成员迁移同时提交坐标;演示为false,成功结果复用,失败清缓存供按需重试。 */
|
||||
function supportsMemberPositions(): Promise<boolean> {
|
||||
if (!snapshot) return Promise.resolve(false);
|
||||
if (!memberCapability.current) {
|
||||
@@ -128,9 +188,11 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo
|
||||
}
|
||||
return memberCapability.current;
|
||||
}
|
||||
// 能力预取失败不视作确定不支持,真正成员操作仍可重新请求。
|
||||
useEffect(() => {
|
||||
void supportsMemberPositions().catch(() => { /* A failed prefetch is retried on demand, never cached as unsupported. */ });
|
||||
}, [root, workspace, !!snapshot]);
|
||||
/** 按需加载真实快照或演示本地草稿,装writer并检查恢复候选;recursive递归加载子图,祖先集合防环且限制深度。 */
|
||||
async function ensure(boardId: string, recursive = false, ancestors = new Set<string>()): Promise<void> {
|
||||
if (ancestors.has(boardId) || ancestors.size > 8) throw new Error("subgraph_cycle");
|
||||
let document = current.current.get(boardId);
|
||||
@@ -140,14 +202,17 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo
|
||||
}
|
||||
if (recursive) { const next = new Set(ancestors); next.add(boardId); for (const node of document.nodes) if (node.data.subgraph) await ensure(node.data.subgraph.boardId, true, next); }
|
||||
}
|
||||
/** 独占期间完整加载根下可达子图,然后收集导出Map;不会把已加载但不可达的祖先或其它分支混入导出。 */
|
||||
async function exportDocuments() {
|
||||
return exclusive(async () => {
|
||||
await ensure(root, true);
|
||||
const reachable = new Map<string, CanvasDocument>();
|
||||
/** 可达集合兼作去重;ensure已验证递归关系,此处只遍历当前已加载文档。 */
|
||||
const collect = (id: string) => { const document = current.current.get(id)!; reachable.set(id, document); for (const node of document.nodes) if (node.data.subgraph && !reachable.has(node.data.subgraph.boardId)) collect(node.data.subgraph.boardId); };
|
||||
collect(root); return reachable;
|
||||
});
|
||||
}
|
||||
// 打开子画布时向上读取元数据与快照,补齐祖先关系;alive阻止离页后的采用,不在本effect中abort请求。
|
||||
useEffect(() => {
|
||||
if (!snapshot || !parentBoardId) return;
|
||||
let alive = true;
|
||||
@@ -165,6 +230,7 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo
|
||||
// Root identity is stable until this editor unmounts.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [parentBoardId, root, workspace]);
|
||||
// 场景可见且未折叠的子图按需异步读取,loading去重;回包时重新确认仍被当前文档引用。
|
||||
useEffect(() => {
|
||||
for (const [boardId, document] of documents) for (const node of document.nodes) {
|
||||
if (!view.current.boards.has(boardId)) continue;
|
||||
@@ -179,9 +245,12 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo
|
||||
// Values and writers are scoped to this mounted root/workspace.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [documents, retryLoad]);
|
||||
// 文档变化防抖:250ms本地备份、800ms远端保存。恢复待决画布跳过,防止覆盖用户尚未选择的草稿。
|
||||
// 远端另等独占锁、命令重试、成员任务和拖动结束;本地备份不需要等这些普通操作全部结束。
|
||||
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 +258,16 @@ 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]);
|
||||
// pagehide/卸载尽量做本地同步备份,不等待远端flush;暂停/迁移仍禁止写,存储满时保留已有记录。
|
||||
useEffect(() => {
|
||||
/** 遍历最新内存文档,备份规范化payload与ACK版本/dirty;页面离开时错误不再触发界面更新。 */
|
||||
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. */ }
|
||||
@@ -205,6 +276,8 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo
|
||||
window.addEventListener("pagehide", backup);
|
||||
return () => { window.removeEventListener("pagehide", backup); backup(); };
|
||||
}, [workspace]);
|
||||
/** 串行排空各writer,任一恢复待决或writer错误就失败;deferMemberPositions保留待移动节点原ACK坐标。
|
||||
* 支持原子坐标的成员接口会在归属事务里提交新位置,避免先把新位置单独写入旧owner。 */
|
||||
async function flush(deferMemberPositions = false) {
|
||||
for (const [boardId, document] of current.current) {
|
||||
if (pendingRecovery.current.has(boardId)) throw new Error("draft_recovery_required");
|
||||
@@ -214,15 +287,50 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo
|
||||
await writer.enqueue(saving); await writer.settled(); if (writer.error) throw new Error(writer.error);
|
||||
}
|
||||
}
|
||||
/** 迁移前必须已关门,无事务/成员/拖动/加载,且恢复已处理;加载整棵迁移树并核查上传占位。
|
||||
* 先备份本页,再临时放行PATCH排空旧writer;所有writer确认无待保存内容后把备份标为clean。 */
|
||||
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}));
|
||||
}
|
||||
// 只在flush的try/finally窗口放开旧PATCH,其它写命令仍被api护栏拒绝。
|
||||
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}));
|
||||
}
|
||||
}
|
||||
/** 旧版内存独占执行器,阻断冲突准入并发布busy;异常记error后继续抛出,finally可靠释放锁。
|
||||
* 它不是跨标签页锁,也不提供操作的整批服务端回滚。 */
|
||||
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); }
|
||||
}
|
||||
/** 服务端快照更新时恢复本地选中标记,避免结构事务结果抹掉当前视图选择。 */
|
||||
function keepSelection(document: CanvasDocument, local?: CanvasDocument): CanvasDocument {
|
||||
return { ...document, nodes: document.nodes.map((node) => ({ ...node, selected: local?.nodes.find((item) => item.id === node.id)?.selected })), edges: document.edges.map((edge) => ({ ...edge, selected: local?.edges.find((item) => item.id === edge.id)?.selected })) };
|
||||
}
|
||||
/** 汇总命令返回的各类快照,按boardID去重,基于旧ACK做三方合并再安装新基线。 */
|
||||
function accept(result: CanvasCommandResult) {
|
||||
const next = new Map(current.current);
|
||||
const values = [...new Map([...(result.parent ? [result.parent] : []), ...(result.child ? [result.child] : []), ...(result.snapshots || []), ...(result.children || [])].map((value)=>[value.board_id,value])).values()];
|
||||
@@ -231,6 +339,8 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo
|
||||
const merged=local&&base?mergeCanvasDocuments(base,local,decoded.document):decoded.document;
|
||||
install(value.board_id,decoded);next.set(value.board_id,keepSelection(merged,local));
|
||||
}
|
||||
// 子图归档后,将仍在内存上传的节点及相关边投到父画布,避免服务端快照中没有的占位被丢弃。
|
||||
// 其余已归档文档及writer从当前表移除,不在这里删除上传任务的原文件。
|
||||
for (const id of result.archived_board_ids || []) {
|
||||
const local=current.current.get(id);
|
||||
const owner=[...current.current].find(([,doc])=>doc.nodes.some((node)=>node.data.subgraph?.boardId===id));
|
||||
@@ -245,6 +355,8 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo
|
||||
publish(next);
|
||||
return [...new Set(values.map((value) => value.board_id))];
|
||||
}
|
||||
/** 结构/AI等旧命令入口:加载参与writer、flush,再冻结expected_versions、If-Match和幂等key。
|
||||
* execute闭包在不确定失败后复用同一body/key/version;这些恢复信息只在内存,不保证刷新后继续。 */
|
||||
async function command(boardId: string, path: string, payload: Record<string, unknown>, participants?: string[], record = true, versionBoard = boardId) {
|
||||
if (retryOperation.current) throw new Error("canvas_busy");
|
||||
return exclusive(async () => {
|
||||
@@ -254,21 +366,25 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo
|
||||
await flush();
|
||||
const body = { ...payload, ...(participants ? { expected_versions: Object.fromEntries(participants.map((id) => [id, writers.current.get(id)?.version])) } : {}) };
|
||||
const key = crypto.randomUUID(), version = writers.current.get(versionBoard)?.version || 1;
|
||||
/** 发送、采用回执、按需补读目标快照,并把有transaction_id的结果记入服务端事务历史。 */
|
||||
const execute = async (): Promise<CanvasCommandResult> => {
|
||||
try {
|
||||
const result = await api<CanvasCommandResult>(canvasPath(workspace, boardId) + "/" + path, { method: "POST", headers: { Accept: CANVAS_ACCEPT, "If-Match": `"${version}"`, "Idempotency-Key": key }, body: JSON.stringify(body) });
|
||||
const changed = accept(result);
|
||||
// 改变子图/事务后通知侧栏刷新目录;没有快照但有版本结果时补读并保留期间的本地编辑。
|
||||
if (path.startsWith("subgraphs") || path.startsWith("transactions/")) window.dispatchEvent(new window.CustomEvent("endoleap:board-directory-changed", { detail: { workspace } }));
|
||||
if (!changed.length && result.canvas_version) {
|
||||
const target=typeof result.board_id === "string"?result.board_id:boardId,remote=await read(target),local=current.current.get(target),base=writers.current.get(target)?.document;
|
||||
const merged=base&&local?mergeCanvasDocuments(base,local,remote.document):remote.document;
|
||||
install(target,remote);publish(new Map(current.current).set(target,keepSelection(merged,local)));
|
||||
}
|
||||
// record=false用于undo/redo等已有历史流程,避免将历史遍历再次当新操作压栈。
|
||||
if (record && result.transaction_id) { undo.current.push({ boardId, path, payload, id: result.transaction_id, participants: changed }); redo.current = []; tick((v) => v + 1); }
|
||||
retryOperation.current = null; setPendingCommand(false); setError("");
|
||||
return result;
|
||||
} catch (error) {
|
||||
const code = error instanceof Error ? error.message : "request_failed";
|
||||
// 网络/上游/解码等不确定失败保留execute并阻断普通编辑;确定拒绝清重试入口,错误仍交调用方处理。
|
||||
if (error instanceof TypeError || error instanceof ApiError && error.status >= 500 || ["upstream_timeout", "upstream_unavailable", "request_failed", "Failed to fetch", "internal_error", "invalid_canvas_snapshot"].includes(code)) { retryOperation.current = execute; setPendingCommand(true); }
|
||||
else { retryOperation.current = null; setPendingCommand(false); }
|
||||
throw error;
|
||||
@@ -279,12 +395,15 @@ 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.
|
||||
/** 成员队列只跑队首;归属移动期间暂停autosave,允许不相关本地编辑,结果按捕获ACK合并。
|
||||
* 只有成功才shift并resolve;失败任务留队首供重试,Promise不在失败时假装完成。 */
|
||||
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) {
|
||||
for (const id of job.participants) if (!writers.current.has(id)) await ensure(id);
|
||||
// 412等确定冲突后的重新准备:读参与快照,先三方合并本地变化,再更新各writer基线。
|
||||
if (job.refresh) {
|
||||
const refreshed = await Promise.all(job.participants.map(async (id) => [id, await read(id)] as const));
|
||||
const next = new Map(current.current);
|
||||
@@ -295,6 +414,7 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo
|
||||
for (const [id, remote] of refreshed) install(id, remote);
|
||||
publish(next); job.refresh = false;
|
||||
}
|
||||
// 支持坐标时flush延后待移动节点位置;否则先按旧保存行为排空,再捕获参与文档作为合并基线。
|
||||
const atomic = await supportsMemberPositions();
|
||||
await flush(atomic === true);
|
||||
const bases = new Map(job.participants.map((id) => [id, writers.current.get(id)!.document]));
|
||||
@@ -303,17 +423,22 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo
|
||||
const node = source.nodes.find((node) => node.id === id); if (!node) throw new Error("invalid_reference");
|
||||
return [id, node.position];
|
||||
}));
|
||||
// 请求内容含参与版本和可选局部坐标;创建job.run后重试不得换body或幂等key,除非确定原请求未应用。
|
||||
const body = JSON.stringify({ source_board_id: job.source, target_board_id: job.target, node_ids: job.ids, ...(atomic ? { positions } : {}),
|
||||
expected_versions: Object.fromEntries(job.participants.map((id) => [id, writers.current.get(id)!.version])) });
|
||||
const key = crypto.randomUUID(), historyIndex = undo.current.length;
|
||||
/** 一次准备生成的可重试闭包;要求回执覆盖所有参与画布,不能凭缺失快照移动本地owner。 */
|
||||
job.run = async () => {
|
||||
const result = await api<CanvasCommandResult>(canvasPath(workspace, job.parent) + `/subgraphs/${job.frame}/members`, {
|
||||
method: "POST", headers: { Accept: CANVAS_ACCEPT, "Idempotency-Key": key }, body });
|
||||
const snapshots = [...(result.parent ? [result.parent] : []), ...(result.snapshots || [])];
|
||||
const values = new Map(snapshots.map((value) => [value.board_id, { ...decodeSnapshot(value), board_id: value.board_id }]));
|
||||
if (!job.participants.every((id) => values.has(id))) throw new Error("invalid_canvas_snapshot");
|
||||
/** 移动结果已含新坐标,合并前只把源节点暂存位置还原到基线,避免重复施加坐标变化。 */
|
||||
const normalize = (id: string, local: CanvasDocument) => id !== job.source ? local : { ...local, nodes: local.nodes.map((node) => job.ids.includes(node.id) ? { ...node, position: bases.get(id)!.nodes.find((item) => item.id === node.id)!.position } : node) };
|
||||
/** 参与画布基于原ACK+归一化本地内容+返回快照重放,未参与文档原样保留。 */
|
||||
const rebaseLocal=(id:string,doc:CanvasDocument)=>values.has(id)?mergeCanvasDocuments(bases.get(id)!,normalize(id,doc),values.get(id)!.document):doc;
|
||||
// 请求期间新增本地历史也基于新owner结果调整;开始前历史和服务端事务条目保持原语义。
|
||||
const rebasedHistory = undo.current.map((entry,index)=>index<historyIndex||"id" in entry?entry:{...entry,before:rebaseLocal(entry.boardId,entry.before),...(entry.related?{related:new Map([...entry.related].map(([id,doc])=>[id,rebaseLocal(id,doc)]))}:{})});
|
||||
const next = new Map(current.current);
|
||||
for (const [id, value] of values) {
|
||||
@@ -328,6 +453,7 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo
|
||||
for (const [id, value] of values) install(id, value);
|
||||
publish(next);
|
||||
undo.current = rebasedHistory;
|
||||
// 将迁移事务插回开始时的historyIndex,保留请求期间后来编辑的顺序,并清除旧redo。
|
||||
if (result.transaction_id) { undo.current.splice(historyIndex, 0, { boardId: job.parent, path: `subgraphs/${job.frame}/members`, payload: { source_board_id: job.source, target_board_id: job.target, node_ids: job.ids, positions }, id: result.transaction_id, participants: [...values.keys()] }); redo.current = []; }
|
||||
};
|
||||
}
|
||||
@@ -337,6 +463,7 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo
|
||||
job.error = error instanceof Error ? error.message : "request_failed";
|
||||
// A definite rejection performed zero writes. A retry may prepare fresh
|
||||
// versions; ambiguous transport failures must retain the exact body/key.
|
||||
// 确定拒绝可清冻结闭包重新准备;412还需刷新版本。不确定传输失败继续保留原job.run。
|
||||
if (error instanceof ApiError && error.status < 500) { job.run = undefined; job.refresh = error.status === 412; }
|
||||
showMembers();
|
||||
} finally {
|
||||
@@ -344,24 +471,33 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo
|
||||
if (members.current.length && !members.current[0].error) void runMembers();
|
||||
}
|
||||
}
|
||||
/** 检查准入和重叠成员任务后入FIFO队列;返回Promise由成功出队时resolve,错误在memberJobs中供UI重试。 */
|
||||
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();
|
||||
});
|
||||
}
|
||||
/** 将上传资源/尺寸绑定到所有当前文档及本地历史,避免撤销恢复旧Blob占位。
|
||||
* 无asset时保留pendingUpload;尺寸仅在纯图片仍为320×240默认值时采用,不覆盖用户已调整大小。 */
|
||||
function resolveImageUpload(id: string, asset?: { url: string; id: string }, size?: {width:number;height:number}) {
|
||||
if(migrationBlocked())return;
|
||||
/** 只匹配pendingUpload身份,移除或保留占位标记,并按文档schema选择是否写assetId。 */
|
||||
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;
|
||||
const {pendingUpload,...ready}=node;
|
||||
return { ...ready, ...(!asset?{pendingUpload}:{}), ...(fitted?{...fitted,style:{...ready.style,...fitted}}:{}), data: { ...ready.data, ...(asset?{src:asset.url,...(document.schema_version===2?{assetId:asset.id}:{})}:{}) } };
|
||||
}) });
|
||||
/** 仅改本地快照历史(含related),服务端事务条目仍由服务端重放解释。 */
|
||||
const rebase = (entry: Entry): Entry => "id" in entry ? entry : { ...entry, before: resolve(entry.before), ...(entry.related ? {related:new Map([...entry.related].map(([id,doc])=>[id,resolve(doc)]))} : {}) };
|
||||
undo.current = undo.current.map(rebase); redo.current = redo.current.map(rebase);
|
||||
publish(new Map([...current.current].map(([board,doc])=>[board,resolve(doc)])));
|
||||
}
|
||||
/** 未上传且无关联边的图片可先在内存跨分区移动;校验容量/ID后按两owner原点和scale换算。
|
||||
* 保存双画布before及pendingMove历史,上传完成后若进入服务端,撤销可能需要真实成员事务。 */
|
||||
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)!;
|
||||
@@ -370,6 +506,7 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo
|
||||
undo.current.push({boardId:source,before:from,related:new Map([[target,to]]),pendingMove:{id,source,target}});redo.current=[];
|
||||
publish(new Map(current.current).set(source,{...from,nodes:from.nodes.filter((node)=>node.id!==id)}).set(target,{...to,nodes:[...to.nodes,moved]}));
|
||||
}
|
||||
/** 独占等待writer停止后读新快照并三方合并;内容指纹改变才记本地历史,再安装新ACK。 */
|
||||
async function refresh(boardId: string) {
|
||||
return exclusive(async () => {
|
||||
const writer = writers.current.get(boardId); if (!writer) return;
|
||||
@@ -380,6 +517,8 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo
|
||||
install(boardId, remote); publish(new Map(current.current).set(boardId, merged));
|
||||
});
|
||||
}
|
||||
/** 显式采用生成结果前,针对目标节点把基线text/src/assetId/ai对齐到远端,再合并其它本地编辑。
|
||||
* 合并结果先排空保存,后续apply命令才能在明确版本上覆盖目标;此方法本身不发送生成结果采用请求。 */
|
||||
async function prepareApply(boardId: string, nodeId: string) {
|
||||
await exclusive(async () => {
|
||||
const writer = writers.current.get(boardId); if (!writer) throw new Error("invalid_reference");
|
||||
@@ -393,12 +532,17 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo
|
||||
setError("");
|
||||
});
|
||||
}
|
||||
/** 查看栈顶并检查准入,成功后才移动栈项;本地条目替换before,事务条目调用服务端undo或重做。
|
||||
* 重做可能创建新子board,使用alias将后续历史引用转到新身份,避免继续操作已归档旧子图。 */
|
||||
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;
|
||||
/** 沿别名链解析最新boardID并防循环;只用于历史重放,不篡改任意元素ID。 */
|
||||
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; };
|
||||
/** 重做本地快照时更新子图board引用,其它节点内容保留。 */
|
||||
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;
|
||||
// 待上传图片若已存在于writer基线,纯本地快照切换已不够,需按归属路径逐跳请求成员移动后再转栈。
|
||||
if (!("id" in entry) && entry.pendingMove) {
|
||||
const move=entry.pendingMove,desired=alias(isRedo?move.target:move.source);
|
||||
const owner=[...current.current].find(([,doc])=>doc.nodes.some((node)=>node.id===move.id));
|
||||
@@ -409,6 +553,7 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo
|
||||
stack.pop();destination.push(entry);tick((v)=>v+1);return;
|
||||
}
|
||||
}
|
||||
// 服务端事务撤销调用transactions/{id}/undo;重做执行原命令并保存新的事务ID/参与快照身份。
|
||||
if ("id" in entry) {
|
||||
if (isRedo) {
|
||||
const result = await command(entry.boardId, entry.path, entry.payload, entry.path === "subgraphs" ? undefined : entry.participants, false);
|
||||
@@ -421,6 +566,7 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo
|
||||
await command(entry.boardId, `transactions/${entry.id}/undo`, {}, entry.participants, false);
|
||||
stack.pop(); destination.push(entry);
|
||||
}
|
||||
// 普通本地历史用当前文档作为反向条目,连同related画布一起发布,保证下一次redo可还原。
|
||||
} else {
|
||||
const before = current.current.get(entry.boardId); if (!before) return;
|
||||
const next=new Map(current.current).set(entry.boardId, entry.before), related=new Map<string,CanvasDocument>();
|
||||
@@ -429,6 +575,9 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo
|
||||
}
|
||||
tick((v) => v + 1);
|
||||
}
|
||||
/** 重试普通writer并触发子图读取effect;不在这里自动重发成员任务或未确认command,它们有各自入口。 */
|
||||
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 };
|
||||
// 对外暴露投影、动作、恢复、事务及writer引用,旧版UI依赖此组合接口;busy合并独占、待确认命令与迁移门闩。
|
||||
// transactionBusy/canUndo/canRedo反映成员队列与栈是否有项,最终undoAction仍再次检查实际准入。
|
||||
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 };
|
||||
}
|
||||
|
||||
@@ -1,28 +1,42 @@
|
||||
/**
|
||||
* 旧编辑器的内存上传任务管理。原文件、Blob预览URL和AbortController随Hook挂载存活,资源上传成功后由commit绑定到本地节点。
|
||||
* 这是可取消的页面内流程,没有持久文件仓库;卸载会中止上传并清理URL,不能承诺跨刷新继续上传。
|
||||
*/
|
||||
"use client";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { api, errorText } from "@/lib/api";
|
||||
import { uploadCanvasImage, type CanvasAsset } from "@/lib/canvas-assets";
|
||||
import type { CanvasDocument, CanvasNode } from "@/lib/canvas";
|
||||
/** draft指定节点归属与可选关联边,调用方负责先创建可编辑占位并正确处理提交时归属。 */
|
||||
export type ImageUploadDraft = { owner: string; node: CanvasNode; edge?: CanvasDocument["edges"][number] };
|
||||
/** file保存原字节、localURL用于即时预览、key用于同任务重试幂等;ready可等待本地图片尺寸解码。 */
|
||||
export type ImageUploadJob = ImageUploadDraft & { file: File; localURL: string; key: string; status: "uploading" | "failed" | "uploaded"; error?: string; controller: AbortController; ready?: Promise<void>; asset?: CanvasAsset };
|
||||
|
||||
/** Uploads attach file references to existing editable local nodes; transient URLs never enter autosaves. */
|
||||
/** commit通过ref读取最新回调;pending按节点ID存任务,jobs数组只作React显示发布。 */
|
||||
export function useImageUploads(workspace: string, commit: (draft: ImageUploadDraft, asset: CanvasAsset) => void) {
|
||||
const pending = useRef(new Map<string, ImageUploadJob>());
|
||||
const [jobs, setJobs] = useState<ImageUploadJob[]>([]);
|
||||
const mounted = useRef(true), onCommit = useRef(commit); onCommit.current = commit;
|
||||
/** 挂载期间发布新的任务数组;任务对象仍是同一引用,字段更新后需调用changed触发显示。 */
|
||||
const changed = () => { if (mounted.current) setJobs([...pending.current.values()]); };
|
||||
/** 中止任务、释放预览URL并移出Map;并不在这里删除文档节点或服务端资源。 */
|
||||
function remove(id: string) { const job = pending.current.get(id); if (!job) return; job.controller.abort(); URL.revokeObjectURL(job.localURL); pending.current.delete(id); changed(); }
|
||||
// 卸载统一abort并回收URL,清Map防止迟到上传结果提交;StrictMode重新挂载effect时复位mounted。
|
||||
useEffect(() => { mounted.current = true; return () => { mounted.current = false; for (const job of pending.current.values()) { job.controller.abort(); URL.revokeObjectURL(job.localURL); } pending.current.clear(); }; }, []);
|
||||
/** uploading状态防并发重试;开始新尝试前换controller并清错误,保留原幂等key。
|
||||
* 上传与ready并行,二者都成功后才commit;commit抛错也会进入failed状态,允许再次处理。 */
|
||||
async function run(job: ImageUploadJob) {
|
||||
if (job.status === "uploading") return;
|
||||
job.status = "uploading"; job.error = undefined; job.controller = new AbortController(); changed();
|
||||
try {
|
||||
const [asset] = await Promise.all([uploadCanvasImage(api, workspace, job.file, job.file.name, job.controller.signal, job.key), job.ready || Promise.resolve()]);
|
||||
// 结果返回后核对挂载、Map内仍是同一个job且未取消,旧任务不能写到后来同ID的新任务。
|
||||
if (!mounted.current || pending.current.get(job.node.id) !== job || job.controller.signal.aborted) return;
|
||||
job.asset = asset; onCommit.current(job, asset); job.status = "uploaded"; changed();
|
||||
} catch (error) { if (mounted.current && pending.current.get(job.node.id) === job && !job.controller.signal.aborted) { job.status = "failed"; job.error = errorText(error); changed(); } }
|
||||
}
|
||||
/** 先创建Blob URL并让调用方建立草稿,草稿构造失败立即释放URL;任务初始failed以便run统一转uploading。 */
|
||||
function start(file: File, draft: (localURL: string) => ImageUploadDraft) {
|
||||
const localURL = URL.createObjectURL(file);
|
||||
let value: ImageUploadDraft;
|
||||
@@ -30,18 +44,23 @@ export function useImageUploads(workspace: string, commit: (draft: ImageUploadDr
|
||||
const job: ImageUploadJob = { ...value, file, localURL, key: crypto.randomUUID(), status: "failed", controller: new AbortController() };
|
||||
pending.current.set(job.node.id, job); changed();
|
||||
// Browser decoding runs locally, concurrently with the original-byte upload.
|
||||
// 纯图片并行解码自然尺寸,限制初始长宽不超过480(不主动放大),再分别设置至少24px的显示尺寸。
|
||||
if (job.node.data.kind === "plain-image") {
|
||||
job.ready = new Promise<void>((resolve,reject) => {
|
||||
const image = new window.Image(); image.onload = () => {
|
||||
if (pending.current.get(job.node.id) !== job) { resolve(); return; }
|
||||
// 分别钳制到最小24可能改变极端长宽比;这里只初始化节点和style尺寸,不重编码上传文件。
|
||||
const scale = Math.min(1,480/image.naturalWidth,480/image.naturalHeight);
|
||||
const size = { width: Math.max(24,Math.round(image.naturalWidth*scale)), height: Math.max(24,Math.round(image.naturalHeight*scale)) };
|
||||
job.node = { ...job.node, ...size, style: { ...job.node.style, ...size } }; changed(); resolve();
|
||||
}; image.onerror = () => reject(new Error("无法读取图片尺寸。")); image.src = localURL;
|
||||
});
|
||||
}
|
||||
// start同步返回任务对象,异步上传由run内部捕获失败,不要求调用方等待才显示占位。
|
||||
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); } };
|
||||
// hasPending包含失败但不包含uploaded,active只看uploading;loaded仅在正式图片加载后移除已上传预览。
|
||||
// retry复用job及幂等键;若ready曾拒绝,重试不会在这里重建尺寸解码Promise。
|
||||
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); } };
|
||||
}
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
/**
|
||||
* 按既有静态资源命名约定展示仪表盘图标的小封装,不负责画布图标库的分片加载或搜索。
|
||||
*/
|
||||
/** name是调用方提供的资源后缀,拼成/assets/dashboardcontext-imgIcon{name}.svg。
|
||||
* size同时设置宽高,默认18px;空alt表示装饰用途,承载动作的外层控件须另有可访问名称。
|
||||
* 此处没有资源白名单校验或加载失败占位,调用方应使用项目已有的图标名。 */
|
||||
export function Icon({ name, size = 18 }: { name: string; size?: number }) {
|
||||
return (
|
||||
<img
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
/**
|
||||
* 公开落地页的客户端展示组件。包含品牌导航、可点击的创作示意图、功能说明、工作方式、FAQ与登录/演示入口。
|
||||
* 示意卡片只改变本页高亮,不创建真实画布元素;本模块不读取账号、不调用编辑器,也不持久化状态。
|
||||
*/
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
@@ -6,7 +10,9 @@ import { ArrowUpRight, ArrowRight, ChevronDown, FileText, Image as ImageIcon, La
|
||||
import { Brand } from "@/components/brand";
|
||||
import styles from "./landing-page.module.css";
|
||||
|
||||
/** 三个示意卡片对应的页脚说明;数组索引与active及预览按钮一一对应。 */
|
||||
const cards = ["先留住一个念头", "让不同的线索相遇", "慢慢成为你的作品"];
|
||||
/** FAQ静态问答数据;这里只呈现文案,访问权限与演示存储的实际执行在对应业务模块中。 */
|
||||
const questions = [
|
||||
["还没想好要做什么,也可以开始吗?", "当然。一个词、一张参考图、一段还不成形的想法,都可以成为起点。先放下来,再慢慢找到它们之间的联系。"],
|
||||
["我可以放些什么在画布里?", "文字、笔记、图片、脚本和图标。你可以自由移动、调整样式、用线连接,也可以用分区把相关内容放在一起。"],
|
||||
@@ -14,9 +20,17 @@ const questions = [
|
||||
["可以先体验,再注册吗?", "可以。点击「先逛逛工作台」进入本地演示。演示内容保留在当前浏览器,登录后可以建立自己的工作空间。"],
|
||||
];
|
||||
|
||||
/** 本页只有两个交互状态:预览卡片单选,以及最多展开一个问题的FAQ。
|
||||
* 导航锚点定位本页三个section;登录入口进入/login,本地演示入口携带demo=1进入工作台。
|
||||
* 主体的SVG连线、花朵、光标和功能插图是展示图形,不接入画布命中、拖动或同步链路。 */
|
||||
export default function LandingPage() {
|
||||
// 初始选中第一张预览卡;三张卡片与底部三个圆点均直接设置同一个索引,aria-pressed同步表达选择。
|
||||
const [active, setActive] = useState(0);
|
||||
// null表示全部收起;点击当前问题置null,点击其它问题改索引。hidden与aria-expanded/controls对应同一状态。
|
||||
const [faq, setFaq] = useState<number | null>(null);
|
||||
// 页面顺序:导航→首屏交互预览→理念横条→三个功能说明→工作步骤→FAQ→行动入口→页脚。
|
||||
// 功能卡与步骤通过静态内容展示;FAQ以问题文本作key,步骤以序号作key,预览以说明作key。
|
||||
// 状态仅在本次组件挂载期间保留;切换预览不自动轮播,FAQ展开不发网络请求。
|
||||
return <div className="landing"><div className={styles.page}>
|
||||
<header className={styles.nav}>
|
||||
<Brand />
|
||||
|
||||
@@ -1,11 +1,19 @@
|
||||
/**
|
||||
* 应用级反馈设施:提供共享Tooltip时间参数与通知挂载点。
|
||||
* 这个模块只负责提示的呈现环境,任务进度、错误事实和通知触发仍由业务调用方管理。
|
||||
*/
|
||||
"use client";
|
||||
|
||||
import { createContext, type ReactNode } from "react";
|
||||
import * as Tooltip from "@radix-ui/react-tooltip";
|
||||
import { Toaster } from "sonner";
|
||||
|
||||
/** 默认false使独立挂载的IconButton退回原生title;只有AppFeedback子树启用统一浮层提示。 */
|
||||
export const FeedbackContext = createContext(false);
|
||||
|
||||
/** 包住页面一次即可共享提示延迟;400ms首次出现,200ms内切换提示可跳过等待。
|
||||
* Toaster放在右下角,最多展示三条通知,默认4500ms消失并允许关闭;颜色读取主题变量。
|
||||
* children正常透传,本组件不订阅文档、队列或服务端状态。 */
|
||||
export function AppFeedback({ children }: { children: ReactNode }) {
|
||||
return <FeedbackContext.Provider value={true}>
|
||||
<Tooltip.Provider delayDuration={400} skipDelayDuration={200}>
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
/**
|
||||
* 通用按钮的样式与属性适配层。variant表达视觉语义,size控制尺寸;点击、禁用和表单行为由调用方传入。
|
||||
* 可通过asChild复用子元素,避免链接外再套按钮;这里不保存业务状态,也不默认指定button的type。
|
||||
*/
|
||||
import * as React from "react";
|
||||
import { Slot } from "@radix-ui/react-slot";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/** 将基础交互样式、语义变体和尺寸变体组合成className;未指定时使用default/default。
|
||||
* focus-visible提供键盘焦点反馈,disabled类限制指针交互并淡化外观,内嵌SVG统一尺寸。 */
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||
{
|
||||
@@ -34,6 +40,7 @@ const buttonVariants = cva(
|
||||
},
|
||||
);
|
||||
|
||||
/** 在原生button属性上增加样式变体和asChild;ref仍按按钮声明,实际宿主由asChild决定。 */
|
||||
export interface ButtonProps
|
||||
extends
|
||||
React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
@@ -41,8 +48,11 @@ export interface ButtonProps
|
||||
asChild?: boolean;
|
||||
}
|
||||
|
||||
/** 转发ref和剩余原生属性,供调用方设置事件、可访问名称、disabled和表单属性。
|
||||
* Slot把属性合并到唯一子元素;使用链接等宿主时,其语义及行为由该子元素承担。 */
|
||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||
// 只选择宿主,不另包一层DOM;cn合并变体和调用方样式,业务逻辑不进入这个封装。
|
||||
const Comp = asChild ? Slot : "button";
|
||||
return (
|
||||
<Comp
|
||||
@@ -53,6 +63,7 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
);
|
||||
},
|
||||
);
|
||||
// forwardRef生成包装组件后补显示名,便于React开发工具定位。
|
||||
Button.displayName = "Button";
|
||||
|
||||
export { Button, buttonVariants };
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
/**
|
||||
* 基于Radix的对话框展示封装:保留其开关、焦点管理和可访问语义,补项目统一的遮罩、布局与动画。
|
||||
* 调用方通过Root的open/onOpenChange控制业务流程;保存中是否允许关闭等规则不由本模块决定。
|
||||
*/
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
@@ -6,14 +10,19 @@ import { X } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/** Root提供受控或非受控开关上下文,本层不再复制一份open状态。 */
|
||||
const Dialog = DialogPrimitive.Root;
|
||||
|
||||
/** Trigger接入同一Root,负责触发打开;可沿用Radix的asChild组合调用方控件。 */
|
||||
const DialogTrigger = DialogPrimitive.Trigger;
|
||||
|
||||
/** Portal把浮层移出页面普通布局,避免受父容器裁切;挂载方式沿用Radix。 */
|
||||
const DialogPortal = DialogPrimitive.Portal;
|
||||
|
||||
/** 暴露关闭原语,供业务在底部动作区等位置放额外关闭入口。 */
|
||||
const DialogClose = DialogPrimitive.Close;
|
||||
|
||||
/** 转发遮罩ref与属性,铺满视口;开关动画由Radix的data-state驱动,className允许调用方补样式。 */
|
||||
const DialogOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
||||
@@ -29,6 +38,9 @@ const DialogOverlay = React.forwardRef<
|
||||
));
|
||||
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
|
||||
|
||||
/** 组合Portal、遮罩与居中内容面板,并在children之后固定附带右上角关闭按钮。
|
||||
* 面板ref和剩余属性交给Radix Content,保留焦点及外部交互事件;关闭按钮有读屏文字。
|
||||
* 本封装没有单独隐藏默认关闭按钮的参数,业务若要阻止关闭应在受控开关/事件层处理。 */
|
||||
const DialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
||||
@@ -53,6 +65,7 @@ const DialogContent = React.forwardRef<
|
||||
));
|
||||
DialogContent.displayName = DialogPrimitive.Content.displayName;
|
||||
|
||||
/** 标题区只组织纵向间距与响应式文字对齐;原生div属性和children原样透传。 */
|
||||
const DialogHeader = ({
|
||||
className,
|
||||
...props
|
||||
@@ -67,6 +80,7 @@ const DialogHeader = ({
|
||||
);
|
||||
DialogHeader.displayName = "DialogHeader";
|
||||
|
||||
/** 动作区在窄屏使用反向纵排,在sm及以上改为靠右横排;不绑定提交或取消动作。 */
|
||||
const DialogFooter = ({
|
||||
className,
|
||||
...props
|
||||
@@ -81,6 +95,7 @@ const DialogFooter = ({
|
||||
);
|
||||
DialogFooter.displayName = "DialogFooter";
|
||||
|
||||
/** 转发Radix标题的ref/属性并统一字号;标题仍参与对话框的可访问命名关联。 */
|
||||
const DialogTitle = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
||||
@@ -96,6 +111,7 @@ const DialogTitle = React.forwardRef<
|
||||
));
|
||||
DialogTitle.displayName = DialogPrimitive.Title.displayName;
|
||||
|
||||
/** 转发Radix说明文字的ref/属性并使用弱化文字样式;说明关联由Radix上下文维护。 */
|
||||
const DialogDescription = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
/**
|
||||
* 画布等工具栏使用的图标按钮。统一按钮尺寸、图标与可访问名称,并按反馈Provider决定使用浮层提示还是原生title。
|
||||
* 按钮的业务动作由onClick等原生属性传入;这里不维护工具选择状态。
|
||||
*/
|
||||
"use client";
|
||||
|
||||
import { FeedbackContext } from "./app-feedback";
|
||||
@@ -6,13 +10,17 @@ import { useContext, forwardRef, type ButtonHTMLAttributes } from "react";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import styles from "./icon-button.module.css";
|
||||
|
||||
/** icon提供Lucide图标实现,label提供默认可访问名称与提示;不接受任意children替换内部图标。 */
|
||||
export type IconButtonProps = Omit<ButtonHTMLAttributes<HTMLButtonElement>, "children"> & {
|
||||
icon: LucideIcon;
|
||||
label: string;
|
||||
};
|
||||
|
||||
/** 将ref交给真实button;type默认为button,避免放进表单时意外提交。
|
||||
* 调用方仍可传aria-pressed、disabled等状态,组件不会自行判断命令是否可用。 */
|
||||
export const IconButton = forwardRef<HTMLButtonElement, IconButtonProps>(
|
||||
function IconButton({ icon: Icon, label, title = label, className = "", type = "button", ...props }, ref) {
|
||||
// Provider存在时由Radix显示提示,移除原生title以免出现两层提示;图标自身对读屏隐藏。
|
||||
const feedback = useContext(FeedbackContext);
|
||||
const button = (
|
||||
<button
|
||||
@@ -26,6 +34,7 @@ export const IconButton = forwardRef<HTMLButtonElement, IconButtonProps>(
|
||||
<Icon size={16} strokeWidth={1.6} aria-hidden="true" />
|
||||
</button>
|
||||
);
|
||||
// 没有全局提示环境就返回原生按钮;空title也不创建Tooltip。提示通过Portal避免被工具栏裁切。
|
||||
if (!feedback || !title) return button;
|
||||
return <Tooltip.Root><Tooltip.Trigger asChild>{button}</Tooltip.Trigger><Tooltip.Portal>
|
||||
<Tooltip.Content className={styles.tooltip} sideOffset={7} collisionPadding={8}>
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
/**
|
||||
* 账户资料编辑与新建工作区共用的客户端对话框。保存后通过回调更新宿主目录/资料,再关闭;
|
||||
* demo分支只构造本地结果,真实分支走profile PATCH或workspaces POST,不由组件直接操作canvas runtime。
|
||||
*/
|
||||
"use client";
|
||||
|
||||
import { useState, type FormEvent } from "react";
|
||||
@@ -7,6 +11,7 @@ import type { Workspace } from "@/lib/workspace";
|
||||
import { UserAvatar } from "./user-avatar";
|
||||
import styles from "./workspace.module.css";
|
||||
|
||||
/** mode决定表单,busy控制当前提交和退出,error用于就地反馈;输入默认值来自打开时profile。 */
|
||||
export function AccountPanel({ mode, profile, demo, onClose, onProfile, onWorkspace }: {
|
||||
mode: "settings" | "workspace";
|
||||
profile: Profile;
|
||||
@@ -20,6 +25,8 @@ export function AccountPanel({ mode, profile, demo, onClose, onProfile, onWorksp
|
||||
const settings = mode === "settings";
|
||||
const name = profile.display_name || profile.username;
|
||||
|
||||
/** 阻止原生导航,捕获FormData后提交;成功先通知宿主再onClose,失败显示错误并恢复按钮。
|
||||
* HTML输入限制是交互提示,服务端仍校验真实资料/空间名称。 */
|
||||
async function submit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
if (busy) return;
|
||||
@@ -27,6 +34,7 @@ export function AccountPanel({ mode, profile, demo, onClose, onProfile, onWorksp
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
// 设置模式只提交display_name/username,避免将整个profile(含只读信息)作为修改payload。
|
||||
if (settings) {
|
||||
const input = { display_name: String(data.get("display_name")).trim(), username: String(data.get("username")).trim() };
|
||||
const updated = demo ? { ...profile, ...input } : await api<Profile>("profile", { method: "PATCH", body: JSON.stringify(input) });
|
||||
@@ -41,6 +49,7 @@ export function AccountPanel({ mode, profile, demo, onClose, onProfile, onWorksp
|
||||
finally { setBusy(false); }
|
||||
}
|
||||
|
||||
// 保存中禁用取消/提交,并拒绝Dialog的关闭状态变化;请求未结束不假装已成功关闭。
|
||||
return (
|
||||
<Dialog open onOpenChange={(open) => { if (!open && !busy) onClose(); }}>
|
||||
<DialogContent className={styles.settingsPanel}>
|
||||
|
||||
@@ -1,20 +1,28 @@
|
||||
/**
|
||||
* 侧栏与画布页面共享的当前board元数据Context。只保存ID、工作区、路由引用与标题,
|
||||
* 不保存元素、加载任务或ProjectEngine;目录改名可更新标题而无需重建业务画布。
|
||||
*/
|
||||
"use client";
|
||||
|
||||
import { createContext, useContext, useMemo, useState, type Dispatch, type ReactNode, type SetStateAction } from "react";
|
||||
import type { Board } from "@/lib/workspace";
|
||||
|
||||
/** null代表当前没有已解析画布,workspace_ref保留路径slug/引用与真实workspace_id的对应关系。 */
|
||||
type ActiveBoard = (Pick<Board, "id" | "workspace_id" | "title" | "slug"> & { workspace_ref?: string }) | null;
|
||||
/** 暴露React状态setter,调用方可条件更新以免迟到改名覆盖另一个活动board。 */
|
||||
const ActiveBoardContext = createContext<{
|
||||
board: ActiveBoard;
|
||||
setBoard: Dispatch<SetStateAction<ActiveBoard>>;
|
||||
} | null>(null);
|
||||
|
||||
/** 在工作台布局内保持元数据,useMemo稳定Context对象,避免只因父渲染就通知全部使用方。 */
|
||||
export function ActiveBoardProvider({ children }: { children: ReactNode }) {
|
||||
const [board, setBoard] = useState<ActiveBoard>(null);
|
||||
const value = useMemo(() => ({ board, setBoard }), [board]);
|
||||
return <ActiveBoardContext.Provider value={value}>{children}</ActiveBoardContext.Provider>;
|
||||
}
|
||||
|
||||
/** 缺少Provider时拒绝读取;不从浏览器路径凭空构造一条已认证board记录。 */
|
||||
export function useActiveBoard() {
|
||||
const context = useContext(ActiveBoardContext);
|
||||
if (!context) throw new Error("useActiveBoard requires ActiveBoardProvider");
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
/**
|
||||
* 侧栏画布树显示组件,使用业务目录库派生结构/搜索路径,只保存哪些目录项被用户折叠。
|
||||
* 不重写board父子归属;活动路径和搜索可临时展开祖先,路由Link负责进入真实画布页面。
|
||||
*/
|
||||
"use client";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import Link from "next/link";
|
||||
@@ -6,13 +10,16 @@ import { activeBoardAncestors, boardDirectory, filterBoardDirectory, type BoardB
|
||||
import { boardHref, matchesRef, type Board } from "@/lib/workspace";
|
||||
import styles from "./workspace.module.css";
|
||||
|
||||
/** branches只随boards/query重算,closed是当前挂载视图局部状态,宿主切工作区可用key重建。 */
|
||||
export function BoardDirectory({ boards, workspace, active, query, demo }: { boards: Board[]; workspace: string; active?: string; query: string; demo: boolean }) {
|
||||
const [closed, setClosed] = useState(new Set<string>());
|
||||
const branches = useMemo(() => filterBoardDirectory(boardDirectory(boards), query), [boards, query]);
|
||||
/** 活动画布或目录刷新时展开其祖先路径,防当前页藏在折叠树下。 */
|
||||
useEffect(() => {
|
||||
const path = activeBoardAncestors(boards, active);
|
||||
setClosed((previous) => { const next = new Set(previous); for (const id of path) next.delete(id); return next; });
|
||||
}, [active, boards]);
|
||||
/** 递归渲染树,视觉缩进最多8层但不裁掉更深子项;搜索时强制展开匹配路径而不清空用户closed记录。 */
|
||||
function render(items: BoardBranch[], depth = 0) {
|
||||
return <ul className={styles.directoryList}>{items.map(({ board, children }) => {
|
||||
const expanded = !!query.trim() || !closed.has(board.id);
|
||||
@@ -22,5 +29,6 @@ export function BoardDirectory({ boards, workspace, active, query, demo }: { boa
|
||||
</div>{expanded && children.length > 0 && render(children, depth + 1)}</li>;
|
||||
})}</ul>;
|
||||
}
|
||||
// 有结果显示树;仅搜索无命中给提示,原始目录空时不伪造一条默认页面。
|
||||
return branches.length ? render(branches) : query ? <p className={styles.hint}>没有找到相关页面</p> : null;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* 应用级Canvas运行时装配点。一次安装模型registry与React renderer目录,按账户/工作区创建runtime,
|
||||
* 按项目root安装Foundry服务适配。组件只装配依赖,业务根、history、ledger和持久化仍由下层实例管理。
|
||||
* 真实宿主卸载才关闭项目;单个画布页面切换只借还视图,不在这里每次重建engine。
|
||||
*/
|
||||
"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";
|
||||
|
||||
/** 应用共享RuntimeHost引用,未装配时为null;renderer Context另有内置显示目录默认值。 */
|
||||
const Context = createContext<RuntimeHost | null>(null);
|
||||
/** 模型定义与显示实现一起在宿主初始化捕获,子页面用此目录保持一致解释。 */
|
||||
const Renderers=createContext(defaultCanvasRenderers);
|
||||
/** 读取宿主安装的React renderer集合,不为每个节点重新构造类型表。 */
|
||||
export function useCanvasRenderers(){return useContext(Renderers);}
|
||||
/** extensions仅在useState惰性初始化时捕获;后续prop换引用不会热替换已打开项目的schema/renderer。 */
|
||||
export function CanvasRuntimeHost({ children,extensions=[] }: { children: ReactNode;extensions?:readonly CanvasRuntimeExtension[] }) {
|
||||
const [{host,renderers}] = useState(() => {
|
||||
// 先组合内置与扩展模型,再按精确版本/结构验证renderer,冲突在打开项目之前失败。
|
||||
const registry = createRegistry([...builtinComponents(),...extensions.flatMap(extension=>extension.components??[])]),renderers=createReactRenderers([...defaultCanvasRenderers.definitions,...extensions.flatMap(extension=>extension.renderers??[])],registry);
|
||||
const host=createRuntimeHost({ registry,
|
||||
/** 每身份作用域绑定transport,current护栏由host提供,流读取也要防换账户后的迟到结果。 */
|
||||
modelLoader(accountId,workspaceId,_authEpoch,current){return createFoundryModelLoader({workspaceId,registry,transport:createFoundryTransport({accountId,isCurrentIdentity:current})});},
|
||||
/** 工作区持有浏览器存储、摘要/解码池;root项目共享其身份范围,各自再装配业务端口。 */
|
||||
workspace(accountId, workspaceId, authEpoch, isCurrentIdentity) {
|
||||
return createWorkspaceRuntime({ accountId, workspaceId, authEpoch, isCurrentIdentity, registry, storage: createBrowserStorage(),digests:createBrowserDigests(),images:createBrowserImageDecoder(),
|
||||
/** 一项目一组协议适配器;summary策略只压缩已协调回执,不改变网络请求或原始操作身份。 */
|
||||
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 }) };
|
||||
},
|
||||
// 同工作区错误用稳定通知id更新,不创建另一份任务状态;错误恢复仍须runtime明确操作。
|
||||
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};
|
||||
});
|
||||
// effect重放的代次令牌只保护宿主清理,不是账户authEpoch,也不进入持久模型。
|
||||
const lifetime = useRef(0);
|
||||
/** cleanup延到微任务检查代次:Strict Mode同步重设effect会递增代次,旧cleanup不关闭新挂载宿主。
|
||||
* 真正卸载才发起host.close,保存失败由宿主错误回调/日志报告,不在此丢弃根或强行释放leader。 */
|
||||
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>;
|
||||
}
|
||||
/** 需要runtime的页面必须在根Provider内,缺失装配明确抛错而不偷建一个临时host。 */
|
||||
export function useCanvasRuntimeHost() {
|
||||
const host = useContext(Context);
|
||||
if (!host) throw new Error("CanvasRuntimeHost is missing");
|
||||
return host;
|
||||
}
|
||||
@@ -1,8 +1,13 @@
|
||||
/**
|
||||
* 工作台标题的局部交互状态机:idle→selected→editing→saving,支持二次点击/F2编辑、Enter/失焦保存、Escape取消。
|
||||
* 只管理草稿、焦点和提示,真正改名由异步onSave负责;组件本身不修改目录或画布文档。
|
||||
*/
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import styles from "./editable-title.module.css";
|
||||
|
||||
/** editable控制能否进入编辑;title是宿主已采用值,draft只在一次编辑会话内使用,保存失败回editing重试。 */
|
||||
export function EditableTitle({ title, editable, onSave, className = "" }: {
|
||||
title: string;
|
||||
editable: boolean;
|
||||
@@ -15,11 +20,14 @@ export function EditableTitle({ title, editable, onSave, className = "" }: {
|
||||
const shell = useRef<HTMLDivElement>(null);
|
||||
const input = useRef<HTMLInputElement>(null);
|
||||
const button = useRef<HTMLButtonElement>(null);
|
||||
// ref防Enter和随后的blur重复调用;cancelled防Escape/已完成后的blur重新提交。
|
||||
const pending = useRef(false);
|
||||
const cancelled = useRef(false);
|
||||
|
||||
/** selected阶段才监听外侧pointerdown;离开此模式即退订,编辑/保存不由这条外侧点击逻辑擅自退出。 */
|
||||
useEffect(() => {
|
||||
if (mode !== "selected") return;
|
||||
/** 只在实际DOM目标落在shell外时取消选择,标题内部点击保留进入编辑的机会。 */
|
||||
const deselect = (event: PointerEvent) => {
|
||||
if (event.target instanceof Node && !shell.current?.contains(event.target)) setMode("idle");
|
||||
};
|
||||
@@ -27,10 +35,12 @@ export function EditableTitle({ title, editable, onSave, className = "" }: {
|
||||
return () => document.removeEventListener("pointerdown", deselect);
|
||||
}, [mode]);
|
||||
|
||||
/** 进入editing后挂载的input才可聚焦/全选,不在渲染阶段直接访问未就绪DOM。 */
|
||||
useEffect(() => {
|
||||
if (mode === "editing") { input.current?.focus(); input.current?.select(); }
|
||||
}, [mode]);
|
||||
|
||||
/** 每次从宿主最新title初始化草稿并清旧错误,不能沿用上次取消的输入。 */
|
||||
function edit() {
|
||||
if (!editable) return;
|
||||
cancelled.current = false;
|
||||
@@ -38,12 +48,15 @@ export function EditableTitle({ title, editable, onSave, className = "" }: {
|
||||
setError("");
|
||||
setMode("editing");
|
||||
}
|
||||
/** 标记取消以抑制blur保存,回selected并下一帧把焦点还给标题按钮。 */
|
||||
function cancel() {
|
||||
cancelled.current = true;
|
||||
setError("");
|
||||
setMode("selected");
|
||||
requestAnimationFrame(() => button.current?.focus());
|
||||
}
|
||||
/** 同步ref挡重复提交,trim后校验非空及UTF-8≤300字节;同名不发请求。
|
||||
* 成功回idle,失败留草稿/错误重编辑,finally释放pending允许下一次明确尝试。 */
|
||||
async function save() {
|
||||
if (pending.current || cancelled.current) return;
|
||||
const name = draft.trim();
|
||||
@@ -65,6 +78,7 @@ export function EditableTitle({ title, editable, onSave, className = "" }: {
|
||||
setMode("editing");
|
||||
} finally { pending.current = false; }
|
||||
}
|
||||
// saving保留input但只读;输入法组合不把Enter当提交,键盘事件停止冒泡避免触发画布快捷键。
|
||||
return (
|
||||
<div ref={shell} className={`${styles.shell} ${className}`}>
|
||||
{mode === "editing" || mode === "saving" ? (
|
||||
|
||||
@@ -1,30 +1,53 @@
|
||||
/**
|
||||
* 画布路由的客户端生命周期桥。把路径引用解析成board/profile,再选择新版engine、渐进预览或旧snapshot编辑器。
|
||||
* 页面只持显示与导航状态,项目由RuntimeHost/WorkspaceRuntime拥有;卸载中断本页订阅,但已开始的项目加载可转后台完成。
|
||||
*/
|
||||
"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";
|
||||
/** 旧编辑器只在浏览器加载,避免服务端执行依赖DOM的画布实现。 */
|
||||
const GraphCanvas = dynamic(() => import("@/components/canvas/graph-canvas"), { ssr: false, loading: () => <p role="status">正在准备画布…</p> });
|
||||
|
||||
type Loaded = { board: Board; snapshot?: CanvasSnapshot; route: string };
|
||||
/** 新版编辑器同样独立客户端bundle,模型选择由loadCanvasEntry结果决定,而非界面手动猜格式。 */
|
||||
const HeadlessGraphCanvas = dynamic(() => import("@/components/canvas/headless-graph-canvas"), { ssr: false, loading: () => <p role="status">正在准备画布…</p> });
|
||||
/** 只读渐进预览先出现,已知内容足够后可升级为partial engine,保持原视口。 */
|
||||
const LoadingGraphCanvas=dynamic(()=>import("@/components/canvas/loading-graph-canvas"),{ssr:false});
|
||||
/** route将结果绑定到这次路径;engine/snapshot/migration分别来自实际选择的协议路径。 */
|
||||
type Loaded = { board: Board; snapshot?: CanvasSnapshot; route: string; engine?: ProjectEngine; migration?:LegacyMigrationContext };
|
||||
/** 当前页面只借用host与renderer,retry触发重新读取;不在页面state复制业务文档或history。 */
|
||||
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);
|
||||
// 预览视口暂存于ref,切成可编辑视图时交作initialViewport;新路由重置,不当文档编辑保存。
|
||||
const previewViewport=useRef<Viewport|undefined>(undefined);
|
||||
const [error, setError] = useState("");
|
||||
const [retry, setRetry] = useState(0);
|
||||
const navigationGeneration=useRef(0);
|
||||
/** 每条路由/重试新建读取生命周期,清旧显示;active、AbortController和host身份护栏共同隔离迟到结果。 */
|
||||
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("");
|
||||
// 演示目录走旧本地画布,不发canonical读取、不绑定真实账户项目。
|
||||
if (demo) {
|
||||
const item = readDemoBoards(workspaceId).find((board) => matchesRef(board, boardId));
|
||||
if (item) setLoaded({ board: item, route });
|
||||
@@ -33,20 +56,64 @@ 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 }); })
|
||||
// 保存开始时身份,并在profile解析后取得currentIdentity闭包,后续await返回均据此判断是否还能采用结果。
|
||||
const startingIdentity=runtimeHost.identity();let currentIdentity:(()=>boolean)|undefined;
|
||||
// latest合并待采用的预览,pumping串行openPartial/persist,防快流把多个partial打开交错到同一项目。
|
||||
let latest:{value:LiveModelPreview;board:Board;accountId:string}|undefined,pumping:Promise<void>|undefined,partialEngine:ProjectEngine|undefined,partialRuntime:ReturnType<typeof runtimeHost.workspace>|undefined;
|
||||
/** 后台任务失败仅在页面仍活跃时显示,离开页面不再更新本页error。 */
|
||||
const reportBackground=(error:unknown)=>{if(active)setError(errorText(error));};
|
||||
/** 已建立partial engine且身份仍有效时,交工作区继续完整读取;视图离开不等于关闭可恢复项目。 */
|
||||
const completeInBackground=()=>{if(partialEngine?.loading.getState()&¤tIdentity?.())void partialRuntime?.resumeLoading(partialEngine.getSnapshot().document.projectId).catch(reportBackground);};
|
||||
/** 串行采用最新预览,先openPartial再发布UI并保存;期间新预览覆盖latest等待槽而非并行改根。 */
|
||||
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;
|
||||
}
|
||||
|
||||
// 导航代次递增传入共享loader,查询身份与取消订阅由host/loader维护;入口优先canonical防旧writer误开。
|
||||
loadCanvasEntry({ request: api, workspaceRef: workspaceId, boardRef: boardId, registry: runtimeHost.registry, signal: controller.signal,navigationGeneration:++navigationGeneration.current,
|
||||
/** 只有本board已在前缀中才显示;有元素后推动partial engine,尚未建立engine时用只读预览。 */
|
||||
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);}}},
|
||||
/** profile解析后绑定账户并读取模型;若开始后的authEpoch已变且当前账户不同于响应账户,拒绝旧导航认领。 */
|
||||
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;
|
||||
// 迁移完成回调只递增retry重开入口,不在旧组件内手工切换文档格式。
|
||||
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;
|
||||
}
|
||||
// 服务已返回新模型但本地仍有同根pending时,查询并收尾原迁移记录,不生成新迁移请求。
|
||||
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);}
|
||||
}
|
||||
// 完整模型到来先等partial采用链结束,再open同一项目,避免尾部覆盖尚在发布的前缀根。
|
||||
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]);
|
||||
// 清掉未采用预览并中断本页读取;已拥有的partial项目交后台,绝不在这里直接engine.close。
|
||||
return () => { active = false;latest=undefined; controller.abort();completeInBackground(); };
|
||||
}, [workspaceId, boardId, route, demo, retry, router, runtimeHost]);
|
||||
// 即使effect清理/新读取尚未完成,只显示route匹配的结果,不短暂闪出上个board。
|
||||
const current = loaded?.route === route ? loaded : null;
|
||||
const board = current?.board;
|
||||
const currentPreview=preview?.route===route?preview:null;
|
||||
const board = current?.board??currentPreview?.board;
|
||||
/** 发布当前board元数据供侧栏标题使用,卸载/换board归还该Context显示状态,不删除服务端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]);
|
||||
// 本board在侧栏改名后优先读共享新标题;其它board的活动状态不能覆盖当前名称。
|
||||
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="正在读取画布" />}
|
||||
// 显示优先级:engine→只读预览→旧snapshot→读取占位;重试错误提示与已显示前缀可并存。
|
||||
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>;
|
||||
}
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
/**
|
||||
* 头像的轻量显示降级:指定URL加载失败时用名字首段的大写表示,新的URL可重新尝试。
|
||||
* 失败标记仅属当前组件,不写用户资料,也不把一次图片失败当成账户失效。
|
||||
*/
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import styles from "./workspace.module.css";
|
||||
|
||||
/** 同一失败URL不反复重载;large仅控制尺寸,图片不发送Referrer,alt保留用户身份文本。 */
|
||||
export function UserAvatar({ name, url, large = false }: {
|
||||
name: string;
|
||||
url?: string | null;
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
/**
|
||||
* 应用通用JSON/FormData请求与登录跳转辅助。请求默认经过同源Foundry代理,错误封装为ApiError,
|
||||
* 界面通过errorText映射中文说明;业务schema和账户代次等更强校验由各领域适配器处理。
|
||||
* 本函数本身不设置超时或自动重试,取消依赖传入signal,代理传输时限在对应路由/响应封装中管理。
|
||||
*/
|
||||
/** 账户资料响应的静态类型,不能用它代替登录会话有效性或权限校验。 */
|
||||
export type Profile = {
|
||||
id: string;
|
||||
username: string;
|
||||
@@ -5,20 +11,25 @@ export type Profile = {
|
||||
email: string | null;
|
||||
avatar_url?: string | null;
|
||||
};
|
||||
/** 保留HTTP状态、服务details和原Retry-After头,message使用稳定错误码,供上层决定显示/重试。 */
|
||||
export class ApiError extends Error {
|
||||
readonly status: number;
|
||||
readonly details?: unknown;
|
||||
readonly retryAfter?: string | null;
|
||||
/** 以Error兼容方式封装服务错误,不把details自动拼进面向用户文案。 */
|
||||
constructor(code: string, status: number, details?: unknown, retryAfter?: string | null) {
|
||||
super(code); this.name = "ApiError"; this.status = status; this.details = details; this.retryAfter = retryAfter;
|
||||
}
|
||||
}
|
||||
/** 泛型T只是调用方的响应形状声明,不执行运行时schema检查。
|
||||
* 204返回null;其它响应必须可解析JSON,非2xx统一抛ApiError,成功body直接交业务层验证。 */
|
||||
export async function api<T>(
|
||||
path: string,
|
||||
options: RequestInit = {},
|
||||
): Promise<T> {
|
||||
const res = await fetch("/api/foundry/" + path, {
|
||||
...options,
|
||||
// FormData让浏览器生成boundary,其它请求默认JSON;调用方headers可覆盖默认Content-Type。
|
||||
headers: { ...(options.body instanceof FormData ? {} : { "Content-Type": "application/json" }), ...options.headers },
|
||||
});
|
||||
let body: unknown;
|
||||
@@ -26,21 +37,27 @@ export async function api<T>(
|
||||
else {
|
||||
try { body = await res.json(); }
|
||||
catch (error) {
|
||||
// 消费body失败时优先传播明确取消原因;语法坏JSON与传输中断使用不同错误码。
|
||||
if (options.signal?.aborted) throw options.signal.reason;
|
||||
const malformed = error instanceof SyntaxError;
|
||||
throw new ApiError(malformed ? "invalid_api_response" : "response_interrupted", res.ok ? 502 : res.status);
|
||||
}
|
||||
}
|
||||
const payload = body as { error?: string | { code?: string }; details?: unknown } | null;
|
||||
// 服务error兼容字符串或{code},无可用码则request_failed,并保留Retry-After原值供上层解释。
|
||||
if (!res.ok) {
|
||||
const code = typeof payload?.error === "object" ? payload.error?.code : payload?.error;
|
||||
throw new ApiError(typeof code === "string" ? code : "request_failed", res.status, payload?.details, res.headers.get("retry-after"));
|
||||
}
|
||||
return body as T;
|
||||
}
|
||||
/** 公开项目标识,来自构建时NEXT_PUBLIC配置;不是密码或用户账户身份。 */
|
||||
export const PROJECT = process.env.NEXT_PUBLIC_FOUNDRY_PROJECT || "endoleap";
|
||||
/** 创建PKCE verifier和SHA-256 challenge:verifier留在当前标签页sessionStorage,
|
||||
* 只把challenge带到同源OAuth入口,后续交换流程负责取回verifier。此处不直接取得令牌。 */
|
||||
export async function startOAuth(provider: "google" | "slack") {
|
||||
const bytes = crypto.getRandomValues(new Uint8Array(32));
|
||||
// 32随机字节编码为无填充base64url,避免普通base64字符影响URL参数。
|
||||
const verifier = btoa(String.fromCharCode(...bytes))
|
||||
.replace(/\+/g, "-")
|
||||
.replace(/\//g, "_")
|
||||
@@ -53,12 +70,43 @@ export async function startOAuth(provider: "google" | "slack") {
|
||||
.replace(/\+/g, "-")
|
||||
.replace(/\//g, "_")
|
||||
.replace(/=/g, "");
|
||||
// 页面跳转前保存原verifier;使用单一固定键,同标签页后一次发起会替换上一次值。
|
||||
sessionStorage.setItem("foundry_verifier", verifier);
|
||||
window.location.assign("/api/oauth/" + provider + "?challenge=" + challenge);
|
||||
}
|
||||
/** 应用错误码→用户文案适配,不改变任务状态或执行恢复;未知错误给通用提示,不直接泄漏内部异常文本。 */
|
||||
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 +120,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: "图片上传结果无效,画布内容未替换,请重试。",
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
/**
|
||||
* 工作区画布目录的读取与展示树派生。完整分页包含子分区,随后用parent_board_id构建侧栏关系。
|
||||
* 显示树可保守容纳孤儿/循环记录,不据此修改服务端owner,也不替代文档模型中的canvas树校验。
|
||||
*/
|
||||
import { matchesRef, type Board } from "./workspace.ts";
|
||||
|
||||
/** 页面注入的通用JSON请求能力,传入signal供导航/卸载取消。 */
|
||||
type Request = <T>(path: string, options?: RequestInit) => Promise<T>;
|
||||
/** Include children and finish pagination before building ownership paths. */
|
||||
/** 分页取齐board后返回按ID去重的列表,后出现的同ID记录更新内容。
|
||||
* next_offset必须严格前进且安全,seen防重复游标,不能只拿首页就推断某canvas没有父分区。 */
|
||||
export async function loadBoardDirectory(request: Request, workspace: string, signal?: AbortSignal) {
|
||||
const boards = new Map<string, Board>();
|
||||
let offset = 0;
|
||||
@@ -16,7 +23,10 @@ export async function loadBoardDirectory(request: Request, workspace: string, si
|
||||
offset = page.next_offset;
|
||||
}
|
||||
}
|
||||
/** 侧栏派生分支:保留原board记录引用,children是本次展示树容器。 */
|
||||
export type BoardBranch = { board: Board; children: BoardBranch[] };
|
||||
/** 每条记录沿父链检查循环;父缺失或成环时作为展示根,避免递归挂载成无限树。
|
||||
* 同层顺序沿用输入列表,不在此另做排序或写回parent_board_id。 */
|
||||
export function boardDirectory(boards: Board[]): BoardBranch[] {
|
||||
const entries = new Map(boards.map((board) => [board.id, { board, children: [] as BoardBranch[] }]));
|
||||
const roots: BoardBranch[] = [];
|
||||
@@ -33,12 +43,14 @@ export function boardDirectory(boards: Board[]): BoardBranch[] {
|
||||
}
|
||||
return roots;
|
||||
}
|
||||
/** 按ID或slug找到当前项,包含自身向上收集祖先ID,重复ID即停,供侧栏自动展开。 */
|
||||
export function activeBoardAncestors(boards: Board[], ref?: string) {
|
||||
const result = new Set<string>();
|
||||
let item = boards.find((board) => matchesRef(board, ref));
|
||||
while (item && !result.has(item.id)) { result.add(item.id); item = boards.find((board) => board.id === item!.parent_board_id); }
|
||||
return result;
|
||||
}
|
||||
/** 忽略首尾空白并做本地小写匹配;父标题命中保留整子树,否则保留命中后代及其路径祖先。 */
|
||||
export function filterBoardDirectory(branches: BoardBranch[], query: string): BoardBranch[] {
|
||||
const term = query.trim().toLocaleLowerCase();
|
||||
if (!term) return branches;
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* 迁移协调器的浏览器装配层。旧编辑视图持共享Web Lock,迁移操作取同一键的独占锁,
|
||||
* 配合本地pending记录阻止已发出迁移却结果未知时继续旧格式写入。锁协调本浏览器,服务端版本/幂等校验另行负责。
|
||||
*/
|
||||
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';
|
||||
|
||||
/** 页面提供身份、可迁移根和完成后的重开回调;rootId缺失不代表可任意推测迁移范围。 */
|
||||
export type LegacyMigrationContext={accountId:string;rootId?:string;registry:Registry;isCurrentIdentity():boolean;onApplied():void};
|
||||
/** 锁/Storage可注入测试,生产默认使用navigator.locks/localStorage;身份和signal都由宿主管理。 */
|
||||
type BrowserScope={scope:CanvasMigrationScope;isCurrentIdentity():boolean;signal:AbortSignal;locks?:LockManager;storage?:Storage};
|
||||
/** 同账户/工作区共享编辑锁键,与持久迁移记录同一作用域,跨页面形成读写互斥。 */
|
||||
const lockKey=(scope:CanvasMigrationScope)=>canvasMigrationStorageKey(scope)+':editing';
|
||||
/** 缺少Web Locks明确拒绝安全迁移装配,不用不可靠的计时器冒充独占锁。 */
|
||||
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. */
|
||||
/** 为旧编辑视图整个生命周期借共享锁,release返回Promise确认锁请求已结束。
|
||||
* ifAvailable不排长队;迁移占用或本canvas有未确认迁移时立即拒绝开放旧编辑。 */
|
||||
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;
|
||||
// ready在锁授予且本地pending检查通过后返回;request则在release/abort后才结束,两者用途不同。
|
||||
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);
|
||||
// 回调等待released以保持共享锁;不能在第一次PATCH完成后就把编辑门解除。
|
||||
let unlock!:()=>void;const released=new Promise<void>(yes=>{unlock=yes;});
|
||||
/** 解开锁回调并等待平台释放;resolve可重复调用,已释放后的再次release仍等同一请求结束。 */
|
||||
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;
|
||||
}
|
||||
|
||||
/** 连接localStorage、专属账户transport和UUID生成器;真正迁移在同一工作区独占锁内执行。 */
|
||||
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();
|
||||
}),
|
||||
});
|
||||
}
|
||||
@@ -1,15 +1,26 @@
|
||||
/**
|
||||
* 旧画布的图片上传辅助,与新版canvas-service/assets的冻结资源协议分开保留。
|
||||
* 同一次调用的重试沿用FormData和幂等键;这里只返回旧版素材记录,不维护可恢复上传账本。
|
||||
*/
|
||||
/** 由应用注入JSON/FormData请求端口,凭证与错误形状沿用宿主API封装。 */
|
||||
type Request = <T>(path: string, options?: RequestInit) => Promise<T>;
|
||||
/** 旧上传接口的素材响应形状,原件规范URL与尺寸由返回前校验。 */
|
||||
export type CanvasAsset = { id: string; url: string; width: number; height: number; bytes: number; mime_type: string };
|
||||
/** 上传原文件大小上限20MiB,空文件同样拒绝。 */
|
||||
export const ASSET_LIMIT = 20 * 1024 * 1024;
|
||||
/** 仅网络TypeError或5xx且未取消时追加一次同键重试;其它错误直接交给调用方。
|
||||
* 调用方可提供key以跨尝试复用;默认key只在这次函数调用开始时生成,不是持久恢复记录。 */
|
||||
export async function uploadCanvasImage(request: Request, workspace: string, file: Blob, name: string, signal?: AbortSignal, key = crypto.randomUUID()): Promise<CanvasAsset> {
|
||||
if (!file.size || file.size > ASSET_LIMIT) throw new Error("asset_too_large");
|
||||
const form = new FormData(); form.append("image", file, name);
|
||||
/** 两次发送共用同一字节、文件名和key,避免丢响应后新建第二份素材。 */
|
||||
const send = () => request<CanvasAsset>(`workspaces/${encodeURIComponent(workspace)}/assets`, { method: "POST", headers: { "Idempotency-Key": key }, body: form, signal });
|
||||
let asset: CanvasAsset;
|
||||
try { asset = await send(); } catch (error) {
|
||||
if (signal?.aborted || !(error instanceof TypeError || typeof error === "object" && error && "status" in error && Number(error.status) >= 500)) throw error;
|
||||
asset = await send(); // The same bytes/key retry a possibly committed upload.
|
||||
}
|
||||
// 成功HTTP不等于合法素材,继续核对ID、规范源站/路径及正有限尺寸,不接受任意远端URL。
|
||||
if (!asset || typeof asset.url !== "string") throw new Error("invalid_asset_response");
|
||||
let url: URL;
|
||||
try { url = new URL(asset.url); } catch { throw new Error("invalid_asset_response"); }
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* 旧卡片 renderer 使用的结构化输入类型,与 headless 元素 schema 分离。
|
||||
* 这些类型描述投影/显示约定,不能用类型断言替代 registry 对原始数据、版本和引用的运行时校验。
|
||||
*/
|
||||
/** 旧绘制路径使用的二维点;是否局部/场景由投影路径的调用约定决定。 */
|
||||
type Point={x:number;y:number};
|
||||
|
||||
/** Compatibility renderer data only; never the authoritative element schema. */
|
||||
/** 兼容卡片显示的 AI 配置,不是已受理生成任务或冻结请求。 */
|
||||
export type AIConfig = { enabled?: boolean; modelId?: string; prompt?: string; propagate?: boolean; parameters?: Record<string, unknown> };
|
||||
/** 兼容分区端口布局数据,连接到内部 node/handle 的关系由业务模型解释。 */
|
||||
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 } };
|
||||
|
||||
/** 内置旧卡片的类型枚举,不限制 registry 可扩展的 componentId 集合。 */
|
||||
export type ElementKind = "text" | "image" | "script" | "plain-text" | "icon" | "frame" | "plain-image";
|
||||
/** 分区标题的隐藏/固定大小/随缩放显示模式。 */
|
||||
export type FrameLabelMode = "hidden" | "fixed" | "scaled";
|
||||
/** 卡片正文的兼容排版配置,不含编辑器实例或 DOM 状态。 */
|
||||
export type TextStyle = { color?: string; background?: string; bold?: boolean; italic?: boolean; underline?: boolean; align?: "left" | "center" | "right"; fontFamily?: "sans" | "serif" | "mono"; lineHeight?: number };
|
||||
/** 显示视图与真实 owner 分开记录;id+incarnation 供命令拒绝旧代次目标。 */
|
||||
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" };
|
||||
/** 旧边外观、动画、已投影路径和执行样式;实际生成/执行事实由 runtime 持有。 */
|
||||
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. */
|
||||
/** 仅供兼容锚点/布局计算的节点几何,measured 优先级由具体布局函数决定。 */
|
||||
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,68 @@
|
||||
/**
|
||||
* 连线超出原生端点包围范围的额外绘制索引。用曲线控制点凸包加半线宽保守包住笔画,
|
||||
* 不要求求出三次曲线的精确极值。无法获取锚点/协议或耗尽预算时返回 unknown,宿主应保守保留显示。
|
||||
*/
|
||||
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.
|
||||
// 仅为边预算内真正要检查的端点建节点表;仍遍历节点,但不为所有无关节点分配 Map 条目。
|
||||
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++){
|
||||
// 超出边预算给全局 paint_budget,而非声称未检查的边没有额外范围。
|
||||
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();
|
||||
/** 优先用 renderer 锚点;原生 record-v1 不能猜旧卡片把手,缺测量即归 unknown。 */
|
||||
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++;
|
||||
// renderer 服务优先,随后用组件几何协议;stroke 也必须来自同一显示语义。
|
||||
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);
|
||||
// 校验每边段数、线宽,再扣全局剩余段数;空段列表是已知无笔画,不应误标 unknown。
|
||||
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);}}
|
||||
// 向四侧扩半线宽,同时检查端点相加不溢出,否则当前边转 unknown。
|
||||
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});
|
||||
// 异常几何不保留过时缓存;单边失败隔离为该 ID 的 unknown,不影响其它已知范围。
|
||||
}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. */
|
||||
/** 视图级曲线范围缓存与网格。外部锚点/样式服务配置改变时,宿主必须同步推进 geometryRevision,
|
||||
* 仅换闭包或修改服务内部状态不在元素签名中,不能指望缓存自行察觉。 */
|
||||
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;
|
||||
/** 同步当前场景;范围身份或 geometryRevision 变化整体失效,删除已移出的边缓存。
|
||||
* query 与 unknown 配套使用;computed 是累计重新计算数,不把缓存命中记成一次曲线计算。 */
|
||||
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,28 @@
|
||||
/**
|
||||
* 提供旧卡片兼容布局与原生测量布局的连线锚点。
|
||||
* 两种协议不能混猜:compat-v1 依卡片边框/ports 排布,record-v1 应使用 renderer 实测 Handle。
|
||||
*/
|
||||
import type {CanvasGeometryNode} from './compat-types.ts';
|
||||
import type {ProjectionNode} from './projection.ts';
|
||||
import {nodeConnector,type Connector,type ConnectorSide} from '../canvas-geometry.ts';
|
||||
/** 兼容几何节点加投影 data,保留 measured 尺寸和旧卡片端口声明。 */
|
||||
type CompatNode=CanvasGeometryNode&{data:ProjectionNode['data']};
|
||||
/** Same card-border and port layout used by CanvasEdge. Never infer it for record-v1 DOM. */
|
||||
/** 方向优先取降级外壳提示,再取标准 handle 名、port 方向或调用方 fallback。
|
||||
* 同方向多个 port 沿高度均匀分布,与兼容 CanvasEdge 使用同一布局。 */
|
||||
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);
|
||||
// 输入/输出各自独立排序,用测量高度优先;默认400仅属于旧卡片布局约定。
|
||||
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;
|
||||
}
|
||||
/** renderer 测量端口:绝对位置与 source/target handle 边界,避免在这里依赖整个 UI store。 */
|
||||
export type MeasuredNode={readonly internals:{readonly positionAbsolute:{x:number;y:number};readonly handleBounds?:{readonly source?:readonly MeasuredHandle[]|null;readonly target?:readonly MeasuredHandle[]|null}}};
|
||||
/** 单个 Handle 在节点中的相对矩形及所在边。 */
|
||||
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. */
|
||||
/** 从实际 Handle 外侧中心生成锚点与朝外法线。target 可采用 source 把手以匹配 loose 模式,
|
||||
* 指定 handleId 未找到或尚无测量时返回 undefined,不编造中心点。 */
|
||||
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,67 @@
|
||||
/**
|
||||
* 按元素 ID 精确路由的只读查询源。一个上游 editor 订阅服务所有元素桶;
|
||||
* 普通提交用 changes 找受影响 ID,整份文档替换或加载覆盖变化则做必要的保守复查。
|
||||
*/
|
||||
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. */
|
||||
/** 创建惰性订阅中心;每个 forElement 源缓存自身读取值,同 ID 的监听共享上游桶。 */
|
||||
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){
|
||||
// 显式元素/字段改动走 ID 路由;无 changes 的文档替换无法精确定位,复查全部已订阅元素。
|
||||
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);
|
||||
}
|
||||
// loading 的已知范围改变会把 absent/unloaded 变成可查询;原先非 present 的桶必须复查。
|
||||
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;
|
||||
}
|
||||
/** 创建单元素外部源;单纯创建源不注册上游,只有 subscribe 才建立观察关系。 */
|
||||
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;},
|
||||
/** 每个订阅包装独立 listener,即使调用方传同一个函数也能分别释放。 */
|
||||
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);
|
||||
// 首次连上游抛错时撤销这次 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;}}
|
||||
// 最后一个桶清空才退订上游,释放 previous 根引用;其它元素仍有监听时继续共享。
|
||||
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,35 @@
|
||||
/**
|
||||
* 显示层的帧合并队列。把多次“需要重画”合并成去重 key 集合,不延迟命令提交、历史或网络任务。
|
||||
* 队列只拥有调度句柄和失效索引;真实数据随时从上游快照读取,dispose 时丢弃未发布通知。
|
||||
*/
|
||||
import type {ExternalSource} from './selectors.ts';
|
||||
/** Presentation invalidations only. Commands, history and network work never wait here. */
|
||||
/** 宿主注入帧请求/取消能力,可接 requestAnimationFrame 或测试时钟;handle 不保存业务身份。 */
|
||||
export type FrameClock={request(callback:()=>void):unknown;cancel(handle:unknown):void};
|
||||
/** 创建一条可手动 flush 的通知队列;同一 key 在一次批次中只出现一次。 */
|
||||
export function createFrameQueue<K>(clock:FrameClock,publish:(dirty:ReadonlySet<K>)=>void){
|
||||
let dirty=new Set<K>(),scheduled:{handle?:unknown}|undefined,disposed=false;
|
||||
/** 同步排空已有脏项,先换新 Set 再回调,回调中的新失效留给下一批,不会被本批 clear 擦掉。 */
|
||||
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({
|
||||
/** 合并 keys 并最多安排一个帧回调;pending 对象身份防止已取消的旧回调误刷后来的批次。 */
|
||||
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,
|
||||
/** 幂等关闭并撤销帧句柄,关闭后失效通知无效果;不会替上游关闭 editor。 */
|
||||
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. */
|
||||
/** 给普通外部数据源加通知节流:读取始终透传当前快照,每个订阅独有可释放的帧队列。
|
||||
* 上游标 immediate 时同步 flush,常用于不能等下一帧才反映的状态边界。 */
|
||||
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;}
|
||||
// 先退订上游,finally 保证上游清理抛错时仍释放本订阅的显示队列。
|
||||
return()=>{try{stop();}finally{queue.dispose();}};
|
||||
}});
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* 视图局部的数值/路径投影缓存。按元素代次、几何和变换值复用计算,既不缓存可编辑记录,也不决定可见性。
|
||||
* 每次 begin/end 标记本帧访问项,剔除不再出现的条目;切换 scope/project/canvas 时整体清空。
|
||||
*/
|
||||
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;
|
||||
/** 子画布变换只依赖 presentation 中 contentScale/unitScale,忽略无关样式字段。 */
|
||||
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({
|
||||
/** 开始一轮投影并重置 seen;owner 身份隔离不同 renderer/editor,即使 projectId 相同也不能混用。 */
|
||||
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;},
|
||||
/** JSON 路径按值比较;先复制/冻结输入再投影,不持有调用方可变路径作为历史缓存。 */
|
||||
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);},
|
||||
/** 返回当前缓存大小和累计计算量的冻结快照;begin 不重置累计计数。 */
|
||||
stats:()=>Object.freeze({nodesComputed,childrenComputed,edgesComputed,nodes:nodes.size,children:children.size,edges:edges.size}),
|
||||
});
|
||||
}
|
||||
/** 由工厂推导的缓存接口,供投影器注入复用。 */
|
||||
export type GeometryProjectionCache=ReturnType<typeof createGeometryProjectionCache>;
|
||||
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* 对已捕获显示投影执行命中查询,而不是直接扫描请求队列或另建选择模型。
|
||||
* 节点用组件 geometry 协议精测,连线用实际显示锚点/曲线近似求距;像素容差按 zoom 转为场景单位。
|
||||
* 预算或几何解释不足时保留 diagnostics/possible,调用方不能把不完整结果当成确定的“没有命中”。
|
||||
*/
|
||||
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';
|
||||
/** 查询消费 projectCanvas 的同一帧结果,包含显示节点、边与 canvas→场景变换。 */
|
||||
type Projection=ReturnType<typeof projectCanvas>;
|
||||
/** 命中身份用真实元素 ID+代次;displayPath 说明嵌套显示路径,不改变 ownerCanvasId。
|
||||
* 连线另保留屏幕显示端点身份,可能是投影暴露的上级入口,而不是底层真实端点。 */
|
||||
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}};
|
||||
/** renderer 可提供实际锚点、曲线、线宽与层级,使命中跟绘制一致;previewIds 补充移动预览候选。 */
|
||||
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};
|
||||
/** scene 点直接附缩放;screen 点还带容器 origin 和 viewport 平移,避免混用窗口/画布坐标。 */
|
||||
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};
|
||||
/** 外部几何服务的点必须由有限数值组成,不能让 NaN/Infinity 流入曲线计算。 */
|
||||
const finitePoint=(point:Point)=>!!point&&Number.isFinite(point.x)&&Number.isFinite(point.y);
|
||||
/** 把显示节点的位置/尺寸/旋转反算回所属 canvas 局部几何,包含当前预览后的显示结果。 */
|
||||
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. */
|
||||
/** 必须使用投影对应的同一 document 和 registry。先取空间候选再精测,返回有序命中及完整性诊断;
|
||||
* 此函数不触发编辑、不消费原生事件,也不替宿主决定是否认领一次输入。 */
|
||||
export function hitProjectedCanvas(projection:Projection,queries:DocumentQueries,registry:Registry,viewSessionId:Id,query:HitPoint,services:HitServices={},budget:HitBudget={}){
|
||||
// 强制快照/registry 身份一致;旧投影搭配新索引可能产生看得见却点不中的结果,直接拒绝。
|
||||
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');
|
||||
// 屏幕位置减容器 origin 与 viewport 平移后除 zoom;容差同样换算,不随缩放改变视觉点击范围。
|
||||
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');
|
||||
// 分别把场景点与容差变回各 canvas,查询本地节点索引;previewIds 可补上索引尚未覆盖的预览位置。
|
||||
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);}
|
||||
/** 由真实 owner 向当前显示根回溯,生成 canvas/入口路径;检测循环和无效父关系。 */
|
||||
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'});}}
|
||||
// 优先采用 renderer 锚点。原生 headless 记录不能用旧卡片的兼容锚点猜位置,缺少服务就给出诊断。
|
||||
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};
|
||||
// renderer 曲线优先,随后才用组件协议;验证段数及每段四个有限点,避免异常几何耗尽查询。
|
||||
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');
|
||||
// 有效点击半径 = 场景容差 + 实际线宽/2;不把只点中心线误当成整条可见线的命中范围。
|
||||
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;
|
||||
// 曲线细分步数由所有边共享剩余预算;无法继续证明时保守返回 possible,不伪造 confirmed。
|
||||
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;}}
|
||||
// 同一边任一段确定命中即可 confirmed;只有不确定命中则同时说明精度或预算诊断。
|
||||
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,34 @@
|
||||
/**
|
||||
* 可移植 bindings 的公共入口,集中导出投影、查询、输入与显示租约工具。
|
||||
* 不导入 React/React Flow;框架 hooks、组件注册与命令 Context 通过 react-flow.ts 明确选择。
|
||||
*/
|
||||
/** 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';
|
||||
// 框架无关 renderer 描述、锚点约定与资源视图租约。
|
||||
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';
|
||||
// 视图局部浮层与 DOM 指针捕获的生命周期工具。
|
||||
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,38 @@
|
||||
/**
|
||||
* 归一化键盘事件的消费顺序:文本编辑 → 浮层 → 手势 → 工具 → 快捷键。
|
||||
* 输入来源由宿主传入,路由器只报告获胜层,不直接 preventDefault、执行命令或访问浏览器全局。
|
||||
*/
|
||||
/** Host normalizes DOM/native input; only the winning layer consumes an event. */
|
||||
/** 将 DOM/native 键盘上下文压成只读值;composing 保护输入法组合,modifier 供具体处理器解释。 */
|
||||
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};
|
||||
/** 只有一个层获得消费权;unclaimed 表示宿主不应据此拦截事件。 */
|
||||
export type InputClaim='text'|'overlay'|'gesture'|'tool'|'shortcut'|'unclaimed';
|
||||
/** 返回 true 即获得事件;后续低优先级处理器不会再运行。 */
|
||||
export type KeyHandler=(input:CanvasKeyInput)=>boolean;
|
||||
// 标准文本控件、可编辑内容和 textbox 角色均视为正在输入,不让画布快捷键抢走按键。
|
||||
const textTarget = 'input,textarea,select,[contenteditable]:not([contenteditable="false"]),[role="textbox"]';
|
||||
// 画布浮层标记和交互 ARIA 角色优先于全局画布命令。
|
||||
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. */
|
||||
/** 检查 target 和 composedPath 上所有可 closest 的节点,识别 shadow DOM 重定向后的输入上下文。
|
||||
* portal 需要自身携带浮层标记/角色;这里只检查实际路径,不推断不存在于 DOM 路径的 React 父关系。 */
|
||||
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?.()??[])]){
|
||||
// 使用结构能力判断,跳过 Window、文本节点等无 closest 的路径项,无需 instanceof Element。
|
||||
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,26 @@
|
||||
/**
|
||||
* 把节点端口/尺寸变化的测量请求合并给 renderer,避免同一轮重复调用更新。
|
||||
* owner token 区分同 ID 的不同挂载实例;卸载实例的迟到失效不能触发后来新实例的测量。
|
||||
*/
|
||||
/** One renderer store's transient measurement requests. Not model or history state. */
|
||||
/** 每个 renderer store 独立创建;默认微任务调度,也可注入测试或宿主 defer。 */
|
||||
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;
|
||||
/** 执行时才解析仍有效的 owner,按节点 ID 去重;先清 dirty 再回调,让重入失效进入下一轮。 */
|
||||
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({
|
||||
/** 每次挂载取得独立租约,即使节点 ID 相同也能单独 dispose。 */
|
||||
acquire(id:string){
|
||||
const owner=Symbol(id);owners.set(owner,id);
|
||||
return Object.freeze({
|
||||
/** 仅活跃租约可以变脏,同一批最多安排一次延后 flush。 */
|
||||
invalidate(){if(!owners.has(owner))return;dirty.add(owner);if(!queued){queued=true;defer(flush);}},
|
||||
/** 卸载同时移除 owner 和其未执行请求;已排微任务可自然执行为空,不取得新实例权限。 */
|
||||
dispose(){owners.delete(owner);dirty.delete(owner);},
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* 视图局部的屏幕空间浮层索引。保存位置、目标代次与焦点/输入法状态,独立于业务文档和撤销历史。
|
||||
* 同 ID 浮层重新注册时获得新 token,旧组件迟到清理不能删掉新浮层。
|
||||
*/
|
||||
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';
|
||||
/** bounds 使用屏幕空间;target 仍是真实元素 ID+代次,viewId 限定所属交互视图。 */
|
||||
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. */
|
||||
/** 为一个有效 viewId 创建租约表、冻结快照和可重建 paint 网格。 */
|
||||
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();
|
||||
/** 从当前条目重建快照和空间索引后通知;这里只同步位置事实,不决定浮层 DOM 的挂载。 */
|
||||
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();}
|
||||
/** 复制并冻结目标/矩形,验证身份和位置;复用 paint 索引校验矩形,拒绝不合法的外部边界。 */
|
||||
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;}
|
||||
// 订阅只关注本视图位置变化,调用方需用返回函数解除监听;getSnapshot 返回稳定的最近发布值。
|
||||
return Object.freeze({viewId,getSnapshot:()=>snapshot,subscribe(listener:()=>void){listeners.add(listener);return()=>{listeners.delete(listener);};},
|
||||
/** 注册或替换一个浮层,返回只能操作本 token 的 update/release。
|
||||
* update 先完整校验,新旧序列化值相同时不发布;过期租约的更新/释放均无效果。 */
|
||||
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();},
|
||||
});
|
||||
}
|
||||
/** 由工厂推导接口,调用方无权直接操作内部 token/Map。 */
|
||||
export type OverlayIndex=ReturnType<typeof createOverlayIndex>;
|
||||
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* 显示空间的可重建均匀网格,用于额外绘制范围的可见性查询。
|
||||
* 大区域、大查询或超出安全格坐标时用保守回退,避免为了空间索引速度漏掉可见元素。
|
||||
*/
|
||||
import type {Id} from '../canvas-core/types.ts';
|
||||
import type {Rect} from '../canvas-core/geometry.ts';
|
||||
import {fault} from '../canvas-core/values.ts';
|
||||
/** 区域 ID 与场景轴对齐矩形;不保存节点内容、选择或交互状态。 */
|
||||
export type PaintRegion={readonly id:Id;readonly bounds:Rect};
|
||||
/** 同时校验端点,防止有限 x/width 相加后溢出;允许零面积边界。 */
|
||||
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;
|
||||
/** 包含边界接触的保守交集,与 nativeRectVisible 的正面积判定用途不同。 */
|
||||
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. */
|
||||
/** cellSize 决定网格尺度,maxCells 限制单区域/查询展开的格数,默认512和256。 */
|
||||
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>();
|
||||
/** 算出覆盖格键;负坐标用 floor,过大或不安全的格范围返回 undefined 走保守路径。 */
|
||||
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({
|
||||
/** 先验证完整输入的 ID 唯一性和矩形,再更新差异;超大区域归入 overflow,每次查询都检查。 */
|
||||
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);}}},
|
||||
/** 普通查询查覆盖格加 overflow,大查询扫描全部行,最后统一按矩形相交过滤。 */
|
||||
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,14 @@
|
||||
/**
|
||||
* 浏览器指针捕获的容错边界。这里只处理 DOM 事件传输所有权;手势 token、预览和几何仍归 editor/session。
|
||||
* DOM 捕获丢失不等于任意事件都能取消当前手势,必须匹配指针与捕获目标。
|
||||
*/
|
||||
/** Transport ownership only: document geometry and gesture tokens stay in the editor. */
|
||||
/** 最小结构化 DOM 端口,让纯测试无需真实 HTMLElement。 */
|
||||
export type PointerCapturePort={setPointerCapture(id:number):void;hasPointerCapture(id:number):boolean;releasePointerCapture(id:number):void};
|
||||
/** 普通事件匹配 pointerId 即可;lostpointercapture 还要匹配原捕获目标且确认它已失去捕获。
|
||||
* 如果同目标已重新捕获该指针,旧 lost 事件不拥有新手势;脱离 DOM 导致查询抛错则允许匹配目标清理。 */
|
||||
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;}}
|
||||
/** 尽力释放且吞掉平台清理异常;DOM 释放失败不能取得 editor 的提交或取消权限。 */
|
||||
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,81 @@
|
||||
/**
|
||||
* 节点额外绘制范围的保守索引。原生 renderer 的未旋转矩形不一定包含旋转/自定义组件的全部笔画,
|
||||
* 因此用 geometry.paint bounds 补充超出的范围;无法解释的节点标 unknown,不能当作已证明不可见。
|
||||
*/
|
||||
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. */
|
||||
/** 对每个显示节点还原局部几何,调用组件 paint 边界,再把四角变到场景取轴对齐包围盒。
|
||||
* 只记录超出原生矩形的节点;单节点协议失败记 unknown,其余节点继续处理。 */
|
||||
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;
|
||||
/** 变换值比较避免无关根发布反复计算 paint 边界。 */
|
||||
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;
|
||||
/** 验证缓存输入是否等价;协议未声明 dataDependencies 时保守比较整个 element.data,
|
||||
* 显式声明后只比较列出的字段,声明遗漏会由协议实现者负责,不能由缓存自动推导。 */
|
||||
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. */
|
||||
/** 视图级增量 paint 网格,每个节点缓存边界结果,只重新计算签名改变的条目。 */
|
||||
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({
|
||||
/** 切 registry/project/canvas 清空缓存;逐节点收集额外范围与 unknown,再同步网格。 */
|
||||
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);
|
||||
}
|
||||
// 删除已不在场景中的缓存,返回可查询索引与冻结的未知列表;调用方须保守处理 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)});
|
||||
},
|
||||
/** 累计实际计算/捕获次数和当前项数,仅供诊断,不是帧耗时或文档 revision。 */
|
||||
stats:()=>Object.freeze({computed,captured,entries:entries.size}),
|
||||
/** 解除场景与 registry 身份引用并清空网格;累计诊断计数仍保留。 */
|
||||
clear(){entries.clear();index.clear();registryOwner=undefined;canvas=undefined;project=undefined;},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* React Flow 输入对象的结构共享缓存,与计算场景的 scene-projection 分工不同。
|
||||
* 按元素代次与 owner 判定同一生命周期,复用值相等的 data/style/position 和整个数组,减少显示刷新。
|
||||
* 缓存属于一个 owner/project/视图 canvas,既不是业务文档,也不是跨项目身份仓库。
|
||||
*/
|
||||
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>;
|
||||
/** 原对象的生命周期证据;同 ID 的重建或移动 owner 后不能直接继承旧显示对象。 */
|
||||
type Retained<T>={incarnation:number;ownerCanvasId:Id;value:T};
|
||||
/** View-owned rendering cache, never a document store. Keep unchanged React Flow input identities. */
|
||||
/** 创建视图专属结构共享器;reuse 会就地替换传入投影数组中的等价条目,调用方应提供派生数组。 */
|
||||
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]>>();
|
||||
/** 对每个显示 ID 核验模型身份,再逐层共享等价字段;返回仅包含本帧元素的新缓存表。 */
|
||||
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;
|
||||
// 常见嵌套字段独立共享,使其它显示属性变化时也能保留未变化的 data/style/position。
|
||||
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};
|
||||
},
|
||||
/** 读取本轮保留的节点显示对象,供 renderer 测量等显示流程使用。 */
|
||||
getNode(id:Id){return nodes.get(id)?.value;},
|
||||
/** 只有上下文、生命周期、renderer type、尺寸和 data 均兼容才可沿用旧测量,不能只比较 ID。 */
|
||||
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;},
|
||||
});
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user