Compare commits

..

105 Commits

Author SHA1 Message Date
codex-bot c34d0b7ce0 docs(canvas): explain node rendering and resource handoffs 2026-11-12 16:21:06 +08:00
codex-bot f28d3810d4 docs(canvas): clarify editing guards and projection lifetimes 2026-11-12 16:18:24 +08:00
codex-bot 65c54e7b9e docs(workspace): explain component state and async ownership 2026-11-12 16:14:21 +08:00
codex-bot cc2172319e docs(ui): explain page and shared component lifecycle boundaries 2026-11-12 16:11:24 +08:00
codex-bot 5d82655e0e docs(canvas): explain legacy document and sync state boundaries 2026-11-12 16:08:02 +08:00
codex-bot 9b24319747 docs(canvas): explain legacy resources and geometry ownership 2026-11-12 16:04:32 +08:00
Zayden-Jung b995e659a6 Merge pull request 'docs: 完善协议、公共库与画布迁移注释' (#13) from codex/foundry-protocol-comments into main
Reviewed-on: #13
2026-11-12 08:03:32 +00:00
codex-bot 5803787910 docs(canvas): clarify entry and migration lifecycle 2026-11-12 16:01:25 +08:00
codex-bot 84c50fe595 docs(app): explain request and directory lifecycle boundaries 2026-11-12 15:57:33 +08:00
codex-bot f78618be2b docs(service): clarify Foundry protocol validation boundaries 2026-11-12 15:54:01 +08:00
Zayden-Jung 18913efab7 Merge pull request 'docs(canvas): 完善画布模块与函数的逻辑注释' (#12) from codex/runtime-replay-comments into main
Reviewed-on: #12
2026-11-12 07:50:38 +00:00
codex-bot 9fa0958eb8 docs(service): explain run admission and event validation 2026-11-12 15:46:27 +08:00
codex-bot 43af2aeceb docs(service): explain asset receipts and model stream boundaries 2026-11-12 15:42:24 +08:00
codex-bot 27311e831d docs(bindings): explain React hooks and renderer ownership 2026-11-12 15:37:19 +08:00
codex-bot ce05ab653d docs(bindings): explain canvas projection and connection paint bounds 2026-11-12 15:32:19 +08:00
codex-bot 04c4566679 docs(bindings): explain derived sources and renderer lease contracts 2026-11-12 15:28:00 +08:00
codex-bot 30978320c3 docs(bindings): explain scene patching and paint cache ownership 2026-11-12 15:23:17 +08:00
codex-bot 7297985829 docs(bindings): explain projection geometry and hit testing budgets 2026-11-12 15:18:42 +08:00
codex-bot cb14e8e6d2 docs(bindings): explain input ownership and subscription lifecycle 2026-11-12 15:14:02 +08:00
codex-bot 7cdd2db4d7 docs(runtime): explain public recovery and engine shutdown contracts 2026-11-12 15:09:51 +08:00
codex-bot 75342cb7ad docs(runtime): explain import runner assembly and controls 2026-11-12 15:04:33 +08:00
codex-bot 7e08dd996b docs(runtime): explain engine image and import preparation lifecycle 2026-11-12 15:00:22 +08:00
codex-bot b3a634ce6b docs(runtime): explain engine file checks and response reconciliation 2026-11-12 14:55:15 +08:00
codex-bot 56b83e2009 docs(runtime): explain engine assembly and durable prefix writes 2026-11-12 14:50:08 +08:00
codex-bot 9abbe9c029 docs(runtime): explain recovery format and cross-table validation 2026-11-12 14:44:29 +08:00
codex-bot e425b28064 docs(runtime): explain progressive import publication and recovery 2026-11-12 14:40:09 +08:00
codex-bot 9c1dd28f3a docs(runtime): explain generation polling state and lifecycle 2026-11-12 14:35:57 +08:00
codex-bot 85b038a93f docs(runtime): explain browser storage and depot transaction boundaries 2026-11-12 14:31:58 +08:00
codex-bot 0ca2af7a37 docs(runtime): clarify copy snapshot and journal replay contracts 2026-11-12 14:27:28 +08:00
codex-bot a927f36d96 docs(runtime): explain recovery capture and sealing lifecycle 2026-11-12 14:20:03 +08:00
codex-bot c3131a71bd docs(runtime): explain durable storage and recovery JSON contracts 2026-11-12 14:15:02 +08:00
codex-bot 5f2bf2d142 docs(runtime): explain default image sizing grant lifecycle 2026-11-12 14:09:25 +08:00
codex-bot 63c4098e4e docs(runtime): clarify resource footprints and retention scan ownership 2026-11-12 14:05:32 +08:00
codex-bot 9c6f17f236 docs(runtime): clarify applied recovery staging and port contracts 2026-11-12 14:01:52 +08:00
codex-bot 9c2ecb9fe2 docs(runtime): explain conflict recovery coordination and audit 2026-11-12 13:56:06 +08:00
codex-bot c83641417b docs(runtime): explain mixed history recovery planning lifecycle 2026-11-12 13:52:23 +08:00
codex-bot a3d92f9cd6 docs(runtime): clarify history event sources and traversal recovery 2026-11-12 13:47:41 +08:00
codex-bot 196866aaf3 docs(runtime): explain partial loading coordination and gates 2026-11-12 13:44:12 +08:00
codex-bot 59b59c2f03 docs(runtime): clarify partial loading structure and import gates 2026-11-12 13:39:25 +08:00
codex-bot 437a46ad36 docs(runtime): explain progressive previews and loading asset evidence 2026-11-12 13:33:34 +08:00
codex-bot 2a9667a287 docs(runtime): clarify scoped loader subscriptions and cache lifecycle 2026-11-12 13:30:05 +08:00
codex-bot 3e4833470a docs(runtime): explain host and workspace ownership lifecycles 2026-11-12 13:27:00 +08:00
codex-bot 896e6a0656 docs(runtime): clarify planning checkpoints and scope evidence 2026-11-12 13:23:33 +08:00
codex-bot 17c14c85e8 docs(runtime): explain prepared and lazy copy sources 2026-11-12 13:20:34 +08:00
codex-bot 020fb55084 docs(runtime): explain indexed document and native import conversion 2026-11-12 13:17:27 +08:00
codex-bot 238e4dd4c5 docs(runtime): explain import proof and identity ownership 2026-11-12 13:14:06 +08:00
codex-bot 3c8953e842 docs(runtime): clarify streaming import cursors and byte reads 2026-11-12 13:10:35 +08:00
codex-bot d02cd2b01d docs(runtime): explain import scheduling and dependency selection 2026-11-12 13:07:14 +08:00
codex-bot 2b0afddd07 docs(runtime): explain digest scheduling and streaming boundaries 2026-11-12 13:04:23 +08:00
codex-bot f6acdac8d8 docs(runtime): explain asset display and image decode lifecycles 2026-11-12 13:01:35 +08:00
codex-bot 9ea8463867 docs(runtime): clarify resource and file retention boundaries 2026-11-12 12:58:15 +08:00
codex-bot c491cd023c docs(runtime): explain wire projection and conflict replanning 2026-11-12 12:54:35 +08:00
codex-bot 20bdc7d44a docs(runtime): clarify elision and reconciliation evidence 2026-11-12 12:51:30 +08:00
Zayden-Jung e78c18b697 Merge pull request 'docs: 完善画布注释并复核核心数据边界' (#11) from codex/storage-case-comments into main
Reviewed-on: #11
2026-11-12 04:50:34 +00:00
codex-bot 56a9806bc0 docs(runtime): clarify ledger authority and scheduler durability boundaries 2026-11-12 12:44:51 +08:00
codex-bot dadfd0e41d docs(core): clarify query completeness and public type guarantees 2026-11-12 12:39:22 +08:00
codex-bot 5fd8312ef5 docs(core): clarify view session and gesture lifecycle boundaries 2026-11-12 12:35:23 +08:00
codex-bot f1c5853fea docs(core): clarify feature schemas and bounded geometry queries 2026-11-12 12:32:01 +08:00
codex-bot 22119358f5 docs(core): clarify copy checkpoint references and dissolve planning 2026-11-12 12:28:44 +08:00
codex-bot aac3813ce4 docs(core): clarify creation sizing and structural planning boundaries 2026-11-12 12:25:10 +08:00
codex-bot 8e3ad8c2a2 docs(core): clarify editor publication and history traversal contracts 2026-11-12 12:21:31 +08:00
codex-bot db3eb8ec5d docs(core): clarify capture validation and replay guarantees 2026-11-12 12:17:04 +08:00
codex-bot cda4db5ec8 docs(config): explain package settings and generated JSON contracts 2026-11-12 12:12:22 +08:00
codex-bot fe80df375c docs(styles): explain global page sections and responsive cascade 2026-11-12 12:07:45 +08:00
codex-bot 4f75dcb9fd docs(styles): explain canvas and workspace layout and interaction states 2026-11-12 12:02:26 +08:00
codex-bot 2a0b6bb9d9 docs(tests): explain legacy canvas scenario setup and recovery flows 2026-11-12 11:57:00 +08:00
codex-bot 412e31e898 docs(tests): explain legacy canvas and workspace unit coverage 2026-11-12 11:51:14 +08:00
codex-bot 6ad003eb37 docs(tests): explain renderer recovery AI import and retained ownership flows 2026-11-12 11:43:08 +08:00
codex-bot 50675ffed3 docs(tests): explain renderer fixture setup proxy routes and native gestures 2026-11-12 11:36:18 +08:00
codex-bot bd728d2d94 docs(tests): explain storage collection import recovery and loading barriers 2026-11-12 11:29:07 +08:00
Zayden-Jung 6298202dd7 Merge pull request 'docs(tests): 说明浏览器驱动、React fixture 与渲染探针' (#10) from codex/browser-fixture-comments into main
Reviewed-on: #10
2026-11-12 03:26:06 +00:00
codex-bot f08310b83c docs(tests): explain browser storage history recovery and suite orchestration 2026-11-12 11:21:50 +08:00
codex-bot e0e4276e71 docs(tests): explain browser drivers React fixtures and renderer probes 2026-11-12 11:13:08 +08:00
Zayden-Jung 167e01af18 Merge pull request 'docs(tests): 说明画布运行时、恢复和集成测试' (#9) from codex/worker-test-comments into main
Reviewed-on: #9
2026-11-12 03:06:28 +00:00
codex-bot 9d788e1712 docs(tests): explain service contracts proxy and packaged integration consumers 2026-11-12 11:01:48 +08:00
codex-bot bd4f1a34a0 docs(tests): explain engine recovery cuts conflict plans and lifecycle guards 2026-11-12 10:53:33 +08:00
codex-bot dd36d4f795 docs(tests): explain AI run admission recovery controls and output ownership 2026-11-12 10:44:54 +08:00
codex-bot 9ae7d2d56a docs(tests): explain legacy migration gates receipts and UI recovery 2026-11-12 10:36:36 +08:00
codex-bot bad68c6d59 docs(tests): explain partial model editing and loading protocols 2026-11-12 10:32:05 +08:00
codex-bot a1e3a32b61 docs(tests): explain model streams previews and scoped read caches 2026-11-12 10:26:33 +08:00
codex-bot 7b0e734e16 docs(tests): explain engine import publication recovery and source seals 2026-11-12 10:20:30 +08:00
codex-bot d5ab402592 docs(tests): explain progressive import parsers proofs and runner 2026-11-12 10:14:58 +08:00
codex-bot 3714af788f docs(tests): explain deep copy sources checkpoints and input probe 2026-11-12 10:09:17 +08:00
codex-bot 2f55f59bfc docs(tests): explain worker pools hashes and copy budgets 2026-11-12 10:03:55 +08:00
Zayden-Jung dc0f25cad0 Merge pull request 'docs(tests): 说明绘制、同步、恢复与资源测试' (#8) from codex/paint-test-comments into main
Reviewed-on: #8
2026-11-12 02:01:13 +00:00
codex-bot 7de9a3ea1d docs(tests): explain project resource lifecycle and collection 2026-11-12 10:00:05 +08:00
codex-bot 3cda36b685 docs(tests): explain asset adoption retention and depot storage 2026-11-12 09:56:11 +08:00
codex-bot 1a0d9cdc8a docs(tests): explain structural recovery and history adoption 2026-11-12 09:50:31 +08:00
codex-bot 53905a4868 docs(tests): explain recovery encoding journals and applied receipts 2026-11-12 09:45:10 +08:00
codex-bot 298f4b36c0 docs(tests): explain ledger scheduling replay and local elision 2026-11-12 09:41:49 +08:00
codex-bot c426e94b58 docs(tests): explain retained views resource handles and batch proofs 2026-11-12 02:43:36 +08:00
codex-bot 330d8d6e34 docs(tests): explain paint bounds hit queries and scene caching 2026-11-12 02:40:06 +08:00
Zayden-Jung ea102b9949 Merge pull request 'docs(tests): 说明核心、几何、输入与订阅测试' (#7) from codex/core-test-comments into main
Reviewed-on: #7
2026-11-11 18:39:28 +00:00
Zayden-Jung efcec4d603 Merge pull request 'docs(tooling): 说明构建脚本、配置与 SDK 宿主示例' (#6) from codex/build-script-comments into main
Reviewed-on: #6
2026-11-11 18:39:22 +00:00
Zayden-Jung e75b9ed5e4 Merge pull request 'docs(canvas): 说明新旧主画布视图与交互适配' (#5) from codex/headless-view-comments into main
Reviewed-on: #5
2026-11-11 18:39:10 +00:00
codex-bot 74748d6e23 docs(tests): explain input routing and subscription lifecycle coverage 2026-11-12 02:36:36 +08:00
codex-bot 3da19e288a docs(tests): explain query geometry and creation history scenarios 2026-11-12 02:33:00 +08:00
codex-bot 47e318682e docs(tests): explain core invariants and history evidence scenarios 2026-11-12 02:29:37 +08:00
codex-bot 9946142d5e docs(tooling): explain SDK packaging and icon build configuration 2026-11-12 02:25:44 +08:00
codex-bot 1c832988a2 docs(canvas): explain legacy editor tools and subgraph interactions 2026-11-12 02:21:27 +08:00
codex-bot 95e8a57019 docs(canvas): explain headless view subscriptions and interaction adapters 2026-11-12 02:17:32 +08:00
Zayden-Jung 6b74629d98 Merge pull request 'docs(canvas): 说明旧画布树状态与事务协调' (#4) from codex/canvas-tree-comments into main
Reviewed-on: #4
2026-11-11 18:16:16 +00:00
codex-bot 33f5c076f6 docs(canvas): explain legacy tree state and transaction coordination 2026-11-12 02:13:31 +08:00
Zayden-Jung bcf810998d Merge pull request 'docs(canvas): 补充加载预览与旧任务 hooks 注释' (#3) from codex/canvas-panel-comments into main
Reviewed-on: #3
2026-11-11 18:10:50 +00:00
Zayden-Jung fb737eeb0b Merge pull request 'docs(canvas): 补充任务面板注释并恢复 PR 交付' (#2) from codex/canvas-panel-comments into main
Reviewed-on: #2
2026-11-11 18:07:38 +00:00
332 changed files with 6172 additions and 929 deletions
+2
View File
@@ -1,2 +1,4 @@
# Next 服务端访问 Foundry 的源站;本地代理从这里发起后端 HTTP 请求,不是浏览器页面地址。
FOUNDRY_API_ORIGIN=https://api.foundry.entroia.com
# 公开的项目路由标识,可进入浏览器构建;不是密码、会话令牌或授权凭证。
NEXT_PUBLIC_FOUNDRY_PROJECT=endoleap
+7
View File
@@ -1,8 +1,15 @@
# 安装依赖不入库;依赖版本由包清单与 lock 文件记录。
node_modules/
# Next 开发/生产构建缓存与产物,不作为源代码提交。
.next/
# 独立 headless ESM 包由 scripts/build-headless.mjs 重建。
.headless-dist/
# 忽略本机环境变量文件;下一条只放行可公开的示例模板。
.env*
!.env.example
# TypeScript 增量检查缓存,由工具重新生成。
*.tsbuildinfo
# macOS Finder 的目录元数据。
.DS_Store
# 仓库根 docs 是本地研究/草案目录,当前约定不跟踪其中内容。
/docs/
+70
View File
@@ -142,3 +142,73 @@ Core `subgraphs.member_positions` 能力启用时,最终落点(源画布局
上传图片时立即在画布显示本地预览,并在后台上传原始文件;上传成功才把稳定附件引用加入快照。服务端预览加载完成前继续显示本地图片,避免网络等待期间空白。失败保留文件供重试/取消,重试沿用同一幂等键;本地 blob URL 不保存到服务端。
上传中的图片也是正常本地节点,可立即选中、拖动、缩放、改名、删除和撤销;上传完成只补齐附件引用,不重置节点。上传前改变分区归属会先在本地完成,文件准备好后保存到当时的实际所属画布。临时上传标记和 blob 地址由传输层排除,不发送给 Core;文件仍未可用时,依赖服务端文件的 AI 生成需等待上传完成。
## 配置与生成数据阅读说明
本节为不适合内嵌注释的配置和严格 JSON 提供旁注;实际值仍以对应文件为准。源码、脚本和测试中的函数说明保留在各自文件内。包版本、TypeScript 输出目标、服务协议版本、文档格式版本和组件数据版本是不同概念。
### 应用包与执行入口
根 `package.json` 的 `private: true` 防止常规 npm 发布,`version: 0.1.0` 是应用包版本。`dependencies` 是应用运行/构建引用的库,`devDependencies` 包含编译器、类型声明和 Tailwind 工具;它们的精确版本及传递依赖由 `package-lock.json` 锁定。lock 文件由包管理器维护,不在其中手写说明或改某个嵌套版本。
| scripts 项 | 执行内容与边界 |
| --- | --- |
| `dev` | 在 localhost:17321 启动 Next 开发服务器,会按需编译。 |
| `build` / `start` | 前者执行 Next 生产构建,后者在 localhost:17321 运行已有构建。 |
| `typecheck` | `tsc --noEmit` 检查类型,不生成应用 JS。 |
| `test:headless` | 运行 `tests/headless/*.test.mjs`;不自动执行所有 `.integration.mjs` 或浏览器场景。 |
| `test:headless:browser` | 浏览器存储、Worker 等适配器场景入口,环境装配见 `browser-storage.mjs`。 |
| `test:headless:proxy` | Foundry 同源代理的隔离集成入口,启动与替身链路见 `proxy.integration.mjs`。 |
| `test:headless:renderer` / `test:headless:performance` | 运行页面集成入口;后者附加 `--performance` 选择性能场景。 |
| `build:headless` | 清理并重建 `.headless-dist` 的 ESM、声明和 Worker 引用,再复制包清单/文档。 |
| `test:headless:package` | 构建、打包到隔离目录并运行消费方及浏览器验收,不是单纯语法检查。 |
集成入口可能编译或启动本地进程,具体动作见各脚本模块注释;本节列出命令不表示这些检查已经运行或通过。
### 应用 TypeScript 配置
`tsconfig.json` 面向应用检查;以下选项共同作用,并非运行时开关:
| 配置项 | 用途 |
| --- | --- |
| `target: ES2020` / `lib` | 声明语言输出目标和可用类型库;`dom`、`dom.iterable`、`esnext` 提供 API 类型,不会安装浏览器 polyfill。 |
| `allowJs` / `skipLibCheck` | 允许 JS 进入项目;跳过声明文件内部的完整检查。它们不关闭项目 TS 源码的检查。 |
| `strict` / `noEmit` | 启用严格类型检查,默认只检查而不生成 JS/声明。 |
| `allowImportingTsExtensions` | 允许源码相对导入使用 `.ts` 等 TS 扩展名;独立包输出由另一配置重写扩展名。 |
| `esModuleInterop` | 为 CommonJS/ESM 互操作提供 TypeScript 的兼容处理,不把整个项目改成 CommonJS。 |
| `module: esnext` / `moduleResolution: bundler` | 使用 ESM 语法并按 bundler 模式解析依赖;应用最终交由 Next 构建链处理。 |
| `resolveJsonModule` | 允许 TS 引用并推断 JSON 模块,例如图标 manifest。 |
| `isolatedModules` / `jsx: react-jsx` | 按单文件转换约束检查源码,并使用 React 自动 JSX 转换形式。 |
| `incremental` | 允许 TypeScript 使用增量检查缓存;生成的 `*.tsbuildinfo` 被忽略。 |
| `paths` | `@/*` 在 TS 解析时指向 `src/*`,这不是把字符串路径写进业务文档。 |
| `plugins` | 登记 Next TypeScript 插件供支持它的工具使用。 |
| `include` / `exclude` | 纳入应用 TS/TSX、`next-env.d.ts` 与 Next 生成类型入口;排除依赖和独立包输出目录。由这些源码导入的依赖仍可参与类型解析。 |
`next-env.d.ts` 是 Next 维护的类型入口,引用框架/图片和生成路由类型。其路径会随开发/构建环境变化,不在这个生成文件中追加维护注释。
### 独立 headless 包
`tsconfig.headless.json` 继承应用配置,显式覆盖输出部分:`target`/`module` 为 ES2022,`rootDir` 为 `src/lib`,`outDir` 为 `.headless-dist/modules`;`noEmit: false` 与 `declaration: true` 生成 JS 和 `.d.ts`。`incremental: false` 不使用这次编译的增量缓存,`rewriteRelativeImportExtensions` 将可重写的相对 TS 导入转成输出扩展名。`types: []` 和 `plugins: []` 清空自动纳入的类型包列表与继承插件条目,不禁止源码显式导入类型;未覆盖的 `lib` 等项仍继承。四组 `include` 从 core/runtime/service/bindings 的 `.ts` 入口收集源码,导入关系还会带入所需依赖。
`headless/package.json` 是复制到产物根的包模板,源目录本身没有生成后的 `modules`。它的 `type: module` 声明 ESM,Node 引擎要求为 `>=22`。`exports` 为每个公开入口分别指定类型声明和 ESM 文件:根及 `/core` 指向 core,另有 `/runtime`、`/browser`、`/service`、`/bindings`、`/react-flow` 与 `/react`。这些是产物目录中的路径,不是指向 `src` 的别名,也没有声明 CommonJS `require` 入口。
`files` 限定包收录的模块、消费示例和验收文档。React、React DOM、React Flow 以带版本范围的可选 peer 声明,供选择相关适配入口的宿主提供;“可选”不表示调用 UI 适配器时可以缺少它依赖的框架。便携入口的使用和依赖边界见 `headless/README.md`。`private: true` 保持预览包私有,不代表已经发布到 registry。
### 组件生成器配置
`components.json` 是 UI 组件生成器读取的配置,不是应用运行时组件 registry。`$schema` 指向配置的 schema;`style: new-york`、`tsx: true` 和 `rsc: false` 记录生成风格、TSX 和 RSC 选项,不改变整个 App Router 的路由/渲染模式。`tailwind.css` 指向 `src/styles/globals.css`,空 `tailwind.config` 没有指定独立配置路径,`baseColor: neutral` 与 `cssVariables: true` 是生成器的配色约定。`aliases` 指示生成文件的组件、工具、UI、lib 和 hooks 导入位置;其中 `@/hooks` 不保证已经存在对应目录。
### 图标生成数据
`src/components/canvas/icon-catalog-data.json` 是检索目录数组,每项包含 `name`(持久引用的图标 ID)、`slug`、`source`、`label`、`categories`、`common` 和 `keywords`;不包含 SVG 路径。`icon-catalog.ts` 用它构建名称 Map 并执行筛选,选择器的虚拟窗口另由 UI 控制。
`src/lib/canvas-icon-assets.json` 保存 `root` URL 前缀与 `assets` 桶名到摘要文件名的映射。`iconAsset()` 对 Lucide 名称做 31 进制滚动散列并模 96,Tabler 名称统一使用 `tabler` 桶。`public/canvas-icons/<版本目录>/*.json` 每个分片以图标名索引 `[tag, props][]`,消费端 `validateIconShard()` 再检查支持的图形与属性。
这三类数据由 `scripts/generate-canvas-icons.mjs` 同批生成,中文标注来自 `scripts/canvas-icon-labels.mjs`。文件名摘要是紧凑 JSON 内容的 SHA-256 前 12 位,写盘时另加换行;它与分桶散列用途不同。分片变更须同步目录/manifest 并更新摘要名;脚本不会自动删除旧分片。不要在 JSON 中插入注释或只手改其中一份,否则会破坏读取、引用或缓存命名约定。
### 协议样例与忽略规则
`tests/headless/fixtures/foundry-model-operation-v2.json` 是服务适配器单测的完整请求样例。协议 2 的 `request_id` 标识冻结请求,`expected_versions` 是参与 canvas 的已确认版本,`operations[].id` 是逻辑操作 ID。样例把第一代元素 `a` 的 `data.title` 从 `a` 改为 `renamed`;`beforePresent`/`afterPresent` 区分字段缺失与有值。字段旁注与断言在 `tests/headless/service.test.mjs`,静态样例不代表真实服务已执行。
`.gitignore` 排除依赖、Next/headless 产物、环境变量、增量缓存和系统文件;仅放行 `.env.example` 作为模板。根 `/docs/` 当前作为本地研究目录忽略。忽略规则只影响未跟踪文件,不会把已经跟踪的文件自动移出 Git。`.env.example` 的公开项目标识与服务端源站说明写在模板内;实际凭据不应写进示例或公开构建变量。
+31
View File
@@ -1,3 +1,8 @@
/**
* 独立SDK的第二宿主示例与可执行断言:用自定义counter组件串起命令、事务拒绝、历史、手势、深复制、重开及投影命中。
* 包导入只使用core/runtime/bindings/service,验证这条路径不需要React、DOM或Next;示例需在已构建SDK包的解析环境执行。
* 存储与协议transport都是进程内模型,用于演示port契约,不实现生产数据库、身份鉴权、跨进程锁或崩溃持久化。
*/
/** 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.
@@ -9,15 +14,24 @@ 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'};
// 内置组件之外注册counter:count要求非负安全整数,能力与矩形几何显式声明,资源与复制引用列表为空。
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:[]}})]);
// 空项目只有根canvas;server/version模拟服务端基线,image模拟本地恢复镜像,两者职责分开。
const original=createDocument(scope.projectId,[{id:scope.projectId,parent:null}],[]);
let image=null,leader,fence=0,server=original,version=1;
// files保存示例字节,requests按requestId保存原body和回执,供相同请求重试查询;均在进程结束后丢失。
const files=new Map(),requests=new Map();
/** 通过对象身份及active校验本地leader句柄;仅演示单进程fence,不是数据库条件写。 */
const check=handle=>{assert.strictEqual(handle,leader);assert.equal(handle.active,true);};
// StoragePort示例:claim取得独占句柄,recover读克隆镜像,commit先准备镜像/文件再一次同步发布。
const storage={
/** 只允许一个活跃leader并递增fence;release幂等失效该句柄,不清除已提交镜像。 */
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;},
/** 返回镜像克隆,调用方不能直接修改存储事实;空image代表首次打开。 */
async recover(handle){check(handle);return structuredClone(image);},
/** 先检查文件新增不覆盖和删除列表,准备完成才发布;返回实际镜像rootRevision作为本地确认。 */
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);}
@@ -25,11 +39,15 @@ const storage={
// 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};
},
/** 文件读取也校验leader;close断言所有engine已释放领导权,不负责清理生产存储。 */
async readFile(handle,id){check(handle);return files.get(id);},close(){assert.equal(leader,undefined);},
};
/** 将模拟server的Map转成协议模型数组,带当前版本与墓碑;不把整个engine状态冒充服务端模型。 */
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:[]});
/** 最小协议2传输适配:模型GET、请求回执GET及操作POST;未实现任意Foundry端点或HTTP鉴权。 */
const transport=async input=>{
input.signal?.throwIfAborted();
// 请求身份查询没命中时返回unknown,不伪造确定未应用回执;本示例记录不跨进程保存。
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);
@@ -37,32 +55,45 @@ const transport=async input=>{
}
const body=JSON.parse(input.body),known=requests.get(input.requestId);
assert.equal(body.protocol_version,2);assert.equal(body.request_id,input.requestId);
// 已有requestId只能复用完全相同的body并返回原回执,防止幂等键承载两种意图。
if(known){assert.equal(known.body,input.body);return{status:200,body:known.receipt};}
// 示例只含一个canvas版本,先要求版本一致,再应用变更并验证整个文档;每次接受使版本递增一次。
assert.deepEqual(body.expected_versions,{[scope.projectId]:version});
server=applyChanges(server,body.operations.flatMap(operation=>operation.changes));validateDocument(server,registry);version++;
// 回执逐一覆盖operations逻辑ID,并携complete=false的模型切片;记录原body方便重试校验。
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};
};
// service把transport包成runtime持久化接口;open示例总以初始模型输入,由StoragePort恢复已提交本地状态。
const persistence=createFoundryPersistence({workspaceId:scope.workspaceId,rootId:scope.projectId,registry,transport});
/** 新建engine注入scope/registry/storage/persistence与ID生成器,所有宿主依赖显式传入。 */
const open=()=>openProjectEngine({scope,registry,document:original,versions:new Map([[scope.projectId,1]]),storage,persistence,createId:randomUUID});
let engine=await open();
try{
// 先创建一个counter并settle,后续断言分别验证本地发布及模拟server状态;元素带独立incarnation。
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();
// 非法事务先移动再写负count,最终必须整体rejected且根对象不变,不能泄露事务中间结果。
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);
// 合法事务将移动和增量组合成一个历史单元;undo/redo之后均等待同步,再检查server的count。
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);
// 临时手势的有效几何可变化,但cancel后文档仍x=32且请求数不增加,证明预览未被当持久编辑发送。
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);
// 深复制走imports任务并等待settle,检查任务完成及本地/模拟服务端元素数;随后验证service模型可读。
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);
// 关闭再打开依赖image恢复内容、历史和ledger;视图属于临时session,重开后不恢复。
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'));
// 自定义renderer只声明记录投影,不引入UI组件;配查询与几何缓存生成场景,再验证纯数据命中结果。
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));
// 输出可机器读取的结果摘要;finally即使断言失败也关闭engine并验证存储领导权已释放。
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();}
+9
View File
@@ -1,6 +1,12 @@
/**
* Next应用配置。为webpack与Turbopack安装相同ReactFlow编译扩展,并给带内容摘要的图标分片设置长期缓存。
* 此处只配置应用编译/响应头,不改变headless SDK导出或服务端数据协议。
*/
import type { NextConfig } from "next";
/** StrictMode显式开启开发检查,poweredByHeader关闭Next标识响应头;其余保持框架默认行为。 */
const config: NextConfig = {
reactStrictMode: true, poweredByHeader: false,
/** 在现有规则后追加匹配ReactFlow ESM入口的loader,再返回原配置对象,保留框架其它规则。 */
webpack(config) {
config.module.rules.push({
test: /[\\/]@xyflow[\\/]react[\\/]dist[\\/]esm[\\/]index\.(?:mjs|js)$/,
@@ -8,6 +14,7 @@ const config: NextConfig = {
});
return config;
},
// Turbopack使用条件路径匹配同一个入口;单独配置以免切换构建器时丢失保留节点扩展。
turbopack: {
rules: {
"*": {
@@ -16,6 +23,8 @@ const config: NextConfig = {
},
},
},
/** 只给版本目录内带12位十六进制摘要的JSON分片配置一年immutable缓存,文件内容变化应生成新文件名。
* 不对图标目录manifest、页面或全部JSON一概设置immutable。 */
async headers() {
return [{
source: "/canvas-icons/:version/:asset([a-z0-9-]+-[a-f0-9]{12}\\.json)",
+4
View File
@@ -1 +1,5 @@
/**
* CSS构建入口:启用项目安装的Tailwind PostCSS插件,供Next样式处理链读取。
* 插件选项为空对象表示使用默认配置;这里不执行CSS生成,也不在导入时启动构建。
*/
export default { plugins: { "@tailwindcss/postcss": {} } };
+11
View File
@@ -1,11 +1,19 @@
/**
* 独立headless SDK的产物生成脚本。先清理.headless-dist,再用tsconfig.headless.json编译四个库目录,修正声明与Worker引用。
* 最后复制包清单、无UI宿主示例和验收文档;失败立即抛出,不把半成品报告成可用包。执行会删除并重建该产物目录。
*/
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';
// 路径基于脚本URL而非当前终端目录;tsc仍显式以仓库根作为cwd解析项目依赖。
const root=resolve(fileURLToPath(new URL('..',import.meta.url))),output=join(root,'.headless-dist');
await rm(output,{recursive:true,force:true});
// 同步等待TypeScript生成ESM及声明;继承终端输出,编译失败直接中止后续处理。
execFileSync(process.execPath,[join(root,'node_modules/typescript/bin/tsc'),'-p',join(root,'tsconfig.headless.json')],{cwd:root,stdio:'inherit'});
/** 递归只处理.d.ts的相对模块说明符,将源.ts后缀改为.js;普通schema/data字符串不做全局替换。
* 这是针对当前输出格式的正则修补,不是任意TypeScript语法的完整重写器。 */
async function declarations(directory){
for(const entry of await readdir(directory,{withFileTypes:true})){
const path=join(directory,entry.name);
@@ -19,12 +27,15 @@ async function declarations(directory){
}
}
}
// 原生编译器处理运行时import后缀,声明文件仍需此补充;完成后再修URL字面量里的Worker源后缀。
await declarations(join(output,'modules'));
// 两个浏览器适配器各应含一个预期Worker URL;要求目标.js文件存在并且源字符串只出现一次才改写。
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`));
}
// 产物保留与headless/package.json的exports/files配套的模块布局;这里复制清单,不执行npm发布或包验收测试。
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');
+5
View File
@@ -1,3 +1,7 @@
/**
* 图标资源生成时使用的中文标注表。作为静态元数据输入给generate-canvas-icons,不在运行时逐个请求翻译。
*/
/** 完整PascalCase图标名到中文标签的人工覆盖表;未列出的图标由生成脚本回退slug拆词名称。 */
export const chinese = {
"Star": "星星",
"Heart": "爱心",
@@ -45,4 +49,5 @@ export const chinese = {
"Triangle": "三角形",
"Diamond": "菱形"
};
/** slug单词到中文检索词,生成时按连字符拆分并拼接;一个词可扩展多个搜索同义词。 */
export const keywords = { arrow: "箭头 方向", user: "用户 人物", file: "文件 文档", folder: "文件夹 目录", heart: "爱心 喜欢", calendar: "日历 日期", clock: "时间 时钟", image: "图片 图像", camera: "相机 摄影", video: "视频 电影", music: "音乐 音频", cloud: "云 天气", sun: "太阳 天气", moon: "月亮 夜晚", circle: "圆 圆形", square: "方形 正方形", triangle: "三角形", building: "建筑 公司", book: "书 阅读", code: "代码 编程", chart: "图表 数据", mail: "邮件 信封", message: "消息 对话", map: "地图 位置", search: "搜索 查找", settings: "设置", lock: "锁 安全", key: "钥匙 密钥", star: "星星 收藏", check: "确认 完成", chevron: "箭头 展开", laptop: "电脑 笔记本", monitor: "屏幕 显示器", smartphone: "手机", shopping: "购物 商店", dollar: "货币 金钱", euro: "货币 欧元", zodiac: "星座", tree: "树 植物", flower: "花 植物", leaf: "叶子 植物", dog: "狗 动物", cat: "猫 动物", fish: "鱼 动物", coffee: "咖啡 饮料", food: "食物", smile: "表情 笑脸", angry: "表情 生气", frown: "表情 难过", paint: "绘画 颜色", palette: "调色板 颜色", text: "文本 文字", type: "字体 文字", grip: "拖动", move: "移动", zoom: "缩放", eye: "眼睛 显示", accessibility: "无障碍", audio: "音频 声音", battery: "电池", network: "网络 连接", wifi: "无线 网络", globe: "地球 全球", bell: "铃铛 通知", badge: "徽章", shield: "盾牌 安全", pencil: "铅笔 编辑", pen: "笔 写作", trash: "垃圾 删除", undo: "撤销", redo: "重做", rocket: "火箭", sparkles: "闪光 灵感", lightbulb: "灯泡 灵感" };
+21
View File
@@ -1,3 +1,7 @@
/**
* 重建画布图标目录与路径分片的离线资源脚本。使用已安装Lucide与指定本地Tabler包,写目录JSON、摘要文件名分片和许可证。
* 版本检查固定输入格式;不会联网下载包,也不删除旧摘要分片,调用者需按脚本注释准备输入并从仓库根执行。
*/
// Reproduce with installed lucide-react 1.69.0 and an extracted @tabler/icons 3.50.0
// tarball: node scripts/generate-canvas-icons.mjs /path/to/tabler/package
import { readFile, writeFile, mkdir } from "node:fs/promises";
@@ -6,13 +10,16 @@ import { resolve } from "node:path";
import dynamicIconImports from "lucide-react/dynamicIconImports";
import { chinese, keywords } from "./canvas-icon-labels.mjs";
// 命令行参数是已解压Tabler包目录;同时校验两套依赖版本,防止升级后未经审查改变SVG/导出结构。
const tablerDir = process.argv[2];
if (!tablerDir) throw new Error("Pass the extracted @tabler/icons 3.50.0 directory");
const tablerPackage = JSON.parse(await readFile(resolve(tablerDir, "package.json"), "utf8"));
if (tablerPackage.version !== "3.50.0") throw new Error("Expected @tabler/icons 3.50.0");
const lucidePackage = JSON.parse(await readFile("node_modules/lucide-react/package.json", "utf8"));
if (lucidePackage.version !== "1.69.0") throw new Error("Review generated assets when upgrading Lucide");
/** slug按连字符转PascalCase;首字符非字母时加Icon前缀,形成稳定的目录ID。 */
const pascal = (slug) => { const value = slug.split("-").map((word) => word[0].toUpperCase() + word.slice(1)).join(""); return /^[A-Z]/.test(value) ? value : "Icon" + value; };
/** 用slug正则派生分类,一个图标可匹配多个;未匹配的记录categories为空,UI可归“其他”。 */
const categories = [
["arrows", /arrow|chevron|navigation|corner|undo|redo|move|expand|shrink/],
["people", /user|person|baby|smile|frown|angry|laugh|heart|hand|thumb|accessibility/],
@@ -24,17 +31,21 @@ const categories = [
["travel", /map|pin|compass|globe|plane|train|car|bus|bike|ship|boat|building|house|home|tent|hotel|lighthouse|rocket|ufo/],
["shapes", /circle|square|triangle|hexagon|pentagon|octagon|diamond|star|shape|cube|sphere|box/],
];
/** Lucide常用集合用于默认分类;Tabler试用集稍后全部标记common。 */
const common = new Set(["Star", "Heart", "Lightbulb", "Sparkles", "Zap", "Target", "Check", "Smile", "Coffee", "Leaf", "Sun", "Moon", "Globe", "MapPin", "ArrowRight", "BookOpen", "FileText", "Folder", "Calendar", "Users", "Image", "Camera", "Music", "Palette"]);
// Map以唯一name聚合元数据和SVG节点,Lucide加载器的__iconData提供路径,尚未在此写最终分片。
const entries = new Map();
for (const [slug, load] of Object.entries(dynamicIconImports)) {
const name = pascal(slug);
const { __iconData } = await load();
entries.set(name, { name, slug, source: "lucide", label: chinese[name] || slug.replaceAll("-", " "), node: __iconData.node });
}
// Tabler只引入这24个outline图标,labels按同一索引配对;名称加Tabler前缀避免与Lucide冲突。
const tabler = ["alien", "ufo", "robot", "brain", "circuit-cell", "cube-3d-sphere", "hexagon-3d", "building-lighthouse", "cat", "dog", "fish", "leaf", "sun", "moon", "rocket", "bulb", "heart", "star", "photo", "movie", "music", "palette", "coffee", "mountain"];
const tablerLabels = ["外星人", "飞碟", "机器人", "大脑", "电路", "立体球", "立体六边形", "灯塔", "猫", "狗", "鱼", "叶子", "太阳", "月亮", "火箭", "灯泡", "爱心", "星星", "照片", "电影", "音乐", "调色板", "咖啡", "山峰"];
for (const [index, slug] of tabler.entries()) {
const svg = await readFile(resolve(tablerDir, "icons/outline", slug + ".svg"), "utf8");
// 仅提取已知SVG图形标签与双引号属性,把连字符属性转为React驼峰;不是任意SVG/HTML的通用解析器。
const node = [...svg.matchAll(/<(path|circle|ellipse|rect|line|polyline|polygon)\s+([^>]+?)\/?\s*>/g)].map((match, index) => [match[1], Object.fromEntries([
["key", String(index)], ...[...match[2].matchAll(/([\w-]+)="([^"]*)"/g)].map(([, key, value]) => [key.replace(/-([a-z])/g, (_, char) => char.toUpperCase()), value]),
])]);
@@ -42,19 +53,24 @@ for (const [index, slug] of tabler.entries()) {
const name = "Tabler" + pascal(slug);
entries.set(name, { name, slug, source: "tabler", label: tablerLabels[index], node });
}
// 名称排序固定目录与后续分片插入顺序;相同输入有助于复现内容摘要。
const records = [...entries.values()].sort((a, b) => a.name.localeCompare(b.name, "en"));
const shards = new Map();
/** 简单32位滚动hash模96,把Lucide名称稳定分桶;不是安全摘要,文件内容防缓存混淆另用SHA-256。 */
const bucket = (name) => { let hash = 0; for (const char of name) hash = (hash * 31 + char.charCodeAt(0)) >>> 0; return hash % 96; };
// 目录去掉SVG node,仅保留检索元数据;路径放入分片,Lucide分96桶,Tabler集中一个桶。
const catalog = records.map(({ node, ...entry }) => {
const key = entry.source === "tabler" ? "tabler" : `lucide-${bucket(entry.name)}`;
if (!shards.has(key)) shards.set(key, {});
shards.get(key)[entry.name] = node;
return { ...entry, categories: categories.filter(([, regex]) => regex.test(entry.slug)).map(([id]) => id), common: common.has(entry.name) || entry.source === "tabler", keywords: entry.slug.split("-").map((word) => keywords[word] || "").join(" ") };
});
// 输出目录带两套版本,manifest把分片逻辑key映射到真实文件名;不在运行时猜hash路径。
const assetDir = "public/canvas-icons/lucide-1.69.0-tabler-3.50.0";
await mkdir(assetDir, { recursive: true });
let totalBytes = 0;
const assets = {};
// 每个分片用紧凑JSON计算SHA-256前12位作文件名,写文件额外加换行;totalBytes统计未加换行的内容字节。
for (const [key, nodes] of shards) {
const content = JSON.stringify(nodes);
const hash = createHash("sha256").update(content).digest("hex").slice(0, 12);
@@ -63,8 +79,13 @@ for (const [key, nodes] of shards) {
await writeFile(`${assetDir}/${filename}`, content + "\n");
totalBytes += Buffer.byteLength(content);
}
// 两份生成JSON分别服务搜索目录与按需加载器;严格JSON不能内嵌注释,其结构与生成流程在本脚本说明。
// catalog 每项是 name/slug/source/label/categories/common/keywords;不带 SVG node,搜索只读取这些元数据。
// manifest.root 为公开 URL 前缀,assets 将 lucide-N 或 tabler 逻辑桶映射到带内容摘要的 JSON 文件名。
// 分片对象按图标 name 索引 [tag, props][];props 为可序列化 SVG 属性,消费端还会执行标签/属性校验。
await writeFile("src/components/canvas/icon-catalog-data.json", JSON.stringify(catalog) + "\n");
await writeFile("src/lib/canvas-icon-assets.json", JSON.stringify({ root: "/" + assetDir.replace(/^public\//, ""), assets }) + "\n");
// 分发路径数据同时复制原包许可证;最后只打印资源统计,不启动应用或验证浏览器显示。
await writeFile(`${assetDir}/LUCIDE-LICENSE.txt`, await readFile("node_modules/lucide-react/LICENSE"));
await writeFile(`${assetDir}/TABLER-LICENSE.txt`, await readFile(resolve(tablerDir, "LICENSE")));
console.log(`${records.length} icons (${records.filter((item) => item.source === "tabler").length} Tabler), ${shards.size} data shards, ${totalBytes} uncompressed bytes`);
+10
View File
@@ -1,15 +1,25 @@
/**
* 应用编译期的ReactFlow可见性扩展。为视口外但仍有焦点/手势/浮层的显式保留对象扩展原生可见集合。
* 只转换匹配到的编译输入字符串,不修改共享node_modules文件,也不进入独立headless SDK的运行逻辑。
*/
// 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.
// 读取安装包版本,固定12.12.1;库升级必须人工审查目标实现,不能凭相同函数名继续替换。
const version = require('@xyflow/react/package.json').version;
/** webpack/Turbopack loader接收源码并返回改写后的源码;任何版本或锚点不符立即失败,防止悄悄失去保留能力。 */
module.exports = function retainVisibility(source) {
if (version !== '12.12.1') throw new Error('Review xyflow retained visibility before upgrading @xyflow/react');
// 四个精确源码锚点覆盖节点查询、边可见性及选择订阅;依赖上游编译格式,不是通用AST补丁。
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');
}
// 节点追加未hidden且显式保留的ID并Set去重;边以保留标记或原可见判断二选一,维持原生索引。
// 最后一处改为空数组不订阅完整选择集合,减少无选择监听时的不必要全图selector工作。
return source
.replace(nodes, `Array.from(new Set([
...${nodes},
@@ -5,7 +5,8 @@
import { Suspense } from "react";
import GraphPage from "@/components/workspace/graph-page";
/** params按当前Next版本await;Suspense给客户端画布准备过程提供状态占位,不在此发模型请求。 */
/** params按当前Next版本await;Suspense为子树渲染时的挂起提供占位,不在此发模型请求。
* params 的 await 在此边界外;GraphPage 在 effect 中加载模型时的进度/错误由自身状态呈现。 */
export default async function Page({ params }: { params: Promise<{ workspace: string; board: string }> }) {
const { workspace, board } = await params;
return (
+2 -1
View File
@@ -8,7 +8,8 @@ import { ActiveBoardProvider } from "@/components/workspace/active-board";
import DashboardSidebar from "./dashboard";
import styles from "@/components/workspace/workspace.module.css";
/** 当前board信息由ActiveBoardProvider跨侧栏/画布共享;Suspense只包侧栏,fallback保留其布局宽度。 */
/** 当前board信息由ActiveBoardProvider跨侧栏/画布共享;Suspense只包侧栏,fallback保留其布局宽度。
* 该边界响应侧栏渲染期间的 suspend,不自动等待侧栏在 effect 中发起的普通请求。 */
export default function DashboardLayout({ children }: { children: ReactNode }) {
return (
<ActiveBoardProvider>
+2 -1
View File
@@ -22,7 +22,8 @@ export const metadata: Metadata = {
},
};
/** 服务端布局输出中文html/body壳;客户端反馈Provider包在runtime宿主外侧,供宿主状态提示使用。 */
/** 服务端布局输出中文html/body壳;客户端反馈Provider包在runtime宿主外侧,供宿主状态提示使用。
* children 作为插槽传入客户端宿主,不会因此把所有子页面模块变成客户端代码;宿主的释放逻辑在自身模块。 */
export default function RootLayout({ children }: { children: ReactNode }) {
return (
<html lang="zh-CN" data-scroll-behavior="smooth">
+4
View File
@@ -35,6 +35,7 @@ export default function Login() {
verify = hash.get("verify_email"),
failure = hash.get("error");
if (!code && !verify && !failure) return;
// 不只删除认证片段,也删除原 query;当前页面没有保留/恢复任意 returnTo 的流程。
history.replaceState(null, "", location.pathname);
if (failure) {
setError("第三方登录未完成,请重新尝试。");
@@ -68,6 +69,7 @@ export default function Login() {
* HTML输入限制给即时反馈,服务端仍负责真实凭证/注册校验。 */
async function submit(e: FormEvent) {
e.preventDefault();
// busy 是显示状态,不是函数入口互斥锁;按钮禁用限制常规点击,submit 本身不检查已在执行。
setBusy(true);
setError("");
setMessage("");
@@ -76,6 +78,7 @@ export default function Login() {
method: "POST",
body: JSON.stringify({ email, password }),
});
// 使用触发本次提交时 render 闭包里的 register;等待期间切换模式不会改变这次请求的后续分支。
if (register)
setMessage("验证邮件已请求发送,请检查邮箱并点击验证链接。");
else router.push("/dashboard");
@@ -98,6 +101,7 @@ export default function Login() {
setError("无法启动登录,请重新尝试。");
}
}
// submit/oauth 同样没有卸载取消或结果代次校验;回调链结束前可继续调用 router/setState。
// 模式切换只清提示,已有邮箱/密码和可见性独立保留。
// 渲染分为品牌故事、模式切换、受控凭证表单、OAuth按钮与demo入口;错误用alert,非错误提示用status。
return (
+2
View File
@@ -21,12 +21,14 @@ export default async function Home() {
signal: AbortSignal.timeout(15000),
});
// 按响应状态确定是否已认证,再丢弃不需要的body;此处不解析profile字段。
// 先保存 HTTP 成功判定;随后 cancel 若抛错,catch 不会把 authenticated 重置为 false。
authenticated = response.ok;
await response.body?.cancel();
} catch {
// An unverified session must not hide the public landing page.
}
}
// redirect 放在上方 try/catch 外,避免把框架用于导航的控制流异常当作 profile 请求失败吞掉。
// 仅在确认成功后跳工作台,未认证时沿用同一静态展示组件。
if (authenticated) redirect("/dashboard");
return <LandingPage />;
+4
View File
@@ -19,6 +19,8 @@ export function CanvasIcon({ name = "Star", size = 24, width = size, height = si
useEffect(() => {
const controller = new AbortController();
setResult({ name, paths: peekIcon(name) });
// 清理通过订阅 signal 取消,但 then 没有额外 active/代次检查;已兑现 Promise 的回调仍可能运行。
// result.name 只能隔离不同名称,同名重试之间不靠此字段区分代次。
loadIcon(name, controller.signal).then((paths) => setResult({ name, paths }), (error) => {
if (error.name !== "AbortError") setResult({ name, error: true });
});
@@ -27,8 +29,10 @@ export function CanvasIcon({ name = "Star", size = 24, width = size, height = si
// effect可能还没处理新name,故读取结果前再次比对名称;失败标记也必须属于当前图标。
const paths = result.name === name ? result.paths : undefined;
const failed = result.name === name && result.error;
// retry 不清浏览器 HTTP 缓存;同名已有成功分片时,loadIcon 仍可能直接复用共享缓存。
// 以24×24为标准坐标;absoluteStrokeWidth在size为数值时按尺寸反向补偿描边宽度。
// 成功时保留调用方onClick;失败时点击改为停止冒泡并重试,避免同时触发外层节点点击。
// props 在默认 SVG 属性之后展开,可覆盖 viewBox 等;data-icon-* 和最终 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"}
+2
View File
@@ -11,7 +11,9 @@ export const ImagePreviews = createContext<{ sources: Map<string,string>; loaded
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);
// onLoad 只携带元素 id,不附带 src/资源代次;迟到加载能否清掉当前预览需由 Provider 的 loaded 实现判定。
const remote = <img loading="lazy" decoding="async" className={className} src={src} alt={title} draggable={false} referrerPolicy="no-referrer" onLoad={() => previews.loaded(id)} />;
// 没有 onError 状态或自动重试;底图失败时若上层仍保留 local,覆盖图可继续显示。
// 没有预览或预览已等于正式源时,只绘制一张图片,避免冗余叠层。
if (!local || local === src) return remote;
// 正式图片继续在底层加载,本地预览绝对定位覆盖在上方;Provider清掉该id后自然移除覆盖。
@@ -21,4 +21,5 @@ const nodes={material:MaterialNode,plainText:PlainTextNode,plainImage:PlainImage
/** 每项保留原始类型/结构描述,node按rendererId查表,connection绑定CanvasEdge。
* render与preview目前共用同一个组件;createReactRenderers负责生成供ReactFlow使用的注册结果。
* 本表没有自定义类型的通配组件,兼容占位由投影/渲染选择链路处理。 */
// 模块级初始化一次,供多个宿主复用;preview 选择同一组件不代表自动只读,交互权限仍由外层上下文限制。
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;}));
@@ -29,10 +29,13 @@ export function CanvasTextEditor({ id, data, selected, plain = false }: { id: st
element?.setSelectionRange(element.value.length, element.value.length);
}, [editing]);
/** 通知控制器开始文本编辑会话,再开启本地输入态;真正可写还要求节点保持选中。 */
// begin 没有成功返回值约定;即使作用域包装静默拒绝动作,本地 editing 仍被设 true,文档修改仍由动作护栏判定。
function begin() { actions.begin(id); setEditing(true); }
// 同时满足editing与selected才允许输入;展示时textarea只读且不进入Tab顺序。
const active = editing && selected;
const label = plain ? "纯文本" : data.title;
// 本组件未监听 compositionstart/end;正文变化直接转交 edit,输入法合并与 history 分组由 Provider 决定。
// textarea 的 Escape 处理也不检查 isComposing,仅用于退出输入态。
// 输入最多100000字符;onChange立即发动作,blur退出并通知endEditing。Escape只结束编辑,不回滚已输入文本。
// 非编辑态覆盖层不接管单击逻辑,让外层画布选择/拖动;双击使用浏览器原生时序并按移动标记过滤。
// 键盘Enter/F2仅在已选中时进入;双击未选中节点会先发select,再开始编辑。
+66
View File
@@ -1,91 +1,124 @@
/* 画布与节点的局部样式:React Flow 全局类通过 :global 组合,状态类由组件/手势控制器切换;CSS 只控制呈现和命中,不负责数据归属、提交或锁。 */
/* 画布铺满定位父容器;随后覆盖 React Flow 背景、选中边框和 attribution 外观。 */
.editor { position: absolute; inset: 0; width: 100%; height: 100%; background: #fffefb; }
.editor :global(.react-flow) { --xy-background-color-default: #fffefb; --xy-node-border-selected-default: 1px solid #a47764; }
.editor :global(.react-flow__attribution) { bottom: 5px; right: 5px; font-size: 9px; background: #fffefbc9; }
/* 手形模式在 pane/节点上给 grab 光标,拖动 pane 时改为 grabbing。 */
.handMode :global(.react-flow__pane), .handMode :global(.react-flow__node) { cursor: grab; }
.handMode :global(.react-flow__pane.dragging) { cursor: grabbing; }
/* 卡片基础边框/圆角/阴影,selectedNode 单独标记选择;标题绝对放在节点上方。 */
.node { position: relative; padding: 0; border: 1px solid #ded7ce; border-radius: 10px; background: white; color: #37352f; box-shadow: 0 2px 5px #40302405; }
.selectedNode { border-color: #a47764; box-shadow: 0 0 0 2px #a4776425; }
.nodeHeading { position: absolute; left: 0; top: -26px; display: flex; align-items: center; gap: 6px; height: 20px; max-width: 100%; font-size: 11px; color: #92877c; white-space: nowrap; cursor: grab; }
/* 文本填满 flex 容器,允许尺寸缩到零且关闭原生 resize;焦点时覆盖全局输入阴影,避免额外边框。 */
.cardText, .plainText { box-sizing: border-box; display: block; width: 100%; height: 100%; min-height: 0; min-width: 0; flex: 1; padding: 14px; resize: none; border: 0; border-radius: inherit; background: transparent; outline: none; box-shadow: none; }
.cardText:focus, .plainText:focus { outline: none; box-shadow: none; border-color: transparent !important; }
.cardText::placeholder, .plainText::placeholder { color: #b2a99e; }
/* 卡片图片保持原比例装入可用空间,不以图片固有尺寸撑开节点。 */
.image { display: block; flex: 1; min-height: 0; width: 100%; height: 100%; max-width: 100%; border-radius: inherit; object-fit: contain; }
/* 工具栏按 aria-pressed 高亮当前模式;disabled 光标不表示实际业务锁逻辑。 */
.toolbar button[aria-pressed="true"] { background: #f1e6df; color: #78523f; }
.toolbar button:disabled { cursor: not-allowed; }
/* 右上工具条纵排;缩放值固定 48px 并使用等宽数字,避免百分比变化挤动旁边按钮。 */
.topRight { position: absolute; top: 12px; right: 12px; z-index: 10; display: flex; flex-direction: column; align-items: flex-end; gap: 3px; }
.toolbar .zoomValue { box-sizing: border-box; flex: 0 0 48px; width: 48px; height: var(--toolbar-control-height, 32px); padding: 0; border-radius: 5px; font: inherit; font-variant-numeric: tabular-nums; }
.toolbar .zoomValue:hover { background: #e9e7e3; }
/* 底部主工具栏相对画布居中;帮助、错误等浮层各自定位和设层级。 */
.bottomToolbar { position: absolute; left: 50%; bottom: 12px; transform: translateX(-50%); z-index: 10; }
/* 帮助面板固定在视口右下方,有宽高上限和内部滚动,长内容不溢出视口。 */
.helpPopup { position: fixed; inset: auto 12px 62px auto; margin: 0; width: 330px; max-width: calc(100vw - 32px); max-height: calc(100dvh - 110px); overflow-y: auto; padding: 18px; border: 1px solid #e2dcd3; border-radius: 12px; background: white; color: #6d6054; box-shadow: 0 8px 30px #32251a14; font-size: 12px; line-height: 1.7; }
.helpPopup strong { color: #49372e; font-size: 13px; }
.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; }
}
/* 手形模式让文本/图片不拦截指针;右上工具条自身横向滚动以容纳窄视口。 */
.handMode .node textarea, .handMode .node img { pointer-events: none; }
.topRight .toolbar { max-width: calc(100vw - 24px); overflow-x: auto; }
/* 隐藏旧 handle 且禁用命中,新的中心点交互在后续规则控制。 */
.node :global(.react-flow__handle).legacyHandle { opacity: 0; pointer-events: none; }
/* 变换容器以中心为 transform-origin,保留溢出给标题、handle 和提示;边框/阴影变化有短过渡。 */
.transformFrame { position: relative; box-sizing: border-box; display: flex; flex-direction: column; transform-origin: center; overflow: visible; transition: border-color 160ms ease, box-shadow 160ms ease; }
/* 纯内容 frame 默认透明边框,选中才显示;plainText 后置 padding 覆盖卡片文本的 14px。 */
.plainFrame { padding: 0; border: 1px solid transparent; border-radius: 2px; background: transparent; box-shadow: none; }
.plainFrame.selectedFrame { border-color: #a47764; }
.plainText { padding: 4px; }
/* 中心连接点默认透明且不响应指针;选中只改变可见性,connectionindicator 才开放命中。 */
.node :global(.react-flow__handle).centerHandle { width: 10px; height: 10px; display: grid; place-items: center; background: #a47764; color: #78523f; border: 0; border-radius: 50%; z-index: 7; cursor: crosshair; touch-action: none; opacity: 0; pointer-events: none; transition: opacity 160ms ease, background 160ms ease, border-color 160ms ease; }
.selectedNode :global(.react-flow__handle).centerHandle { opacity: 1; }
.node :global(.react-flow__handle).centerHandle:global(.connectionindicator) { pointer-events: auto; }
.node :global(.react-flow__handle).centerHandle:hover { border-color: #a47764; background: #f1e6df; }
/* 右下缩放按钮单独命中、禁止触摸默认行为;旋转模式隐藏它,避免两种手势同时起动。 */
.cornerResize { position: absolute; right: 2px; bottom: 2px; display: grid; place-items: center; width: 20px; height: 20px; padding: 0; border: 0; border-radius: 3px; color: #92877c; background: transparent; z-index: 8; cursor: nwse-resize; touch-action: none; opacity: 1; scale: 1; pointer-events: auto; transition: opacity 160ms ease, scale 160ms ease; }
.selectedFrame .cornerResize { opacity: 1; scale: 1; pointer-events: auto; }
.rotatingFrame .cornerResize { opacity: 0; pointer-events: none; }
/* 旋转时由 frame 接收手势,文本和中心连接点让出指针。 */
.rotatingFrame { cursor: grabbing; touch-action: none; }
.rotatingFrame textarea, .node.rotatingFrame :global(.react-flow__handle).centerHandle { pointer-events: none; }
/* 角度提示放在 frame 下方,禁止命中以免遮住正在进行的手势。 */
.rotationHint { position: absolute; left: 50%; bottom: -30px; translate: -50% 0; display: flex; align-items: center; gap: 4px; white-space: nowrap; padding: 4px 7px; border-radius: 5px; background: #fffefbe8; color: #78523f; font-size: 10px; pointer-events: none; }
/* 节点样式工具栏采用横排可滚动表面,按钮尺寸固定;数值/选择器共用高度。 */
.styleToolbar { display: flex; align-items: center; gap: 3px; padding: 4px; border: 1px solid #ded7ce; border-radius: 8px; background: #fffefb; color: #49372e; box-shadow: 0 5px 20px #32251a12; max-width: calc(100vw - 24px); overflow-x: auto; }
.styleToolbar button { display: grid; place-items: center; width: 28px; height: 28px; flex: 0 0 28px; padding: 0; border-radius: 4px; }
.styleToolbar button:hover, .styleToolbar button[aria-pressed="true"] { background: #f1e6df; color: #78523f; }
.styleToolbar select, .styleToolbar input[type="number"] { height: 28px; border: 0; border-radius: 3px; background: transparent; padding: 0 3px; font-size: 12px; }
.styleToolbar input[type="number"] { width: 48px; }
/* 可见圆点只显示颜色,透明原生 color input 覆盖整个点击区域。 */
.colorPicker { position: relative; display: grid; place-items: center; width: 28px; height: 28px; flex: 0 0 28px; cursor: pointer; }
.colorPicker span { width: 17px; height: 17px; border: 1px solid #bfb4aa; border-radius: 50%; }
.colorPicker input { position: absolute; inset: 0; width: 100%; height: 100%; opacity: 0; cursor: pointer; }
/* 分支菜单固定定位并高于普通工具条,菜单项整行点击;定位坐标由组件计算。 */
.branchMenu { position: fixed; z-index: 60; width: 210px; padding: 7px; border: 1px solid #ded7ce; border-radius: 10px; background: #fffefb; color: #49372e; box-shadow: 0 8px 30px #32251a1a; }
.branchMenu header { display: flex; align-items: center; justify-content: space-between; padding: 3px 6px 6px; color: #92877c; font-size: 11px; }
.branchMenu header button { display: grid; place-items: center; width: 24px; height: 24px; }
.branchMenu > button { display: flex; align-items: center; gap: 10px; width: 100%; min-height: 36px; padding: 8px; border-radius: 5px; font-size: 13px; text-align: left; }
.branchMenu button:hover { background: #f1e6df; }
/* 节点图标填满内容区域,SVG 自身不截获指针,事件交给外层节点。 */
.iconContent { width: 100%; height: 100%; padding: 6px; border-radius: inherit; }
.iconContent svg { pointer-events: none; }
/* 图标选择器固定在左侧,可用高度限定并采用纵向 flex,头/搜索/分类与列表分区。 */
.iconPicker { position: fixed; top: 70px; left: 12px; bottom: 70px; z-index: 60; display: flex; flex-direction: column; width: 272px; max-width: calc(100vw - 24px); max-height: 550px; padding: 14px; border: 1px solid #ded7ce; border-radius: 12px; background: #fffefb; color: #49372e; box-shadow: 0 8px 30px #32251a14; }
.iconPicker header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 12px; font-size: 14px; }
.iconPicker header button { width: 28px; height: 28px; display: grid; place-items: center; border-radius: 4px; }
.iconPicker input { width: 100%; padding: 7px 9px; border: 1px solid #ded7ce; border-radius: 6px; font-size: 12px; margin-bottom: 12px; }
/* 普通图标网格五列并内部滚动;后续 virtualIconGrid 是虚拟列表视口,窗口项目由 JS 定位。 */
.iconGrid { display: grid; grid-template-columns: repeat(5, 1fr); gap: 6px; overflow-y: auto; }
.iconGrid button { display: grid; place-items: center; aspect-ratio: 1; border-radius: 6px; color: #6d6054; }
.iconGrid button:hover, .iconPicker header button:hover { background: #f1e6df; }
.iconPicker footer { padding-top: 12px; color: #92877c; font-size: 10px; }
/* 来源选择器限制宽度,分类按钮可换行并按 aria-pressed 显示当前筛选。 */
.iconSource { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-bottom: 10px; font-size: 11px; }
.iconSource select { min-width: 0; max-width: 175px; padding: 4px; border: 1px solid #ded7ce; border-radius: 5px; background: transparent; }
.iconCategories { display: flex; flex-wrap: wrap; gap: 4px; margin-bottom: 10px; }
.iconCategories button { padding: 3px 6px; border-radius: 4px; color: #92877c; font-size: 10px; }
.iconCategories button[aria-pressed="true"] { background: #f1e6df; color: #78523f; }
/* flex:1 让列表吃剩余高度,overscroll contain 减少滚动传到外层;CSS 不负责裁剪数据窗口。 */
.virtualIconGrid { flex: 1; min-height: 96px; overflow-y: auto; overscroll-behavior: contain; }
.virtualIconGrid button { display: grid; place-items: center; border: 3px solid transparent; border-radius: 8px; background-clip: padding-box; color: #6d6054; }
.virtualIconGrid button:hover { background-color: #f1e6df; }
.virtualIconGrid button:focus-visible, .iconCategories button:focus-visible { outline: 2px solid #a47764; outline-offset: -2px; }
/* 图标资源加载提示与选择器使用相近层级/起点。 */
.iconLoading { position: absolute; left: 12px; top: 70px; z-index: 60; padding: 14px; border-radius: 10px; background: #fffefb; font-size: 12px; }
/* 归属徽标放在节点下方,限制长名称且不参与命中;旋转时隐藏以减少遮挡。 */
.membershipBadge { position: absolute; bottom: -25px; left: 0; display: flex; align-items: center; gap: 4px; max-width: 280px; padding: 3px 6px; border-radius: 4px; background: #e9f1e9; color: #3e684b; font-size: 10px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; pointer-events: none; }
.rotatingFrame .membershipBadge { display: none; }
.frameMemberCount { font-size: 10px; color: #92877c; white-space: nowrap; }
/* 名称模式按钮宽度随文字,菜单固定定位且限制在视口内,勾选列保持对齐。 */
.styleToolbar .frameModeTrigger { display: flex; align-items: center; justify-content: space-between; gap: 8px; width: auto; min-width: 96px; flex: 0 0 auto; padding: 0 7px; font-size: 12px; }
.frameModeMenu { position: fixed; z-index: 120; width: 220px; max-width: calc(100vw - 16px); max-height: calc(100dvh - 16px); overflow-y: auto; padding: 5px; border: 1px solid #ded7ce; border-radius: 9px; background: #fffefb; color: #49372e; box-shadow: 0 8px 28px #32251a1f; }
.frameModeMenu button { display: flex; align-items: center; gap: 8px; width: 100%; padding: 9px 7px; border-radius: 5px; text-align: left; }
@@ -94,33 +127,43 @@
.frameModeCheck { display: grid; place-items: center; width: 15px; flex: 0 0 15px; }
.frameModeMenu strong { display: block; font-size: 12px; font-weight: 500; }
.frameModeMenu small { display: block; margin-top: 2px; color: #92877c; font-size: 10px; }
/* 加入目标用绿色边框/光晕,移出预览改虚线;提示元素本身不拦截拖放。 */
.frameNode.frameDropTarget { border-color: #538366; background: #5383660c; box-shadow: 0 0 0 3px #53836638; }
.frameNode.frameDropExit { border-style: dashed; }
.frameDropHint { position: absolute; top: 12px; left: 12px; transform-origin: top left; border: 1px solid #9db8a4; border-radius: 6px; padding: 6px 9px; background: #f3f8f3; color: #3e684b; font-size: 12px; white-space: nowrap; pointer-events: none; }
/* 归属变更通知居中放在底部工具栏上方;空内容隐藏,非空也不接管指针。 */
.membershipNotice { position: absolute; bottom: 70px; left: 50%; transform: translateX(-50%); z-index: 20; max-width: min(540px, calc(100vw - 32px)); padding: 8px 12px; border: 1px solid #c8d9ca; border-radius: 8px; background: #f3f8f3; color: #3e684b; font-size: 12px; pointer-events: none; }
.membershipNotice:empty { display: none; }
/* 手形模式统一关闭文本、缩放、中心点和样式工具条的编辑入口。 */
.handMode .plainText { pointer-events: none; }
.handMode .rotationHint, .handMode .centerHandle { visibility: hidden; }
.handMode .cornerResize { pointer-events: none; opacity: 0.6; }
.handMode .styleToolbar { display: none; }
/* 第一组减少动态效果规则关闭 frame、角缩放和中心点的过渡;后面还有分段滑块/连线动画的关闭规则。 */
@media (prefers-reduced-motion: reduce) {
.transformFrame, .cornerResize, .node :global(.react-flow__handle).centerHandle { transition: none; }
}
/* 分区主体 pointer-events:none,让内部节点/画布继续接收事件;标签和四边命中条另行启用 pointer-events。 */
.frameNode { border: 1px solid #c9b7a5; border-radius: 8px; background: transparent; pointer-events: none; }
.frameNode.selectedFrame { border-color: #a47764; box-shadow: 0 0 0 2px #a477641a; }
/* 名称放在分区顶部外侧并以左下角缩放;文字截断但标签自身可拖动。 */
.frameLabel { position: absolute; bottom: calc(100% + 8px); left: 0; max-width: 400px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 13px; font-weight: 500; line-height: 22px; color: #92806d; transform-origin: left bottom; pointer-events: auto; cursor: grab; }
/* 四条 10px 命中带跨在视觉边框两侧,扩大拖动区域,不给整个分区内部铺点击层。 */
.frameBorderHit { position: absolute; pointer-events: auto; cursor: grab; }
.frameBorder_top { top: -5px; left: 0; right: 0; height: 10px; }
.frameBorder_bottom { bottom: -5px; left: 0; right: 0; height: 10px; }
.frameBorder_left { left: -5px; top: 0; bottom: 0; width: 10px; }
.frameBorder_right { right: -5px; top: 0; bottom: 0; width: 10px; }
/* 分区改名输入沿用工具栏高度;手形模式下标签和命中边框也不接管事件。 */
.styleToolbar .frameNameInput { width: 150px; height: 28px; padding: 0 6px; border: 1px solid #ded7ce; border-radius: 4px; font-size: 12px; background: transparent; }
.handMode .frameBorderHit, .handMode .frameLabel { pointer-events: none; }
/* 同步错误提示在顶部居中并允许按钮换行,说明文字独占一行。 */
.syncError { position: absolute; left: 50%; top: 70px; translate: -50% 0; z-index: 22; display: flex; flex-wrap: wrap; gap: 8px; max-width: min(580px, calc(100vw - 24px)); padding: 12px; border: 1px solid #e5c7b8; border-radius: 8px; background: #fff9f3; color: #a25439; font-size: 12px; }
.syncError span { flex-basis: 100%; }
/* 恢复对话框限制视口宽度并居中,backdrop 给遮罩色;模态焦点行为来自 dialog 而不是 CSS。 */
.recoveryDialog { max-width: min(460px, calc(100vw - 32px)); padding: 24px; margin: auto; border: 1px solid #ded7ce; border-radius: 12px; background: #fffefb; color: #49372e; box-shadow: 0 12px 50px #32251a20; }
.recoveryDialog::backdrop { background: #30251e33; }
.recoveryDialog h2 { font-size: 16px; margin-bottom: 12px; }
@@ -128,24 +171,32 @@
.recoveryDialog > div { display: flex; flex-wrap: wrap; gap: 10px; margin-top: 20px; }
.recoveryDialog button { padding: 8px 12px; border: 1px solid #cbb8a8; border-radius: 5px; }
/* 文本编辑容器允许 flex 收缩;readonly textarea 让出指针,由上层选择层处理选择/拖动。 */
.textEditor { position: relative; display: flex; flex: 1; min-width: 0; min-height: 0; width: 100%; height: 100%; border-radius: inherit; }
.textEditor textarea[readonly] { pointer-events: none; }
/* 透明选择层盖住内容区域,选中后给 grab 光标;手形模式取消该层命中。 */
.textSelectLayer { position: absolute; inset: 0; border-radius: inherit; cursor: default; }
.handMode .textSelectLayer { pointer-events: none; }
.selectedFrame .textSelectLayer { cursor: grab; }
.textSelectLayer:active { cursor: grabbing; }
/* 纯图片没有卡片 padding,图片仅显示不接受选择/拖动,外层控制变换。 */
.plainImageFrame { padding: 0; }
.plainImage { width: 100%; height: 100%; display: block; object-fit: contain; pointer-events: none; user-select: none; }
/* 标题省略与改名输入分开;输入固定目标宽度并继承字体。 */
.nodeTitle { min-width: 0; overflow: hidden; text-overflow: ellipsis; }
.nodeTitleInput { width: 180px; min-width: 0; height: 22px; padding: 0 4px; border: 1px solid #cbb8a8; border-radius: 4px; background: #fffefb; font: inherit; color: #78523f; }
/* 三段图标选项用 isolation 建局部层叠,按钮在滑块上方,背景由滑块提供。 */
.frameLabelSegments { position: relative; display: flex; padding: 3px; border-radius: 6px; background: #f1eee8; isolation: isolate; }
.frameLabelSegments button { position: relative; z-index: 1; width: 28px; height: 28px; flex: 0 0 28px; background: transparent !important; color: #92877c; }
.frameLabelSegments button[aria-checked="true"] { color: #78523f; }
/* 滑块绝对定位并用 transform 做短过渡,实际位移由组件按选项设置。 */
.frameLabelSlider { position: absolute; left: 3px; top: 3px; width: 28px; height: 28px; border-radius: 4px; background: #fffefb; box-shadow: 0 1px 4px #32251a16; transition: transform 160ms ease; }
/* 八向缩放 handle 按中心锚定,pointer-events:auto 覆盖外层让出命中的行为;手形模式再禁用。 */
.resizeHandle { position: absolute; width: 10px; height: 10px; transform: translate(-50%, -50%); border: 1px solid #a47764; border-radius: 2px; padding: 0; background: #fffefb; z-index: 12; touch-action: none; pointer-events: auto; }
.handMode .resizeHandle { pointer-events: none; }
/* 连线工具条可换行,数字、颜色、范围和选择器分别限宽,避免长表单撑出视口。 */
.edgeToolbar { display: flex; align-items: end; flex-wrap: wrap; gap: 7px; max-width: min(680px, calc(100vw - 24px)); padding: 10px; border: 1px solid #ded7ce; border-radius: 9px; background: #fffefb; color: #49372e; box-shadow: 0 5px 20px #32251a14; }
.edgeToolbar label { display: flex; align-items: center; gap: 4px; font-size: 10px; }
.edgeToolbar input[type="number"] { width: 50px; }
@@ -156,17 +207,24 @@
.edgeToolbar button:hover { background: #f1e6df; }
.edgeToolbar button:disabled { opacity: .4; }
.edgeToolbar small { width: 100%; color: #92877c; font-size: 10px; }
/* 曲线控制点用 pointer-events:all 开放 SVG 命中,触摸默认行为关闭,拖动逻辑由事件处理器负责。 */
.curveControl { cursor: move; touch-action: none; pointer-events: all; }
/* 连线效果分别用 dashoffset 流动和 opacity 脉冲;是否附加动画类由执行状态/配置决定。 */
.edgeDashAnimation { animation: edgeDash 1.6s linear infinite; }
.edgePulseAnimation { animation: edgePulse 1.6s ease-in-out infinite; }
/* 虚线一个周期移动 40 单位;脉冲从低透明到不透明再返回。 */
@keyframes edgeDash { to { stroke-dashoffset: -40; } }
@keyframes edgePulse { 0%, 100% { opacity: .1; } 50% { opacity: 1; } }
/* 第二组减少动态效果规则关闭滑块过渡和两种连线动画。 */
@media (prefers-reduced-motion: reduce) { .frameLabelSlider { transition: none; } .edgeDashAnimation, .edgePulseAnimation { animation: none; } }
/* 全画布 busy 覆盖层是独立呈现态;成员局部同步使用后部 memberSync,不应从这个类推断所有任务都会锁画布。 */
.canvasBusy { position: absolute; inset: 0; display: flex; align-items: start; justify-content: center; padding-top: 80px; z-index: 200; background: #fffefb35; cursor: progress; color: #78523f; font-size: 12px; }
/* 子图显示比例输入窄排布;折叠子图的摘要占内部 inset 区域并可点击。 */
.subgraphScale { display: flex; align-items: center; gap: 2px; font-size: 11px; }
.subgraphScale input { width: 45px; }
.collapsedSubgraph { position: absolute; inset: 15px; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 7px; color: #92877c; pointer-events: auto; font-size: 12px; }
.collapsedSubgraph small { font-size: 10px; }
/* 元素 AI 面板限制视口宽度;标题信息省略,提示词可纵向伸展,底部模型/传播/执行按钮横排。 */
.aiPanel { width: min(440px, calc(100vw - 28px)); border: 1px solid #ded7ce; border-radius: 13px; padding: 14px; background: #fffefb; box-shadow: 0 8px 30px #32251a16; color: #49372e; }
.aiPanel header { display: flex; align-items: center; gap: 6px; font-size: 12px; margin-bottom: 9px; }
.aiPanel header span { margin-left: auto; color: #92877c; max-width: 160px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 10px; }
@@ -176,32 +234,40 @@
.aiPanel footer label { display: flex; align-items: center; gap: 4px; font-size: 10px; white-space: nowrap; }
.aiPanel footer button { width: 32px; height: 32px; flex: 0 0 32px; display: grid; place-items: center; border-radius: 8px; background: #78523f; color: white; }
.aiPanel footer button:disabled { opacity: .35; }
/* 普通提示与 role=alert 错误采用不同颜色;冲突候选区域独立滚动并保留预格式文本换行。 */
.aiPanel p { margin: 7px 0 0; color: #92877c; font-size: 10px; }
.aiPanel p[role="alert"] { color: #a25439; }
.aiConflict { max-height: 180px; overflow: auto; padding: 8px; border-radius: 7px; background: #f4eee7; font-size: 11px; }
.aiConflict pre { white-space: pre-wrap; }
.aiConflict img { max-height: 100px; }
.aiConflict button { padding: 6px; color: #78523f; }
/* 对通用变换 frame 再声明中心 handle:默认不可见不可命中,选中显示、connectionindicator 开启命中。 */
.transformFrame :global(.react-flow__handle).centerHandle { width: 10px; height: 10px; border: 0; background: #a47764; opacity: 0; pointer-events: none; }
.selectedFrame :global(.react-flow__handle).centerHandle { opacity: 1; }
.transformFrame :global(.react-flow__handle).centerHandle:global(.connectionindicator) { pointer-events: auto; }
/* 端口先定义几何,再在下一条默认隐藏/禁用;选中只显示,是否可连仍由 connectionindicator 决定。 */
.framePort { width: 8px; height: 8px; border: 1px solid #fffefb; background: #a47764; pointer-events: auto; }
.framePort { opacity: 0; pointer-events: none; }
.selectedFrame .framePort { opacity: 1; }
.framePort:global(.connectionindicator) { pointer-events: auto; }
.rotatingFrame :global(.react-flow__handle).centerHandle { pointer-events: none; }
/* busy 状态中的恢复/重试按钮仍显示明确可点表面。 */
.canvasBusy button { margin: 8px 5px; padding: 6px 9px; border: 1px solid #ded7ce; border-radius: 5px; background: #fffefb; cursor: pointer; }
/* 图片上传状态浮在底部工具栏上方,宽度受容器限制,不作为节点持久数据。 */
.imageUploadStatus { position: absolute; bottom: 88px; left: 50%; transform: translateX(-50%); z-index: 120; display: flex; align-items: center; gap: 12px; max-width: min(600px,90%); padding: 10px 14px; background: #fffdf9; border: 1px solid #ded5cb; border-radius: 10px; color: #796e63; font-size: 12px; box-shadow: 0 4px 18px #372f2410; }
/* 成员同步只显示紧凑状态和下划线操作按钮,不使用全画布遮罩。 */
.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; }
/* 导入状态面板位于右上,有宽高上限和内部滚动;summary 切换详情,任务之间用边线分隔。 */
.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; }
/* 导入动作横排,其余任务按钮使用文字色与局部留白,任务状态由 runtime/组件决定。 */
.importActions { display: flex; align-items: center; gap: 8px; }
.importActions > button:not(:first-child), .importJob button { color: #78523f; padding: 4px 0; }
@@ -7,5 +7,6 @@ import styles from "./canvas.module.css";
/** 四边均使用source Handle以配合画布连接模式;selected控制能否开始连线以及读屏可见性。
* 不在这里创建端点记录,也不判断跨分区连线的归属。 */
export function ContentHandles({ selected }: { selected: boolean }) {
// selected 只设置 isConnectableStart/aria-hidden;未显式设置结束端能力或 tabIndex,仍须遵守外层 ReactFlow 配置。
return <>{([["top", Position.Top], ["right", Position.Right], ["bottom", Position.Bottom], ["left", Position.Left]] as const).map(([id, position]) => <Handle key={id} id={id} type="source" position={position} className={styles.centerHandle} isConnectableStart={selected} aria-hidden={!selected} aria-label={`从 ${id} 连接元素`} />)}</>;
}
+2 -1
View File
@@ -6,7 +6,8 @@
/** preview产生临时显示,commit结束并提交,cancel撤销本次预览;具体语义由创建租约的编辑器实现。 */
export type EditingLease<T> = { preview(value: T): void; commit(): void; cancel(): void };
/** 冻结包装后的方法表,每次调用重新检查isCurrent;过期事件静默忽略,不能结束另一场交互。 */
/** 冻结包装后的方法表,每次调用重新检查isCurrent;过期事件静默忽略。
* 是否识别精确交互由 isCurrent 的实现保证;这里只冻结外壳,不复制 actions 或吞掉检查/动作异常。 */
export function guardEditingLease<T>(isCurrent: () => boolean, actions: EditingLease<T>): EditingLease<T> {
return Object.freeze({
// 指针移动可能持续产生事件,必须逐次检查,不能只在创建租约时检查一次。
+84
View File
@@ -1,3 +1,9 @@
/**
* 旧版主画布的界面装配与交互控制。useCanvasTree拥有旧快照、writer、历史和子图事务,组件把其投影交给ReactFlow。
* 本地state管理工具、模式、菜单与提示,refs保存拖动/连线起点及文件选择上下文;真实上传与演示Data URL分别处理。
* 节点/连线组件通过Context调用适配动作,不直接修改投影;子图归属、端口连线和解散优先使用服务端能力与事务。
* 迁移门闩暂停旧编辑和创作入口,给迁移协调组件提供检查、保存排空与备份;本文件不自行切换到新版engine。
*/
"use client";
import { useImageUploads } from "./use-image-uploads";
import {LegacyCanvasMigration} from "./legacy-canvas-migration";
@@ -41,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 };
/** 每个挂载对应一个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);
@@ -62,6 +74,7 @@ function CanvasEditor({ board, snapshot, migration }: { board: Board; snapshot?:
} 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);
@@ -72,26 +85,35 @@ function CanvasEditor({ board, snapshot, migration }: { board: Board; snapshot?:
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);
// 根恢复待决时暂停保存;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) {
@@ -102,38 +124,48 @@ function CanvasEditor({ board, snapshot, migration }: { board: Board; snapshot?:
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`;
@@ -141,29 +173,40 @@ function CanvasEditor({ board, snapshot, migration }: { board: Board; snapshot?:
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)!;
@@ -174,7 +217,9 @@ function CanvasEditor({ board, snapshot, migration }: { board: Board; snapshot?:
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);
@@ -184,11 +229,13 @@ function CanvasEditor({ board, snapshot, migration }: { board: Board; snapshot?:
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;
@@ -201,11 +248,13 @@ function CanvasEditor({ board, snapshot, migration }: { board: Board; snapshot?:
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);
@@ -218,6 +267,7 @@ function CanvasEditor({ board, snapshot, migration }: { board: Board; snapshot?:
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();
@@ -233,6 +283,7 @@ function CanvasEditor({ board, snapshot, migration }: { board: Board; snapshot?:
] } });
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);
@@ -242,6 +293,8 @@ function CanvasEditor({ board, snapshot, migration }: { board: Board; snapshot?:
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);
@@ -252,19 +305,23 @@ function CanvasEditor({ board, snapshot, migration }: { board: Board; snapshot?:
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;
}
@@ -272,6 +329,7 @@ function CanvasEditor({ board, snapshot, migration }: { board: Board; snapshot?:
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)) {
@@ -287,10 +345,12 @@ function CanvasEditor({ board, snapshot, migration }: { board: Board; snapshot?:
}
}
}
/** 先把入口端口解析成实际内部节点;同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++) {
@@ -303,6 +363,7 @@ function CanvasEditor({ board, snapshot, migration }: { board: Board; snapshot?:
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);
@@ -316,11 +377,13 @@ function CanvasEditor({ board, snapshot, migration }: { board: Board; snapshot?:
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 = "";
@@ -331,6 +394,7 @@ function CanvasEditor({ board, snapshot, migration }: { board: Board; snapshot?:
}
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;
@@ -343,12 +407,14 @@ function CanvasEditor({ board, snapshot, migration }: { board: Board; snapshot?:
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) => {
@@ -370,6 +436,8 @@ function CanvasEditor({ board, snapshot, migration }: { board: Board; snapshot?:
} 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 = "";
@@ -387,6 +455,8 @@ function CanvasEditor({ board, snapshot, migration }: { board: Board; snapshot?:
} 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; }
@@ -399,7 +469,9 @@ function CanvasEditor({ board, snapshot, migration }: { board: Board; snapshot?:
setTimeout(() => URL.revokeObjectURL(url), 1000);
}
// 旧版全局快捷键:跳过输入框、文本编辑、对话框和打开的popover,支持撤销/重做与V/H;卸载移除。
useEffect(() => {
/** 按DOM目标过滤后处理键;此旧路径没有新版routeCanvasKey的组合输入/认领优先级模型。 */
const keydown = (event: KeyboardEvent) => {
const target = event.target;
if (!(target instanceof HTMLElement)) return;
@@ -417,6 +489,11 @@ function CanvasEditor({ board, snapshot, migration }: { board: Board; snapshot?:
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}>
@@ -427,6 +504,7 @@ function CanvasEditor({ board, snapshot, migration }: { board: Board; snapshot?:
<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) => {
@@ -436,6 +514,7 @@ function CanvasEditor({ board, snapshot, migration }: { board: Board; snapshot?:
});
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);
@@ -445,6 +524,7 @@ function CanvasEditor({ board, snapshot, migration }: { board: Board; snapshot?:
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;
@@ -461,6 +541,7 @@ function CanvasEditor({ board, snapshot, migration }: { board: Board; snapshot?:
}
if (linked.length) setMembershipNotice("已解散选框,子画布内容已保留");
} catch (error) { setError(error instanceof Error ? error.message : "删除未完成"); }
// 结构分支已自行执行删除/解散,阻止ReactFlow再按旧投影默认删一遍。
return false;
}}
onNodesChange={tree.nodesChanged}
@@ -486,6 +567,7 @@ function CanvasEditor({ board, snapshot, migration }: { board: Board; snapshot?:
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 } }))} />;
@@ -558,6 +640,7 @@ function CanvasEditor({ board, snapshot, migration }: { board: Board; snapshot?:
<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]}>
@@ -577,6 +660,7 @@ function CanvasEditor({ board, snapshot, migration }: { board: Board; snapshot?:
);
}
/** 为单个旧编辑器建立独立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>;
}
@@ -1,3 +1,9 @@
/**
* 新版主画布的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";
@@ -40,9 +46,13 @@ 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;
@@ -52,16 +62,20 @@ function CanvasView({ engine, board, viewId, renderers,fitLoading=true }: { engi
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
@@ -70,7 +84,9 @@ function CanvasView({ engine, board, viewId, renderers,fitLoading=true }: { engi
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
@@ -78,19 +94,26 @@ function CanvasView({ engine, board, viewId, renderers,fitLoading=true }: { engi
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
@@ -100,36 +123,50 @@ function CanvasView({ engine, board, viewId, renderers,fitLoading=true }: { engi
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;}
@@ -149,12 +186,16 @@ function CanvasView({ engine, board, viewId, renderers,fitLoading=true }: { engi
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;
@@ -165,16 +206,21 @@ function CanvasView({ engine, board, viewId, renderers,fitLoading=true }: { engi
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);
@@ -182,21 +228,30 @@ function CanvasView({ engine, board, viewId, renderers,fitLoading=true }: { engi
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;}});
@@ -208,10 +263,12 @@ function CanvasView({ engine, board, viewId, renderers,fitLoading=true }: { engi
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]');
@@ -221,10 +278,12 @@ function CanvasView({ engine, board, viewId, renderers,fitLoading=true }: { engi
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;
@@ -233,15 +292,19 @@ function CanvasView({ engine, board, viewId, renderers,fitLoading=true }: { engi
// 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;
@@ -249,19 +312,23 @@ function CanvasView({ engine, board, viewId, renderers,fitLoading=true }: { engi
},
}),[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;
@@ -269,8 +336,15 @@ function CanvasView({ engine, board, viewId, renderers,fitLoading=true }: { engi
},
}),[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); }); }}
@@ -296,6 +370,7 @@ function CanvasView({ engine, board, viewId, renderers,fitLoading=true }: { engi
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])) };
@@ -305,6 +380,7 @@ function CanvasView({ engine, board, viewId, renderers,fitLoading=true }: { engi
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 });
@@ -318,6 +394,7 @@ function CanvasView({ engine, board, viewId, renderers,fitLoading=true }: { engi
<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); });
@@ -339,9 +416,12 @@ function CanvasView({ engine, board, viewId, renderers,fitLoading=true }: { engi
{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>;
}
+2
View File
@@ -31,12 +31,14 @@ export function MaterialNode(props: NodeProps<CanvasNode>) {
const editing = useScopedNodeEditing(id,data.__headlessTarget);
const { zoom } = useViewport();
// 以卡片宽度(默认260)×视口缩放×设备像素比估算显示成本;1024以内优先预览源。
// 像素比在 render 时读取,不单独监听其变化;zoom 订阅或其它重渲染才重新计算此启发式。
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中心打开菜单,由动作层再处理合法性。
// pointerStart 不按 pointerId 分组,属于点击位移辅助;键盘入口也不在这里检查 branchable,由动作实现兜底。
// 点击/按键停止冒泡,避免分支操作同时触发节点或画布交互;文本内容的编辑生命周期由子组件负责。
return (
<TransformableNode {...props} className={`${styles.node} ${selected ? styles.selectedNode : ""}`}>
+3 -1
View File
@@ -41,12 +41,14 @@ export const NodeEditing = createContext<Omit<typeof defaultEditing, GestureHook
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;
// current 在动作触发时读取快照,不建立订阅;后续状态变化不会仅因这个检查而触发组件重渲染。
// 无新版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。
// 这里不捕获 gesture token;同视图同目标的新手势也能通过,精确手势身份须由创建者的租约保护。
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,
@@ -61,7 +63,7 @@ export function useScopedNodeEditing(id:string,rendered?:RenderedTarget){
// 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结束新交互。
// 结束手势要同时通过对象有效性与归属检查;这不是对任意迟到事件的完整 token 隔离。
if(editing.finishTransform)scoped.finishTransform=()=>{if(current()&&ownGesture())editing.finishTransform!();};
if(editing.cancelTransform)scoped.cancelTransform=()=>{if(current()&&ownGesture())editing.cancelTransform!();};
// 包装只负责入口检查;事务合法性、持久化和失败报告仍由Provider对应的动作实现负责。
+3 -1
View File
@@ -19,9 +19,11 @@ export function useNodeInternalsUpdate(id:string){
// 首个节点创建store级batch,每次挂载取得本id的owner;清理仅释放该owner。
useEffect(()=>{
let batch=batches.get(scope);if(!batch){batch=createNodeInternalsBatch(update);batches.set(scope,batch);}
// batch 首次创建时捕获 update;同 store 后续使用者沿用该更新器,不在每次 effect 中替换。
const owner=batch.acquire(id);lease.current=owner;
return()=>{owner.dispose();if(lease.current===owner)lease.current=undefined;};
},[scope,update,id]);
// 回调通过ref读取当前租约,避免调用方保存旧闭包后继续刷新已卸载节点。
// 回调通过ref读取当前租约;旧调用者保存的同一回调,在本组件 id 改变后会转向新 id 的租约。
// effect 尚未安装/已清理则无动作;它不把回调永久绑定到最初 id。
return useCallback(()=>lease.current?.invalidate(),[]);
}
+2
View File
@@ -17,11 +17,13 @@ export function NodeTitle({ id, title, selected,renderedTarget }: { id: string;
useEffect(() => { if (!selected) setEditing(false); }, [selected]);
useEffect(() => { if (editing) { input.current?.focus(); input.current?.select(); } }, [editing]);
/** blur是统一提交入口:忽略取消、空名称与无变化名称,随后复位取消标记并退出输入态。 */
// rename 是同步动作入口,不等待远端 ACK;若实现抛错,下方标记复位与退出也不会自动执行。
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;按键不继续冒泡到画布。
// maxLength 是浏览器 UTF-16 长度限制;此 save 只 trim/去空/比旧值,不另做 UTF-8 字节校验。
// 标题长度限制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(); } }} />;
@@ -14,6 +14,7 @@ export function PlainImageNode(props: NodeProps<CanvasNode>) {
// 节点宽度未提供时按320估算,再乘zoom与设备像素比;不超过1024时请求预览源。
// 这是显示宽度启发式,不检查图片原始尺寸;无window时像素比按1处理。
const preview = (props.width || 320) * zoom * (typeof window === "undefined" ? 1 : window.devicePixelRatio || 1) <= 1024;
// 该估算只影响托管 URL 的 variant;不是独立下载任务,不会为普通外部图片生成预览资源。
// 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>;
}
@@ -8,9 +8,11 @@
export function createRetainedProjection(){
// 弱引用键避免缓存永久占住已被替换的投影对象;只按对象身份缓存,不监测对象内部原地修改。
const tagged=new WeakMap<object,object>();
// previous 强引用最近一批输入/输出;WeakMap 不等于整个实例完全不持有投影,空 ids 才清这份整批引用。
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[]}=>{
// 清 previous 不清 tagged;同一对象日后再次被保留,仍可能复用已有带标记副本。
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[]};
+1
View File
@@ -7,6 +7,7 @@ import type { CanvasNodeData } from "@/lib/canvas-elements";
* 字体家族只识别serif/mono,其它继承外层;颜色、背景、对齐与行高使用各自默认值。
* 使用||回退,因此0和空字符串也走默认;这里是显示映射,不承担schema校验。 */
export function textAppearance(data: CanvasNodeData): CSSProperties {
// 每次生成新的扁平 CSS 对象;不缓存节点引用,也不会写回默认值到 data。
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,
fontFamily: style.fontFamily === "serif" ? 'Georgia, "Songti SC", serif' : style.fontFamily === "mono" ? 'ui-monospace, "SFMono-Regular", monospace' : "inherit", backgroundColor: style.background || "transparent" };
@@ -17,6 +17,7 @@ const positions={top:Position.Top,right:Position.Right,bottom:Position.Bottom,le
* 仍绘制已知连接点供现有连线定位,但禁止从这些Handle发起或接收新连接;不推断未知内容字段。 */
export function UnsupportedNode({data,selected}:NodeProps<ProjectionNode>){
// fallback来自bindings投影;缺失时仍显示通用提示,不尝试从data猜测组件结构。
// 只读取公共投影信封;此组件本身没有 TransformableNode,也不安装任何编辑动作处理器。
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>
@@ -27,6 +28,7 @@ export function UnsupportedNode({data,selected}:NodeProps<ProjectionNode>){
/** 直接使用ReactFlow推导的端点生成贝塞尔路径,附可访问标题;不调用未知类型的编辑/样式逻辑。
* BaseEdge绘制统一颜色的可见线条,数据保留与版本兼容仍归模型层负责。 */
export function UnsupportedEdge(props:EdgeProps<CanvasEdgeModel>){
// 采用端点派生的通用曲线,不重放未知模型的自定义 path/marker;保留数据与视觉完全还原是两件事。
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>;
}
+106
View File
@@ -1,3 +1,9 @@
/**
* 旧版画布树的状态与操作协调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";
@@ -8,20 +14,34 @@ 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: [] });
/** 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');
@@ -29,33 +49,42 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo
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);
@@ -63,12 +92,16 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo
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);
@@ -80,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 (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 (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;
@@ -106,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.
@@ -116,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) {
@@ -124,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) {
@@ -141,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);
@@ -153,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;
@@ -178,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;
@@ -192,6 +245,8 @@ 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 || migrationBlocked()) return;
const backup = setTimeout(() => {
@@ -208,7 +263,9 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo
}, 800);
return () => { clearTimeout(backup); clearTimeout(save); };
}, [documents, paused, workspace, memberJobs, migrationHeld]);
// pagehide/卸载尽量做本地同步备份,不等待远端flush;暂停/迁移仍禁止写,存储满时保留已有记录。
useEffect(() => {
/** 遍历最新内存文档,备份规范化payload与ACK版本/dirty;页面离开时错误不再触发界面更新。 */
const backup = () => {
if (pause.current || migrationBlocked()) return;
for (const [boardId, document] of current.current) {
@@ -219,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");
@@ -228,6 +287,8 @@ 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');
@@ -240,12 +301,14 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo
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');
@@ -255,15 +318,19 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo
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 (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()];
@@ -272,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));
@@ -286,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 () => {
@@ -295,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;
@@ -320,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 (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);
@@ -336,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]));
@@ -344,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) {
@@ -369,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 = []; }
};
}
@@ -378,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 {
@@ -385,24 +471,31 @@ 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 (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)!;
@@ -413,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;
@@ -423,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");
@@ -436,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 || 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));
@@ -452,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);
@@ -464,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>();
@@ -472,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(); };
// 对外暴露投影、动作、恢复、事务及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 };
}
+34
View File
@@ -1,95 +1,121 @@
/* 当前 LandingPage 的局部配色、两栏首屏、可选预览卡片、功能区和响应式布局;预览交互由组件状态驱动,不使用真实画布模型。 */
/* 页面作用域内定义墨色/弱文字/边线变量,局部规则覆盖全局品牌类时使用 :global。 */
.page { --ink: #21221f; --muted: #797b73; --line: #e5e6df; background: #fff; color: var(--ink); font-family: Inter, -apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei", sans-serif; }
/* 移除触摸高亮并保留明确的键盘可见焦点描边。 */
.page a, .page button { -webkit-tap-highlight-color: transparent; }
.page a:focus-visible, .page button:focus-visible { outline: 2px solid #6879c2; outline-offset: 4px; }
/* 导航内容限制最大宽度,品牌、导航链接和右侧操作区横排。 */
.nav { display: flex; align-items: center; gap: 40px; height: 88px; max-width: 1320px; padding: 0 48px; margin: auto; }
.page :global(.brand) { color: var(--ink); gap: 8px; font-size: 19px; letter-spacing: -0.6px; }
.page :global(.brand img) { width: 30px; height: 30px; }
.page :global(.brand small) { color: #92958c; font-size: 11px; font-weight: 400; letter-spacing: 0; }
.nav nav { display: flex; gap: 28px; font-size: 13px; color: #72766c; }
.nav nav a:hover { color: var(--ink); }
/* margin-left:auto 将登录和小 CTA 推到最右侧,hover 只改变表面色。 */
.navActions { margin-left: auto; display: flex; align-items: center; gap: 24px; font-size: 13px; }
.smallCta { display: inline-flex; align-items: center; gap: 12px; padding: 10px 15px; border: 1px solid #cfd2c8; border-radius: 5px; }
.smallCta:hover { background: #f5f6f0; }
/* 桌面首屏文案/预览用接近等宽的两列;clamp 让标题在边界内随视口缩放。 */
.hero { max-width: 1320px; margin: auto; padding: 82px 48px 90px; display: grid; grid-template-columns: 1fr 1.04fr; align-items: center; gap: 46px; }
.eyebrow { font: 10px/1.6 ui-monospace, "SFMono-Regular", monospace; letter-spacing: 1.25px; color: #90958a; }
.heroCopy .eyebrow { display: flex; align-items: center; gap: 8px; white-space: nowrap; }
.statusDot { width: 5px; height: 5px; background: #83966c; border-radius: 50%; }
.hero h1 { font-size: clamp(38px, 4.4vw, 61px); font-weight: 600; line-height: 1.23; letter-spacing: -3px; margin: 22px 0; }
.hero h1 > span { position: relative; white-space: nowrap; }
/* 标题装饰线通过伪元素绘制,不增加实际文本或影响行内宽度。 */
.hero h1 > span::after { content: ""; position: absolute; left: 0; bottom: -3px; width: 100%; height: 5px; border-bottom: 2px solid #b9c87c; border-radius: 50%; rotate: -2deg; }
.heroCopy > p { color: #6f7369; font-size: 15px; line-height: 1.9; font-weight: 400; }
/* CTA 可换行,主按钮悬停上移 1px;旁边文字链接保持次级视觉权重。 */
.heroActions { display: flex; flex-wrap: wrap; align-items: center; gap: 22px; margin-top: 30px; }
.primary { display: inline-flex; align-items: center; justify-content: center; gap: 16px; padding: 14px 20px; background: var(--ink); color: #fff; border: 1px solid var(--ink); border-radius: 5px; font-size: 13px; font-weight: 500; box-shadow: 0 2px 2px #21221f08; transition: background 160ms, translate 160ms; }
.primary:hover { background: #41453b; translate: 0 -1px; }
.textLink { display: inline-flex; align-items: center; gap: 8px; font-size: 12px; color: #6e7565; }
.textLink:hover { color: #21221f; }
/* 手写风附注和星形装饰仅参与视觉排版。 */
.heroNote { margin-top: 34px; font-family: "Kaiti SC", "STKaiti", Georgia, serif; color: #969a8b; font-size: 13px; display: flex; align-items: center; gap: 8px; }
.scribbleStar { font-family: Georgia, serif; color: #929a74; font-size: 24px; }
/* 预览外壳轻微旋转并设置阴影,min-width:0 防止网格项被内容撑宽。 */
.preview { min-width: 0; border: 1px solid #e0e3d9; border-radius: 9px; background: #fafafa; box-shadow: 0 12px 50px #3c442508, 0 2px 8px #3c442505; rotate: -1deg; }
.previewHeading { padding: 14px 18px; border-bottom: 1px solid var(--line); display: flex; align-items: center; justify-content: space-between; font-size: 10px; color: #777d70; }
.previewHeading > span:first-child { display: flex; align-items: center; gap: 7px; color: #69745e; }
.previewHeading > span:last-child { font: 8px ui-monospace, monospace; letter-spacing: 1px; }
.tinyLogo { font-size: 20px; color: #4c5841; }
/* 点阵预览固定宽高比,卡片和 SVG 连线在相同相对容器内绝对定位。 */
.scene { position: relative; aspect-ratio: 600 / 440; background-image: radial-gradient(#dce1d2 0.65px, transparent 0.65px); background-size: 17px 17px; }
.wires { position: absolute; inset: 0; width: 100%; height: 100%; stroke: #a3ad90; stroke-width: 1; }
.wires circle { fill: #a3ad90; }
/* 预览卡片作为按钮可点击,hover 上移,activeCard 通过边框/阴影反馈当前选择。 */
.scene > button { position: absolute; display: flex; flex-direction: column; text-align: left; border: 1px solid #e1e4d8; border-radius: 5px; padding: 14px; background: #fff; color: #373a35; box-shadow: 0 2px 5px #32471504; transition: box-shadow 180ms, border-color 180ms, translate 180ms; }
.scene > button:hover { translate: 0 -3px; }
.scene .activeCard { border-color: #9aa77d; box-shadow: 0 0 0 2px #9aa77d15, 0 4px 12px #32471508; }
.cardLabel { display: flex; align-items: center; gap: 5px; font-size: 9px; color: #9aab88; }
/* 三类预览卡片各有位置、旋转和排版;纸角、艺术图与标签是局部装饰。 */
.ideaCard { left: 5%; top: 17%; width: 42%; min-height: 41%; rotate: -3deg; }
.ideaCard strong { margin-top: 14px; margin-bottom: 13px; font-size: clamp(13px, 1.6vw, 22px); font-family: "Songti SC", Georgia, serif; font-weight: 400; line-height: 1.55; }
.cardText { font-size: 9px; line-height: 1.8; color: #9ba48f; }
.paperCorner { position: absolute; bottom: 4px; right: 4px; width: 10px; height: 10px; border-right: 1px solid #d9dece; border-bottom: 1px solid #d9dece; }
/* 艺术卡片给 SVG 可用高度,使用无填充线稿;便签另有底色与字号。 */
.scene .artCard { top: 8%; right: 7%; width: 29%; min-height: 49%; background: #edeaf7; rotate: 4deg; }
.artCard svg { width: 100%; max-height: 150px; flex: 1; stroke: #53584d; stroke-width: 1.2; fill: none; }
.artCaption { font: italic 8px Georgia, serif; text-align: center; color: #909e79; }
.scene .noteCard { left: 38%; bottom: 9%; width: 37%; min-height: 27%; background: #f0eedf; rotate: 2deg; }
.noteCard strong { margin: 9px 0 12px; font-size: clamp(11px, 1.4vw, 18px); font-family: "Songti SC", Georgia, serif; font-weight: 400; line-height: 1.5; }
.noteTag { align-self: flex-start; font-size: 8px; padding: 3px 5px; border: 1px solid #d6d6ba; border-radius: 3px; color: #8f9278; }
/* 随手附注与假协作光标定位在预览中,光标不拦截鼠标事件。 */
.looseNote { position: absolute; left: 8%; bottom: 12%; color: #aab39c; rotate: -10deg; font: italic 14px Georgia, serif; }
.cursor { position: absolute; right: 17%; bottom: 20%; color: #879567; rotate: -12deg; pointer-events: none; }
.cursor span { position: absolute; top: 20px; left: 12px; background: #879567; color: white; border-radius: 3px; padding: 3px 7px; font-size: 9px; }
/* 预览页脚横排指示点与页码,aria-pressed 标示当前点,最后一项推到右侧。 */
.previewFooter { display: flex; align-items: center; gap: 10px; padding: 13px 18px; border-top: 1px solid var(--line); font-size: 9px; color: #a1a992; }
.previewFooter > div { display: flex; gap: 5px; }
.previewFooter button { width: 5px; height: 5px; border-radius: 50%; background: #d8dfcc; }
.previewFooter button[aria-pressed="true"] { background: #879567; }
.previewFooter > span:last-child { margin-left: auto; font: 9px ui-monospace, monospace; }
/* 宣言横条用上下边线与两侧注记分隔首屏和后续内容。 */
.manifestoStrip { display: flex; align-items: center; justify-content: space-between; gap: 20px; max-width: 1224px; margin: 0 auto; padding: 25px 0; border-top: 1px solid var(--line); border-bottom: 1px solid var(--line); }
.manifestoStrip > span { font: 8px ui-monospace, monospace; letter-spacing: 0.6px; color: #a0a494; }
.manifestoStrip p { font-size: 13px; color: #646c58; letter-spacing: 0.3px; }
.manifestoStrip p span { color: #9ba28e; }
/* 功能区域限制最大宽度;标题与说明左右对齐,功能卡使用三列网格。 */
.space { max-width: 1320px; margin: auto; padding: 105px 48px; }
.sectionHeading { display: flex; align-items: flex-end; justify-content: space-between; gap: 30px; margin-bottom: 48px; }
.sectionHeading h2, .faq h2 { font-size: 34px; line-height: 1.5; letter-spacing: -1px; font-weight: 500; margin-top: 18px; }
.sectionHeading > p { padding-bottom: 5px; color: #6c7166; line-height: 1.9; font-size: 13px; }
.featureGrid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 32px; }
.featureGrid article { min-width: 0; }
/* 每个功能插画使用固定高度裁剪容器,内部收集/连线/创作布局分别绘制。 */
.featureArt { position: relative; height: 185px; border: 1px solid #eaede4; border-radius: 6px; background: #f9faf6; overflow: hidden; margin-bottom: 25px; }
/* 收集示例用轻微旋转和水平位移错开三条素材,保持纵向居中。 */
.collectArt { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 9px; }
.collectArt span { display: flex; align-items: center; gap: 9px; padding: 8px 12px; min-width: 70%; border: 1px solid #e1e6d7; border-radius: 4px; background: #fff; font-size: 10px; color: #859471; rotate: -2deg; }
.collectArt span:nth-child(2) { rotate: 2deg; translate: 8px 0; }
.collectArt span:nth-child(3) { rotate: -1deg; translate: -5px 0; }
.collectArt svg { width: 14px; }
/* 连线示例 SVG 铺满插画,两个端点卡片和装饰字形单独定位。 */
.connectArt svg { width: 100%; height: 100%; stroke: #b1b99e; }
.connectArt span { position: absolute; top: 39px; left: 23px; padding: 10px 13px; border: 1px solid #e1e5d6; border-radius: 5px; background: #fff; color: #8a9877; font-size: 11px; }
.connectArt span:nth-of-type(2) { top: 110px; left: auto; right: 35px; background: #edf0e1; }
.connectArt i { position: absolute; top: 38px; right: 25px; color: #a0ad86; font: 32px Georgia, serif; }
/* 创作示例居中纸张和标题,编辑徽标独立固定在插画右下附近。 */
.createArt { display: grid; place-items: center; }
.createArt > div { display: flex; flex-direction: column; align-items: center; gap: 12px; padding: 24px; background: #f1efe3; width: 72%; rotate: -2deg; border: 1px solid #e0dfcf; }
.createArt > div > span { font: 7px ui-monospace, monospace; letter-spacing: 1px; color: #9da184; }
.createArt strong { font: 25px "Songti SC", Georgia, serif; color: #6e7857; letter-spacing: 1px; }
.editBadge { position: absolute; right: 14px; bottom: 24px; display: flex; align-items: center; gap: 5px; padding: 5px 8px; border: 1px solid #dce3cf; background: #fff; border-radius: 4px; font-size: 8px; color: #889874; }
/* 编号、功能标题与正文使用逐级字号,正文行高便于多行阅读。 */
.featureNumber { color: #a4ab98; font: 9px ui-monospace, monospace; letter-spacing: 0.7px; }
.featureGrid h3 { font-size: 18px; font-weight: 500; margin: 12px 0; letter-spacing: -0.4px; }
.featureGrid p { font-size: 12px; color: #6c7166; line-height: 1.9; }
/* 工作流区域用浅底与上下边线分隔,两列分别承载引语和步骤。 */
.workflow { background: #f7f7f4; border-top: 1px solid #e9ece1; border-bottom: 1px solid #e9ece1; display: grid; grid-template-columns: 1fr 1fr; gap: 90px; padding: 85px max(48px, calc((100vw - 1224px) / 2)); }
/* 引号绝对定位在文案旁,标题自身保持相对定位,签名字体与正文区分。 */
.quote { position: relative; }
.quoteMark { font: 80px/1 Georgia, serif; position: absolute; top: -30px; left: -12px; color: #d9dfca; }
.quote h2 { position: relative; font: 31px/1.7 "Songti SC", Georgia, serif; color: #586449; }
.quote em { display: block; font-style: normal; }
.quote p { font-size: 12px; color: #747a6d; line-height: 2; margin-top: 18px; }
.signature { display: block; font: italic 13px Georgia, serif; color: #a3ad94; margin-top: 30px; }
/* 步骤纵向居中,每项含编号、文字和右侧不收缩的图标。 */
.workflowList { display: flex; flex-direction: column; justify-content: center; }
.workflowList > div { display: flex; gap: 18px; padding: 24px 0; border-bottom: 1px solid #dfe5d4; }
.workflowList > div:first-child { padding-top: 0; }
@@ -97,11 +123,13 @@
.workflowList h3 { font-size: 15px; font-weight: 500; color: #5e6e4e; margin-bottom: 8px; }
.workflowList p { font-size: 11px; line-height: 1.8; color: #747a6d; }
.workflowList svg { flex-shrink: 0; color: #a5b38e; margin-left: auto; }
/* FAQ 桌面为标题/问题两列,问题按钮整行可点击;展开内容是否存在由组件决定。 */
.faq { max-width: 1320px; padding: 105px 48px; margin: auto; display: grid; grid-template-columns: 0.8fr 1.2fr; gap: 90px; }
.faqItem { border-bottom: 1px solid var(--line); }
.faqItem button { display: flex; align-items: center; justify-content: space-between; gap: 20px; width: 100%; padding: 23px 0; font-size: 14px; text-align: left; }
.faqItem svg { flex-shrink: 0; color: #929b84; transition: rotate 160ms; }
.faqItem p { padding: 0 28px 24px 0; font-size: 12px; line-height: 1.9; color: #8b947e; }
/* 收尾 CTA 居中,星形装饰绝对定位;页脚内容横排并用 auto margin 推开链接。 */
.closing { position: relative; border-top: 1px solid var(--line); text-align: center; padding: 85px 24px 90px; }
.closing h2 { font-size: 38px; letter-spacing: -1.3px; font-weight: 500; margin: 18px 0; }
.closing p { font-size: 13px; color: #74796d; margin-bottom: 28px; }
@@ -110,8 +138,11 @@
.footer > p { font-size: 11px; color: #a1a992; }
.footer > a:not(:first-child) { margin-left: auto; display: flex; gap: 7px; align-items: center; font-size: 11px; color: #7e8b6a; }
.footer > span { color: #abb39d; font: 8px ui-monospace, monospace; }
/* 断行标记默认隐藏,本文件没有另行显示它的媒体查询。 */
.mobileBreak { display: none; }
/* 大屏仅增大首屏上下留白。 */
@media (min-width: 1400px) { .hero { padding-top: 105px; padding-bottom: 110px; } }
/* 中屏压缩页面边距、标题、按钮及区块间距,仍保留主要双栏/三栏结构。 */
@media (max-width: 1050px) {
.nav, .hero, .space, .faq, .footer { padding-left: 30px; padding-right: 30px; }
.hero { gap: 26px; }
@@ -124,6 +155,7 @@
.featureGrid { gap: 20px; }
.faq { gap: 45px; }
}
/* 窄屏隐藏导航链接,将首屏、功能、工作流和 FAQ 转为单列,并缩小装饰及页脚内容。 */
@media (max-width: 760px) {
.nav { height: 74px; gap: 15px; padding-left: 22px; padding-right: 22px; }
.nav nav { display: none; }
@@ -159,6 +191,7 @@
.footer > p { display: none; }
.footer > span { flex-basis: 100%; }
}
/* 极窄屏进一步压缩边距和预览卡片文字,隐藏品牌副标,避免最小视口拥挤。 */
@media (max-width: 380px) {
.page :global(.brand small) { display: none; }
.hero { padding-left: 20px; padding-right: 20px; }
@@ -170,4 +203,5 @@
.ideaCard strong { margin-top: 8px; margin-bottom: 8px; }
.noteTag { font-size: 7px; padding: 2px; }
}
/* 按减少动态效果偏好关闭链接、按钮和 FAQ 图标的过渡。 */
@media (prefers-reduced-motion: reduce) { .page a, .page button, .faqItem svg { transition: none; } }
+1
View File
@@ -28,6 +28,7 @@ export default function LandingPage() {
const [active, setActive] = useState(0);
// null表示全部收起;点击当前问题置null,点击其它问题改索引。hidden与aria-expanded/controls对应同一状态。
const [faq, setFaq] = useState<number | null>(null);
// FAQ 的 hidden 只切换可见性,问答节点仍保留在 DOM;页面没有请求型加载或异步清理任务。
// 页面顺序:导航→首屏交互预览→理念横条→三个功能说明→工作步骤→FAQ→行动入口→页脚。
// 功能卡与步骤通过静态内容展示;FAQ以问题文本作key,步骤以序号作key,预览以说明作key。
// 状态仅在本次组件挂载期间保留;切换预览不自动轮播,FAQ展开不发网络请求。
+2 -1
View File
@@ -13,7 +13,8 @@ export const FeedbackContext = createContext(false);
/** 包住页面一次即可共享提示延迟;400ms首次出现,200ms内切换提示可跳过等待。
* Toaster放在右下角,最多展示三条通知,默认4500ms消失并允许关闭;颜色读取主题变量。
* children正常透传,本组件不订阅文档、队列或服务端状态。 */
* children正常透传,本组件不订阅文档、队列或服务端状态。
* visibleToasts 限制同时可见数量,不是业务事件队列容量;通知创建/撤销仍通过通知适配器调用。 */
export function AppFeedback({ children }: { children: ReactNode }) {
return <FeedbackContext.Provider value={true}>
<Tooltip.Provider delayDuration={400} skipDelayDuration={200}>
+1
View File
@@ -53,6 +53,7 @@ export interface ButtonProps
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
// 只选择宿主,不另包一层DOM;cn合并变体和调用方样式,业务逻辑不进入这个封装。
// asChild 不把链接转换成原生按钮:disabled 属性本身不保证链接无法导航,调用方须按宿主处理行为。
const Comp = asChild ? Slot : "button";
return (
<Comp
+2 -1
View File
@@ -40,7 +40,8 @@ DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
/** 组合Portal、遮罩与居中内容面板,并在children之后固定附带右上角关闭按钮。
* 面板ref和剩余属性交给Radix Content,保留焦点及外部交互事件;关闭按钮有读屏文字。
* 本封装没有单独隐藏默认关闭按钮的参数,业务若要阻止关闭应在受控开关/事件层处理。 */
* 本封装没有单独隐藏默认关闭按钮的参数,业务若要阻止关闭应在受控开关/事件层处理。
* 剩余 props 只传 Content;当前调用没有把 Overlay 的属性入口一并暴露给 DialogContent。 */
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
+5
View File
@@ -1,3 +1,5 @@
/* IconButton 的共享尺寸、交互态和 tooltip 外观;尺寸从所在 Toolbar 的自定义属性继承,缺省为 32px 控件/20px 图标。 */
/* 固定 flex 尺寸避免拥挤时压缩;inline-flex 居中图标,透明背景显露宿主表面,文字颜色继承宿主。 */
.button {
display: inline-flex;
align-items: center;
@@ -13,10 +15,13 @@
color: inherit;
cursor: pointer;
}
/* SVG 同时约束宽高与 flex-basis,防止图标在文字/布局挤压下变形。 */
.button svg { width: var(--toolbar-icon-size, 20px); height: var(--toolbar-icon-size, 20px); flex: 0 0 var(--toolbar-icon-size, 20px); }
/* 悬停填底、键盘可见焦点描边、禁用态降低透明度并提示不可操作。 */
.button:hover { background: #e9e7e3; }
.button:focus-visible { outline: 2px solid #9d7d66; outline-offset: 2px; }
.button:disabled { opacity: 0.45; cursor: not-allowed; }
/* tooltip 使用高层级深色面板与有限宽度;arrow 颜色与面板一致。 */
.tooltip { z-index: 1000; max-width: 240px; padding: 6px 9px; border-radius: 6px; background: #49372e; color: #fffdf9; font-size: 12px; line-height: 1.45; box-shadow: 0 3px 12px #372f2420; user-select: none; }
.arrow { fill: #49372e; }
+1
View File
@@ -21,6 +21,7 @@ export type IconButtonProps = Omit<ButtonHTMLAttributes<HTMLButtonElement>, "chi
export const IconButton = forwardRef<HTMLButtonElement, IconButtonProps>(
function IconButton({ icon: Icon, label, title = label, className = "", type = "button", ...props }, ref) {
// Provider存在时由Radix显示提示,移除原生title以免出现两层提示;图标自身对读屏隐藏。
// 后置 ...props 可以覆盖 aria-label 等同名属性;label 是默认名称,最终 DOM 语义由调用者共同负责。
const feedback = useContext(FeedbackContext);
const button = (
<button
+4
View File
@@ -1,3 +1,5 @@
/* Toolbar 共享表面:统一控件尺寸变量、排列间距、边框和背景;不负责自身在画布中的定位。 */
/* 提供后代按钮消费的 CSS 变量;半透明背景与 backdrop blur 处理工具栏背后的内容。 */
.surface {
--toolbar-icon-size: 20px;
--toolbar-control-width: 32px;
@@ -18,5 +20,7 @@
}
/* Text in the view controls already creates wider visual spacing. */
/* 含缩放文字的视图工具栏增加横向留白,不改变控件本身的尺寸。 */
.relaxed { column-gap: 6px; padding-inline: 6px; }
/* 分隔线保持 1px flex 基准,避免被相邻控件挤宽或挤没。 */
.divider { flex: 0 0 1px; width: 1px; height: 16px; margin: 0 2px; background: #ece6df; }
@@ -29,6 +29,7 @@ export function AccountPanel({ mode, profile, demo, onClose, onProfile, onWorksp
* HTML输入限制是交互提示,服务端仍校验真实资料/空间名称。 */
async function submit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
// busy 是本次 render 的状态值,限制后续渲染后的重复提交;不是同步 ref 互斥或持久操作幂等键。
if (busy) return;
const data = new FormData(event.currentTarget);
setBusy(true);
@@ -38,6 +39,7 @@ export function AccountPanel({ mode, profile, demo, onClose, onProfile, onWorksp
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) });
// 宿主回调也在 try 内;若回调抛错,会显示失败,但已成功的服务端修改不会因此回滚。
onProfile(updated);
} else {
const name = String(data.get("name")).trim();
@@ -49,6 +51,7 @@ export function AccountPanel({ mode, profile, demo, onClose, onProfile, onWorksp
finally { setBusy(false); }
}
// 请求没有本地 AbortController/结果代次标记;受控 Dialog 阻止普通关闭,父级强制卸载仍由宿主管理。
// 保存中禁用取消/提交,并拒绝Dialog的关闭状态变化;请求未结束不假装已成功关闭。
return (
<Dialog open onOpenChange={(open) => { if (!open && !busy) onClose(); }}>
@@ -17,6 +17,7 @@ const ActiveBoardContext = createContext<{
/** 在工作台布局内保持元数据,useMemo稳定Context对象,避免只因父渲染就通知全部使用方。 */
export function ActiveBoardProvider({ children }: { children: ReactNode }) {
// Provider 本身不监听路由/登出,也不推断哪条异步结果较新;发布、清空及身份核对由调用方负责。
const [board, setBoard] = useState<ActiveBoard>(null);
const value = useMemo(() => ({ board, setBoard }), [board]);
return <ActiveBoardContext.Provider value={value}>{children}</ActiveBoardContext.Provider>;
@@ -13,6 +13,7 @@ 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>());
// memo 按 boards 引用重算;宿主更新条目须提供新数组,不能只原地改标题/父链再期待重新派生。
const branches = useMemo(() => filterBoardDirectory(boardDirectory(boards), query), [boards, query]);
/** 活动画布或目录刷新时展开其祖先路径,防当前页藏在折叠树下。 */
useEffect(() => {
@@ -22,6 +23,7 @@ export function BoardDirectory({ boards, workspace, active, query, demo }: { boa
/** 递归渲染树,视觉缩进最多8层但不裁掉更深子项;搜索时强制展开匹配路径而不清空用户closed记录。 */
function render(items: BoardBranch[], depth = 0) {
return <ul className={styles.directoryList}>{items.map(({ board, children }) => {
// 搜索中的切换只修改 closed 记录,当前仍强制展开;清掉搜索后才看到用户折叠记录的效果。
const expanded = !!query.trim() || !closed.has(board.id);
return <li key={board.id}><div className={styles.directoryRow} style={{ paddingLeft: Math.min(depth, 8) * 16 }}>
{children.length ? <button type="button" className={styles.directoryToggle} aria-label={`${expanded ? "收起" : "展开"}${board.title}的子画布`} aria-expanded={expanded} onClick={() => setClosed((previous) => { const next = new Set(previous); if (expanded) next.add(board.id); else next.delete(board.id); return next; })}><ChevronRight size={13} style={{ transform: expanded ? "rotate(90deg)" : undefined }} /></button> : <span className={styles.directorySpacer} />}
@@ -23,6 +23,7 @@ const Renderers=createContext(defaultCanvasRenderers);
export function useCanvasRenderers(){return useContext(Renderers);}
/** extensions仅在useState惰性初始化时捕获;后续prop换引用不会热替换已打开项目的schema/renderer。 */
export function CanvasRuntimeHost({ children,extensions=[] }: { children: ReactNode;extensions?:readonly CanvasRuntimeExtension[] }) {
// 惰性初始化只装配对象/工厂,不在 render 直接打开项目;实际账户绑定和持久化租约由后续调用取得。
const [{host,renderers}] = useState(() => {
// 先组合内置与扩展模型,再按精确版本/结构验证renderer,冲突在打开项目之前失败。
const registry = createRegistry([...builtinComponents(),...extensions.flatMap(extension=>extension.components??[])]),renderers=createReactRenderers([...defaultCanvasRenderers.definitions,...extensions.flatMap(extension=>extension.renderers??[])],registry);
@@ -51,6 +52,7 @@ export function CanvasRuntimeHost({ children,extensions=[] }: { children: ReactN
* 真正卸载才发起host.close,保存失败由宿主错误回调/日志报告,不在此丢弃根或强行释放leader。 */
useEffect(() => {
const generation = ++lifetime.current;
// cleanup 不等待 close 完成,也不主动重试;异步失败经 host 的错误通道和此处 catch 报告。
// 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]);
@@ -1,9 +1,15 @@
/* EditableTitle 的只读标题/输入框切换样式;壳体作为错误提示的定位参照,网络和提交状态由组件控制。 */
/* 相对定位保留标题所在行,绝对定位错误框不会把工具栏向下撑开。 */
.shell { position: relative; display: flex; align-items: center; }
/* 两种状态共用高度、字体和透明边框,降低进入编辑时的尺寸跳变。 */
.title, .input { box-sizing: border-box; min-width: 0; max-width: 100%; height: 32px; padding: 0 6px; border: 1px solid transparent; border-radius: 5px; background: transparent; color: inherit; font: inherit; line-height: 30px; }
/* 非编辑标题单行省略;悬停仅作用于可用按钮,选中态由 data-selected 控制。 */
.title { overflow: hidden; white-space: nowrap; text-overflow: ellipsis; text-align: left; }
.title:hover:not(:disabled) { background: #f0ece6; }
.title[data-selected="true"] { background: #ede5dc; border-color: #d3c0b2; }
.title:disabled { cursor: default; }
/* 编辑态给定目标宽度但受上方 max-width 限制,readonly 时降低透明度。 */
.input { width: 220px; border-color: #a47764; background: white; outline: none; }
.input[readonly] { opacity: 0.65; }
/* 错误框显示在标题下方,并以视口宽度限制最大宽度;长说明允许换行。 */
.error { position: absolute; top: calc(100% + 10px); left: 0; width: 260px; max-width: calc(100vw - 40px); padding: 8px 10px; border: 1px solid #e5c7b8; border-radius: 7px; background: #fff9f3; color: #a25439; font-size: 12px; line-height: 1.6; white-space: normal; box-shadow: 0 4px 12px #32251a0a; }
@@ -53,6 +53,7 @@ export function EditableTitle({ title, editable, onSave, className = "" }: {
cancelled.current = true;
setError("");
setMode("selected");
// 不保存帧句柄做卸载取消;执行时通过 ref 可选访问,目标已卸载则不聚焦。
requestAnimationFrame(() => button.current?.focus());
}
/** 同步ref挡重复提交,trim后校验非空及UTF-8≤300字节;同名不发请求。
@@ -70,6 +71,7 @@ export function EditableTitle({ title, editable, onSave, className = "" }: {
setMode("saving");
setError("");
try {
// onSave 没有本组件的 signal/代次护栏;切换目标或卸载后的结果隔离需由宿主回调保证。
await onSave(name);
cancelled.current = true;
setMode("idle");
@@ -78,6 +80,7 @@ export function EditableTitle({ title, editable, onSave, className = "" }: {
setMode("editing");
} finally { pending.current = false; }
}
// title prop 改变不自动覆盖正在编辑的 draft;同名判断使用本次 save 闭包看到的 title。
// saving保留input但只读;输入法组合不把Enter当提交,键盘事件停止冒泡避免触发画布快捷键。
return (
<div ref={shell} className={`${styles.shell} ${className}`}>
+5
View File
@@ -61,6 +61,7 @@ export default function GraphPage({ workspaceId, boardId }: { workspaceId: strin
// 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。 */
// 此错误显示只检查页面 active;它不像结果采用分支那样再核对 currentIdentity。
const reportBackground=(error:unknown)=>{if(active)setError(errorText(error));};
/** 已建立partial engine且身份仍有效时,交工作区继续完整读取;视图离开不等于关闭可恢复项目。 */
const completeInBackground=()=>{if(partialEngine?.loading.getState()&&currentIdentity?.())void partialRuntime?.resumeLoading(partialEngine.getSnapshot().document.projectId).catch(reportBackground);};
@@ -68,6 +69,7 @@ export default function GraphPage({ workspaceId, boardId }: { workspaceId: strin
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&&currentIdentity?.()){setLoaded(previous=>previous?.route===route&&previous.engine===engine?previous:{board:next.board,engine,route});setPreview(null);}else{completeInBackground();return;}await engine.persist();}})();
// finally 清空运行句柄,再对等待槽续泵;latest 只留最近预览,并非逐块持久操作队列。
pumping=task.finally(()=>{pumping=undefined;if(latest&&active)void pump().catch(reportBackground);});return pumping;
}
@@ -91,6 +93,7 @@ export default function GraphPage({ workspaceId, boardId }: { workspaceId: strin
catch(error){reportBackground(error);}
}
// 完整模型到来先等partial采用链结束,再open同一项目,避免尾部覆盖尚在发布的前缀根。
// 任一次泵拒绝会沿外层 catch 显示错误,不继续把完整结果冒充已经成功采用。
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});
@@ -101,12 +104,14 @@ export default function GraphPage({ workspaceId, boardId }: { workspaceId: strin
return () => { active = false;latest=undefined; controller.abort();completeInBackground(); };
}, [workspaceId, boardId, route, demo, retry, router, runtimeHost]);
// 即使effect清理/新读取尚未完成,只显示route匹配的结果,不短暂闪出上个board。
// route 只包含两个路径参数,不包含 demo/retry/authEpoch;同路由这些值变化时由 effect 清状态和取消护栏处理。
const current = loaded?.route === route ? loaded : null;
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 });
// 这是当前页面的无条件清空,不删除 runtime 或目录记录,也不是按 boardId 比对的条件撤回。
return () => setActiveBoard(null);
}, [board, workspaceId, setActiveBoard]);
// 本board在侧栏改名后优先读共享新标题;其它board的活动状态不能覆盖当前名称。
+3 -1
View File
@@ -7,13 +7,15 @@
import { useState } from "react";
import styles from "./workspace.module.css";
/** 同一失败URL不反复重载;large仅控制尺寸,图片不发送Referrer,alt保留用户身份文本。 */
/** 只记住最近一次失败URL;large仅控制尺寸,图片不发送Referrer,alt保留用户身份文本。
* 换 URL 可以再试,若之后回到较早失败的 URL 也可能重试;不是永久失败地址集合。 */
export function UserAvatar({ name, url, large = false }: {
name: string;
url?: string | null;
large?: boolean;
}) {
const [failedUrl, setFailedUrl] = useState<string | null>(null);
// fallback 用 slice(0,1) 取首个 UTF-16 代码单元,并非完整 Unicode 字素;空名字得到空占位。
const showImage = Boolean(url && url !== failedUrl);
return (
<span className={`${styles.avatar} ${large ? styles.avatarLarge : ""}`}>
@@ -1,3 +1,5 @@
/* 工作区壳体、目录、账户菜单与画布浮动标题;CSS Module 负责显示,账号/画布生命周期和侧栏开关由组件管理。 */
/* 侧栏宽度变量供侧栏本体和浮动标题共同消费,根容器至少占满动态视口高度。 */
.shell {
--sidebar-width: 270px;
display: flex;
@@ -5,6 +7,7 @@
background: #fffefb;
color: #37352f;
}
/* 侧栏插槽 sticky 固定在滚动视口顶部;裁剪展开过渡期间的内容,层级高于画布。 */
.sidebarSlot {
position: sticky;
top: 0;
@@ -14,6 +17,7 @@
overflow: hidden;
z-index: 30;
}
/* 侧栏采用纵向 flex,导航区可滚动、账户区留在底部;最大宽度给主区域保留至少 40px。 */
.sidebar {
display: flex;
flex-direction: column;
@@ -29,30 +33,38 @@
letter-spacing: -0.15px;
color: #37352f;
}
/* 顶行按钮分居两端;搜索框 focus-within 让输入聚焦时整个框有反馈。 */
.topRow { display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px; color: #85817b; }
.search { display: flex; align-items: center; gap: 7px; min-height: 32px; border: 1px solid #e6e3df; border-radius: 7px; padding: 4px 8px; color: #97938d; }
.search:focus-within { border-color: #b2a292; box-shadow: 0 0 0 2px #b2a29218; }
.search input { min-width: 0; width: 100%; padding: 0; background: transparent; border: 0; outline: none; font: inherit; color: #37352f; }
.search input::placeholder { color: #9a9690; }
/* flex:1 配合 min-height:0 才允许长目录在侧栏内部滚动。 */
.navigation { flex: 1; min-height: 0; overflow-y: auto; padding-top: 10px; }
/* 首页与目录链接共用行高/图标间距,长标题省略;aria-current 对当前页提供选中背景。 */
.home, .pageLink { display: flex; align-items: center; gap: 8px; padding: 4px 8px; min-height: 30px; border-radius: 6px; font-weight: 450; }
.home { width: fit-content; font-weight: 600; background: #ecebe8; }
.pageLink { margin: 1px 0; }
.pageLink svg { flex-shrink: 0; color: #87837c; }
.pageLink span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.pageLink:hover, .pageLink[aria-current="page"] { background: #eae7e2; }
/* 分组名称、空态提示和错误分别排版;错误允许在任意位置折行。 */
.sectionLabel { display: flex; justify-content: space-between; padding: 18px 7px 6px; color: #9b9690; font-size: 11px; font-weight: 400; letter-spacing: 0; }
.hint { padding: 4px 8px; color: #9a948c; line-height: 1.6; font-size: 12px; }
.error { margin: 6px 4px; font-size: 12px; color: #b14b3c; overflow-wrap: anywhere; }
/* 账户区域向侧栏底部靠齐,按钮占满宽度以便整行点击。 */
.accountArea { margin-top: auto; padding-top: 6px; }
.accountButton { display: flex; align-items: center; gap: 8px; padding: 6px 7px; min-height: 36px; width: 100%; border-radius: 6px; text-align: left; color: #37352f; }
.accountButton:hover { background: #edeae5; }
/* 头像固定尺寸并裁成圆形,图片 object-fit:cover;缺图时容纳首字占位。 */
.avatar { display: grid; place-items: center; flex: 0 0 24px; width: 24px; height: 24px; overflow: hidden; border-radius: 50%; background: #e8e0d6; color: #705844; font-size: 12px; font-weight: 600; }
.avatar img { display: block; width: 100%; height: 100%; object-fit: cover; }
.avatarLarge { flex-basis: 36px; width: 36px; height: 36px; font-size: 16px; }
/* 账户文字允许收缩和省略,副文字在此布局隐藏。 */
.accountText { display: flex; flex: 1; flex-direction: column; min-width: 0; }
.accountText strong { font-size: 13px; font-weight: 550; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
.accountText small { display: none; }
/* 原生 popover 的面板位置/宽度约束;自身可滚动且从左下角进入,不用 CSS 切换 open 状态。 */
.accountPopup {
position: fixed;
inset: auto auto 50px 8px;
@@ -73,31 +85,40 @@
letter-spacing: -0.15px;
transform-origin: bottom left;
}
/* 仅 popover 打开时播放进入动画;backdrop 保持透明,但是否可交互仍由 popover 行为决定。 */
.accountPopup:popover-open { animation: account-enter 130ms ease-out; }
.accountPopup::backdrop { background: transparent; }
/* 菜单头、工作区字母头像及身份行共用纵向文字布局,长名称省略。 */
.accountHeading { display: flex; align-items: center; gap: 10px; padding: 8px 7px 10px; }
.workspaceAvatar { display: grid; place-items: center; flex: 0 0 36px; height: 36px; border-radius: 7px; color: #72533e; background: #eee8df; font-size: 19px; font-weight: 600; }
.accountHeading > div, .popupIdentity > div { display: flex; flex-direction: column; min-width: 0; gap: 1px; }
.accountHeading strong { font-size: 14px; font-weight: 600; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
.accountHeading small { color: #8b8780; font-size: 12px; }
/* 菜单分组和身份区域提供内间距,邮箱允许断行;工作区列表限制高度后独立滚动。 */
.menuGroup { padding: 1px 0; }
.popupIdentity { display: flex; align-items: center; gap: 8px; padding: 8px 7px 7px; }
.popupIdentity strong { font-size: 12px; font-weight: 500; }
.popupIdentity small { color: #8b8780; font-size: 11px; overflow-wrap: anywhere; letter-spacing: 0; }
.workspaceList { max-height: min(220px, 30dvh); overflow-y: auto; }
/* 菜单项整行可点,文字可收缩,图标保持宽度;disabled 态显式降低透明度。 */
.menuItem { display: flex; align-items: center; gap: 9px; width: 100%; min-height: 31px; padding: 5px 8px; border-radius: 5px; text-align: left; font-size: 13px; line-height: 20px; font-weight: 450; }
.menuItem > span:not(.workspaceMini) { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.menuItem > svg { flex-shrink: 0; }
.menuItem:hover { background: #f1f0ed; }
.menuItem:disabled { opacity: 0.45; cursor: not-allowed; }
/* 工作区小头像与新建/登出颜色区分,separator 分隔组而不引入额外布局容器。 */
.workspaceMini { display: grid; place-items: center; flex: 0 0 20px; height: 20px; border-radius: 4px; color: #82634c; background: #f1ebe3; font-size: 11px; font-weight: 600; }
.newWorkspace { color: #2383e2; }
.logout { color: #78746d; font-size: 12px; }
.separator { height: 1px; margin: 6px 7px; background: #eeece8; }
/* 左上浮动条以 --floating-left 定位;侧栏展开时偏移到侧栏右侧,位置和宽度作 240ms 过渡。 */
.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); }
/* 展开侧栏时使用实际受视口约束的侧栏宽度计算偏移;账户 popover 也继承同一偏移变量。 */
.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; }
.settingsIdentity { display: flex; align-items: center; gap: 10px; font-size: 13px; color: #89837b; overflow-wrap: anywhere; }
.settingsForm label { display: flex; flex-direction: column; gap: 6px; font-size: 13px; font-weight: 500; }
@@ -106,35 +127,48 @@
.settingsActions button { padding: 6px 12px; border: 1px solid #e2ddd6; border-radius: 6px; font-size: 13px; }
.settingsActions button[type="submit"] { background: #37352f; color: white; border-color: #37352f; }
.settingsActions button:disabled { opacity: 0.5; cursor: not-allowed; }
/* 固定全视口画布置于底层;点阵背景与加载/错误状态由后续类分别承担。 */
.graph { position: fixed; inset: 0; z-index: 0; display: flex; width: 100%; height: 100dvh; background: #fffefb; }
.graphSurface { width: 100%; height: 100%; background-image: radial-gradient(#b8aea245 1px, transparent 1px); background-size: 22px 22px; }
.graphStatus { position: absolute; left: 50%; top: 72px; transform: translateX(-50%); max-width: calc(100vw - 48px); padding: 8px 12px; border-radius: 6px; background: #fffefbe6; color: #9a948c; text-align: center; font-size: 13px; }
/* 账户菜单从轻微下移/缩小过渡到正常位置与不透明状态。 */
@keyframes account-enter { from { opacity: 0; transform: translateY(4px) scale(0.985); } to { opacity: 1; transform: translateY(0) scale(1); } }
/* 较窄视口将侧栏目标宽度收为 240px;仍受上方 max-width 约束。 */
@media (max-width: 600px) { .shell { --sidebar-width: 240px; } }
/* 减少动态效果偏好下关闭菜单进入动画;浮动条过渡在文件后部单独关闭。 */
@media (prefers-reduced-motion: reduce) { .accountPopup:popover-open { animation: none; } }
/* 浮动条 logo 链接沿用 Toolbar 控件尺寸与键盘焦点样式,保证和旁边按钮对齐。 */
.logoLink { display: inline-flex; align-items: center; justify-content: center; flex: 0 0 auto; box-sizing: border-box; width: var(--toolbar-control-width, 32px); height: var(--toolbar-control-height, 32px); padding: 4px 6px; border-radius: 5px; }
.logoLink img { display: block; width: var(--toolbar-icon-size, 20px); height: var(--toolbar-icon-size, 20px); }
.logoLink:hover { background: #e9e7e3; }
.logoLink:focus-visible { outline: 2px solid #9d7d66; outline-offset: 2px; }
/* 浮动头像按钮同样使用共享尺寸;从浮动条打开的账户面板改为顶端定位。 */
.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; }
/* clamp 把账户面板左边界限制在视口内;transform-origin 随顶部锚点改为左上。 */
.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; }
/* 浮动头像采用图标尺寸,标题允许 flex 收缩但保留溢出区域给编辑错误提示。 */
.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; } }
/* 极窄且侧栏仍展开时隐藏 logo/标题/分隔,保留浮动条其余控件。 */
@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; }
.directoryRow .pageLink { flex: 1; min-width: 0; padding-left: 3px; }
/* 可折叠项按钮与叶项占位共享 20px 列宽,保持各行标题对齐;焦点内描边不扩尺寸。 */
.directoryToggle, .directorySpacer { flex: 0 0 20px; width: 20px; height: 26px; }
.directoryToggle { display: grid; place-items: center; border: 0; border-radius: 4px; background: transparent; color: #87837c; cursor: pointer; }
.directoryToggle:hover { background: #eae7e2; }
+4 -1
View File
@@ -27,9 +27,11 @@ export async function api<T>(
path: string,
options: RequestInit = {},
): Promise<T> {
// fetch 本身拒绝(网络失败/发出前取消)时原异常直接传播;下面的 ApiError 包装只覆盖响应读取与 HTTP 错误。
const res = await fetch("/api/foundry/" + path, {
...options,
// FormData让浏览器生成boundary,其它请求默认JSON;调用方headers可覆盖默认Content-Type。
// FormData让浏览器生成boundary,其它请求默认JSON;这里只展开 headers 的自有可枚举字段。
// 普通对象可覆盖同名默认字段;Headers 实例/二元组数组不会在这里被规范化成对象键值。
headers: { ...(options.body instanceof FormData ? {} : { "Content-Type": "application/json" }), ...options.headers },
});
let body: unknown;
@@ -49,6 +51,7 @@ export async function api<T>(
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"));
}
// 204 的 null 同样通过类型断言返回;调用者选 T 时须包含真实的空响应约定。
return body as T;
}
/** 公开项目标识,来自构建时NEXT_PUBLIC配置;不是密码或用户账户身份。 */
+3
View File
@@ -10,6 +10,7 @@ type Request = <T>(path: string, options?: RequestInit) => Promise<T>;
/** 分页取齐board后返回按ID去重的列表,后出现的同ID记录更新内容。
* next_offset必须严格前进且安全,seen防重复游标,不能只拿首页就推断某canvas没有父分区。 */
export async function loadBoardDirectory(request: Request, workspace: string, signal?: AbortSignal) {
// Map 是本次读取的临时去重表,全部分页成功才返回;某页失败不发布已积累的半份目录。
const boards = new Map<string, Board>();
let offset = 0;
const seen = new Set<number>();
@@ -28,6 +29,7 @@ export type BoardBranch = { board: Board; children: BoardBranch[] };
/** 每条记录沿父链检查循环;父缺失或成环时作为展示根,避免递归挂载成无限树。
* 同层顺序沿用输入列表,不在此另做排序或写回parent_board_id。 */
export function boardDirectory(boards: Board[]): BoardBranch[] {
// 重复 ID 采用最后一条数据、沿用首次插入顺序;分支节点是新对象,board 本身仍借用输入引用。
const entries = new Map(boards.map((board) => [board.id, { board, children: [] as BoardBranch[] }]));
const roots: BoardBranch[] = [];
for (const entry of entries.values()) {
@@ -56,6 +58,7 @@ export function filterBoardDirectory(branches: BoardBranch[], query: string): Bo
if (!term) return branches;
return branches.flatMap((branch) => {
if (branch.board.title.toLocaleLowerCase().includes(term)) return [branch];
// 只重建需要裁剪 children 的祖先容器;直接命中的分支复用原对象,不深复制整棵树。
const children = filterBoardDirectory(branch.children, term);
return children.length ? [{ ...branch, children }] : [];
});
+3
View File
@@ -12,6 +12,7 @@ export type LegacyMigrationContext={accountId:string;rootId?:string;registry:Reg
/** 锁/Storage可注入测试,生产默认使用navigator.locks/localStorage;身份和signal都由宿主管理。 */
type BrowserScope={scope:CanvasMigrationScope;isCurrentIdentity():boolean;signal:AbortSignal;locks?:LockManager;storage?:Storage};
/** 同账户/工作区共享编辑锁键,与持久迁移记录同一作用域,跨页面形成读写互斥。 */
// 键不含 rootId:独占迁移锁会暂时排斥同账户/工作区所有共享旧编辑租约,而非仅目标树。
const lockKey=(scope:CanvasMigrationScope)=>canvasMigrationStorageKey(scope)+':editing';
/** 缺少Web Locks明确拒绝安全迁移装配,不用不可靠的计时器冒充独占锁。 */
function ports(options:BrowserScope){
@@ -30,6 +31,7 @@ export async function claimLegacyCanvasEditing(options:BrowserScope&{canvasId:st
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;});
// signal 未传给 locks.request;取消在回调授予时检查,持锁后通过 abort 监听释放。
const request=locks.request(lockKey(options.scope),{mode:'shared',ifAvailable:true},async lock=>{
try{
check(options);if(!lock)throw fault('migration_busy');
@@ -56,6 +58,7 @@ export function createBrowserCanvasMigration(options:BrowserScope&{registry:Regi
isCurrentIdentity:options.isCurrentIdentity,signal:options.signal,createId:()=>crypto.randomUUID(),
// 任一共享旧编辑页仍开着时独占锁不可用;调用方先保存/释放自己的编辑租约,再进入迁移。
lock:work=>locks.request(lockKey(options.scope),{mode:'exclusive',ifAvailable:true},async lock=>{
// work 的 Promise 整体保持独占租约;拒绝同样交还锁,pending 记录不会随锁释放而自动删除。
check(options);if(!lock)throw fault('migration_busy');return work();
}),
});
+3 -1
View File
@@ -4,7 +4,7 @@
*/
/** 由应用注入JSON/FormData请求端口,凭证与错误形状沿用宿主API封装。 */
type Request = <T>(path: string, options?: RequestInit) => Promise<T>;
/** 旧上传接口的素材响应形状,原件规范URL与尺寸由返回前校验。 */
/** 旧上传接口的素材响应形状;返回前检查 ID、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;
@@ -12,6 +12,7 @@ export const ASSET_LIMIT = 20 * 1024 * 1024;
* 调用方可提供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");
// 文件大小是本地发送前检查;不在这里解码图片、计算内容摘要或确认 mime_type。
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 });
@@ -25,5 +26,6 @@ export async function uploadCanvasImage(request: Request, workspace: string, fil
let url: URL;
try { url = new URL(asset.url); } catch { throw new Error("invalid_asset_response"); }
if (!/^[a-f\d]{8}-[a-f\d]{4}-[a-f\d]{4}-[a-f\d]{4}-[a-f\d]{12}$/i.test(asset.id) || url.origin !== "https://api.foundry.entroia.com" || url.pathname !== `/projects/${process.env.NEXT_PUBLIC_FOUNDRY_PROJECT || "endoleap"}/workspaces/${workspace}/assets/${asset.id}` || url.search || !Number.isFinite(asset.width) || asset.width < 1 || !Number.isFinite(asset.height) || asset.height < 1) throw new Error("invalid_asset_response");
// bytes/mime_type 未在此逐项核验,URL hash/用户名密码也未另设拒绝条件;不能视为新版资源权威证明。
return asset;
}
+1
View File
@@ -1,6 +1,7 @@
/**
* 旧卡片 renderer 使用的结构化输入类型,与 headless 元素 schema 分离。
* 这些类型描述投影/显示约定,不能用类型断言替代 registry 对原始数据、版本和引用的运行时校验。
* 本文件仅定义 TypeScript 形状,不运行默认值/迁移/编辑行为;投影器负责从标准 element 生成这些显示字段。
*/
/** 旧绘制路径使用的二维点;是否局部/场景由投影路径的调用约定决定。 */
type Point={x:number;y:number};
+17 -7
View File
@@ -11,7 +11,9 @@ 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. */
/** 可选缓存的共享实现,限制本次边数和总曲线段数。
* 缓存命中仍扣减段预算,不让缓存历史悄悄放宽相同查询的工作量语义。 */
* 缓存命中仍扣减段预算,不让缓存历史悄悄放宽相同查询的工作量语义。
* maxEdges 限访问的边数,maxSegments 同时作单边与总段数限;存在待查端点时仍遍历所有 projected nodes。
* 几何回调同步执行且没有时间抢占,回调产出数组前的成本不由预算控制;输入须属于同一有效显示帧。 */
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>();
@@ -29,21 +31,25 @@ function connectionPaint(projection:ReturnType<typeof projectCanvas>,registry:Re
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'&&registry.resolve(record)==='known'?registry.components.get(record.componentId)?.geometry:undefined;
// 同时捕获显示边、真实记录、端点位置/大小、锚点及变换;预览/移动都可能改变曲线范围。
// 签名复制锚点/端点数值/transform,但 record 与 edge 借用原不可变引用,缓存不深捕获它们。
// 外部几何服务/协议配置不在签名内,须通过下方 geometryRevision 或 registry 对象变化失效。
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);
// 即使本边范围是空,也可缓存“已证明没有额外范围”,避免重复调用几何服务。
// 锚点与基础输入每轮仍重新求取;签名相等才复用曲线范围/段数,跳过 segments/stroke 计算。
// 空 region 可表示笔画未超原生范围或空 segments,不能将其误当“整条边没有显示内容”。
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。
// 非空段数超过本次剩余预算则追加全局 paint_budget 并终止后续边;空数组仍要求有效 stroke。
// 空数组缓存0段/无region,属本几何协议声明无笔画,不误标 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();
// 原生范围由两端节点矩形并集包围;只有实际笔画包围盒超出它,才添加补充空间行。
// 原生范围取两端节点未旋转矩形的联合 AABB;曲线控制点凸包+半线宽 AABB 超出时才添整块补充范围。
// 这是保守估计而非曲线精确极值,允许额外保留可见候选;不计算命中距离或直接控制 renderer 挂载。
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,不影响其它已知范围。
@@ -56,11 +62,15 @@ function connectionPaint(projection:ReturnType<typeof projectCanvas>,registry:Re
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,
* 仅换闭包或修改服务内部状态不在元素签名中,不能指望缓存自行察觉。 */
* 仅换闭包或修改服务内部状态不在元素签名中,不能指望缓存自行察觉。
* 每视图独立实例;缓存按 registry 对象/project/canvas/revision 隔离,不另核 editor owner。
* cache 保留边几何结果,index 存本轮额外区域;超过预算的其它边可暂留缓存但不进入本轮 regions。 */
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 是累计重新计算数,不把缓存命中记成一次曲线计算。 */
* unknown 中元素 ID 表示该边无法解释,字符串 paint_budget 表示余下查询不完整,不能据空 regions 判不可见。
* query 是可变 index 的方法,旧返回对象也查最新网格,unknown 则是本轮冻结列表。
* computed 计通过锚点/变换检查后未命中的几何尝试(可随后失败),不等于成功区域数;clear 不重置累计值。 */
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)});
+6 -2
View File
@@ -9,7 +9,9 @@ import {nodeConnector,type Connector,type ConnectorSide} from '../canvas-geometr
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 使用同一布局。 */
* 找到具名 port 时,同 direction 的 port 沿高度均匀分布,与兼容 CanvasEdge 使用同一布局。
* 即使 fallback hint 选择了另一侧,已识别 port 仍按高度做 y 偏移;这是旧布局协议,不适用于原生 Handle。
* nodeConnector 先负责节点边界/旋转,下面直接叠加 y 偏移,不再把这段偏移当局部向量旋转。 */
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仅属于旧卡片布局约定。
@@ -21,7 +23,9 @@ export type MeasuredNode={readonly internals:{readonly positionAbsolute:{x:numbe
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,不编造中心点。 */
* source 仅查 source 列表;target 按 target 再 source 顺序查,匹配 loose 模式。
* 非空 handleId 精确查找,空字符串/null/undefined 取首项;无匹配或未测量返回 undefined。
* 假定 positionAbsolute 与 Handle 尺寸采用同一场景单位;直接叠加,不在这里验证有限值或再次处理节点旋转。 */
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]};
+11 -5
View File
@@ -16,7 +16,9 @@ export type ElementSourcePorts={
};
/** 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 的监听共享上游桶。 */
/** buckets 按 elementId 共享上游查询结果/观察器,每个 forElement 另有即时读取缓存。
* 首次 subscribe 才接上游,全部桶退订才释放 previous 根引用;创建源或直接读不持有上游租约。
* 依赖不可变快照与完整 Commit.changes 合同,不从队列/网络结果猜测元素是否已改变。 */
export function createElementSources(ports:ElementSourcePorts){
// 失败标记使下次成功查询即使值等价也通知恢复;不把异常缓存成一条虚假元素记录。
type Bucket={value:ElementLookup;listeners:Set<()=>void>;failed?:boolean};
@@ -28,11 +30,13 @@ export function createElementSources(ports:ElementSourcePorts){
const before=previous!;previous=next;
const ids=new Set<Id>();
if(before.document!==next.document){
// 显式元素/字段改动走 ID 路由;无 changes 的文档替换无法精确定位,复查全部已订阅元素。
// 显式变化只路由 field.target.id / element.id,不按 canvas 变更自动扩散到全体子元素。
// 文档替换且无 changes 时复查全部已订阅桶;路由准确性依赖发布者提供完整变更及 lookup 的依赖合同。
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 的桶必须复查。
// loading 对象或其读取基线变动可改变 absent/unloaded 判定,因此复查非 present 桶。
// 不将任意 runtime 更新都当元素变化;failed 桶也只有进入本次候选 IDs 才会在此重新查询。
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>();
@@ -50,7 +54,8 @@ export function createElementSources(ports:ElementSourcePorts){
return Object.freeze({forElement(id:Id):ExternalSource<ElementLookup>{
let value:ElementLookup|undefined;
return Object.freeze({
/** 每次从当前根查询并按值保留稳定引用,不能只依赖通知时机猜测最新数据。 */
/** 每次查询当前根,用 equal 保持等价 ElementLookup 的局部引用;读取不改共享桶通知基线。
* 不深复制查询值,lookup 必须返回适合不可变使用的结果;异常直接抛给调用方读取边界。 */
getSnapshot(){const next=ports.lookup(ports.getSnapshot(),id);if(!value||!equal(value,next))value=next;return value;},
/** 每个订阅包装独立 listener,即使调用方传同一个函数也能分别释放。 */
subscribe(notify:()=>void){
@@ -59,7 +64,8 @@ export function createElementSources(ports:ElementSourcePorts){
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 根引用;其它元素仍有监听时继续共享。
// 最后一个桶清空才 stop 上游并释放 previous;同回调的多次订阅因 listener 包装仍分别退订。
// stop 异常不被捕获,后续状态重置不保证执行;宿主的取消订阅端口应不抛错。
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;}};
},
});
+10 -5
View File
@@ -6,23 +6,28 @@ 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 在一次批次中只出现一次。 */
/** 创建一条可手动 flush 的通知队列;Set 按键身份去重,并保留首次插入顺序。
* clock/publish 由宿主实现,不读取 DOM/模型;调度器异常原样抛出,不另设重试或全局错误处理。 */
export function createFrameQueue<K>(clock:FrameClock,publish:(dirty:ReadonlySet<K>)=>void){
let dirty=new Set<K>(),scheduled:{handle?:unknown}|undefined,disposed=false;
/** 同步排空已有脏项,先换新 Set 再回调,回调中的新失效留给下一批,不会被本批 clear 擦掉。 */
/** 同步排空:先撤当前调度并换新 Set,再 publish,回调中的 invalidate 留给下一批。
* batch 以 ReadonlySet 类型借出但未运行时冻结,与新 dirty 已分离;publish 抛错不把本批自动放回队列。 */
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 对象身份防止已取消的旧回调误刷后来的批次。 */
/** 逐项加入 dirty,至多安排一个帧回调;pending 对象身份防旧回调误刷后来一批。
* request 抛错会清 scheduled,但保留 dirty 供后续 invalidate/flush 重试;不自动再 request。 */
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。 */
/** 标记 disposed 后尽力取消帧并清 dirty;后续 invalidate/flush 无效,不替上游关闭 editor。
* 这里不捕获 clock.cancel 异常,宿主取消端口须可靠清理;重复 dispose 不会重试一次失败取消。 */
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,常用于不能等下一帧才反映的状态边界。 */
* 上游标 immediate 时先 invalidate 再同步 flush,合并的旧失效也一起通知;读取从不等待帧。
* 同一工厂的不同订阅不共享队列;退订函数每次都会调用上游 stop,幂等性依赖该 stop 合同。 */
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());
+9 -4
View File
@@ -12,21 +12,26 @@ const sameGeometry=(a:Geometry,b:Geometry)=>a.x===b.x&&a.y===b.y&&a.width===b.wi
/** 子画布变换只依赖 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. */
/** 缓存实例应归一个显示上下文;计数器累计实际计算次数供性能诊断,不是模型版本。 */
/** 一个显示上下文使用一份缓存:三个 Map 分别缓存节点几何、子 canvas 变换和连线路径。
* 调用方须按 begin→若干 node/child/edge→end 使用;这里不校验调用配对,也不提供失败回滚/并发帧隔离。
* 累计计数只在实际重算后增加,不是文档版本或当前命中次数;返回投影值冻结,键只保留所需输入。 */
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 相同也不能混用。 */
/** scope 按对象身份、project/canvas 按 ID 隔离;任一变化清三张表,累计计数不清。
* 每轮重建 seen,随后命中缓存也记一次访问;begin 不自行遍历文档或判断元素仍可见。 */
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;},
/** 计算分区内部坐标到场景的变换,单位比例变化也会使缓存失效。 */
/** 分区入口几何+父变换+contentScale/unitScale 生成内部 canvas→场景变换,单位变化也使缓存失效。
* 不含 collapsed 等显示决策,是否调用 child 由投影器决定;使用核心 childCanvasTransform 解释单位。 */
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,未访问旧项可暂留至下次完整清理,缓存不是业务恢复事实。 */
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}),
+22 -10
View File
@@ -29,7 +29,10 @@ const finitePoint=(point:Point)=>!!point&&Number.isFinite(point.x)&&Number.isFin
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。先取空间候选再精测,返回有序命中及完整性诊断;
* 此函数不触发编辑、不消费原生事件,也不替宿主决定是否认领一次输入。 */
* 此函数不触发编辑、不消费原生事件,也不替宿主决定是否认领一次输入。
* 同步消费借入的 projection/query/services,不捕获另一份文档;几何服务须按此显示帧返回同坐标系的结果。
* maxCandidates 约束节点精测+边访问,maxCurveSteps 由全部边共享,maxSegments 则是每边上限;
* 前置 Map/层序/空间候选收集和用户几何回调不被这些计数抢占,不能据此承诺总耗时硬上限。 */
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;
@@ -38,34 +41,43 @@ export function hitProjectedCanvas(projection:Projection,queries:DocumentQueries
// 屏幕位置减容器 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 可补上索引尚未覆盖的预览位置。
// 先收集 previewIds,再逐 canvas 反变换查局部索引,以 Set 合并;真正精测前会排除未出现在投影中的 ID。
// 索引几何未随瞬态 preview 改动时,renderer 必须补 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/入口路径;检测循环和无效父关系。 */
/** 从元素真实 owner 沿 parent 回到当前显示根,反转成根→owner 路径;不是修改元素归属。
* 每条命中都生成显示路径,循环/父关系断裂作为该目标诊断;viewSessionId 只沿用调用值,不查询 session 注册表。 */
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;};
// 节点精测只处理投影中实际存在的候选;未知/降级组件使用矩形协议,单元素协议失败隔离为诊断。
// 节点精测只处理投影中实际存在的候选;unknown/fallback/非 node 几何协议统一用矩形显示外壳。
// confirmed 仅证明采用的显示几何命中,不宣称已经理解未知组件原始轮廓。目标回调须按 Error 风格抛错。
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&&registry.resolve(record)==='known'?registry.components.get(record.componentId)?.geometry:undefined,protocol=resolved?.kind==='node'?resolved:rectangleGeometry,p=toLocal(t,point),localTolerance=tolerance/t.scale;
// 组件协议返回的几何边界也要校验;精测依据局部旋转几何和按比例换算的容差。
// bounds 在此用于合法性检查,最终命中由 hit 返回布尔值;不能把通过 bounds 检查当成已经命中。
// geometry 使用 node 的显示尺寸/位置(含预览)反算,element 仍是同帧文档记录,容差换算为 owner 局部单位。
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');
// 连线与节点共用候选预算;逐边解析真实显示端点和画笔,失败不影响其它边的查询。
// 节点精测在先,连线随后遍历 projection.edges(不再做空间粗筛),两者共用 work 预算。
// 单边解析显示端点/画笔失败记诊断,继续其它边;某些边因节点预算已耗尽可能尚未访问。
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'&&registry.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 曲线优先,随后才用组件协议;验证段数及每段四个有限点,避免异常几何耗尽查询。
// 自定义 edgeGeometry 非 null/undefined 的结果优先(包括空数组),否则调用已知 connection 协议。
// 先检查每段4个有限点及每边段数,再求距;回调本身仍同步执行,没有超时或抢占。
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;不把只点中心线误当成整条可见线的命中范围。
// strokeWidth 同样优先服务回调,再用组件协议,要求是场景单位的有限非负数。
// 有效点击半径=场景容差+线宽/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'});}}
// 尚未完整加载的显示分区可能漏掉未收到元素,命中列表存在也不代表整场景查询完整。
// 只核当前 projection.transforms 覆盖的显示 canvas 是否完整;不推断折叠内部的未显示元素。
// 命中列表非空仍可能不完整,diagnostics 汇总几何解释/预算/精度与模型覆盖问题。
if([...projection.transforms.keys()].some(id=>!queries.coverage.completeCanvasIds.includes(id)))diagnostics.push({code:'model_scope_incomplete'});
// 排序按显示层级从高到低,同层节点优先于边,再按文档层序倒序;只返回结果,不直接改变选择。
// 排序:layer 降序→同层 node 优先→文档层序倒序,不以 confirmed/possible 抢改显示层级顺序。
// complete 仅等于本次 diagnostics 为空;消费者仍需理解命中 certainty,函数不改变 selection 或手势。
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});
}
+1
View File
@@ -1,6 +1,7 @@
/**
* 可移植 bindings 的公共入口,集中导出投影、查询、输入与显示租约工具。
* 不导入 React/React Flow;框架 hooks、组件注册与命令 Context 通过 react-flow.ts 明确选择。
* 此入口只重导出,不创建 view、订阅或缓存实例;scene coordinator 与分桶源的内部接线不在此集中公开。
*/
/** Portable scene projection and hit queries: no React or React Flow imports. */
// 模型到场景投影、几何缓存和受预算控制的命中查询。
+5 -2
View File
@@ -16,7 +16,8 @@ const overlayTarget = '[data-canvas-overlay-view],[role="dialog"],[role="menu"],
/** 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 父关系。 */
* portal 需要自身携带浮层标记/角色;这里只检查实际路径,不推断不存在于 DOM 路径的 React 父关系。
* closest 可查节点的祖先;两个标记独立累计,可同时为 true。DOM 端口异常原样传播,不在此兜底成未编辑。 */
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?.()??[])]){
@@ -28,7 +29,9 @@ export function canvasKeyContext(event:{readonly target?:unknown;composedPath?:(
}
return {textEditing,overlay};
}
/** 已被宿主处理的事件不再认领;输入法/文本与浮层直接截住,随后按固定优先级短路调用处理器。 */
/** 已 defaultPrevented 的事件先返回 unclaimed,即使同时是文本/浮层输入也不再次认领。
* composing/textEditing 返回 text,overlay 返回 overlay;这两层只是上下文判断,不调用下方 handler。
* gesture→tool→shortcut 同步短路,抛错直接传播;返回 claim 不会自行 preventDefault/stopPropagation。 */
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';
+6 -3
View File
@@ -3,10 +3,12 @@
* owner token 区分同 ID 的不同挂载实例;卸载实例的迟到失效不能触发后来新实例的测量。
*/
/** One renderer store's transient measurement requests. Not model or history state. */
/** 每个 renderer store 独立创建;默认微任务调度,也可注入测试或宿主 defer。 */
/** 每个 renderer store 独立创建:owners 记录挂载 token→nodeId,dirty 只记录待测 token。
* defer 默认为微任务;update 接收执行时仍有效的 ID,不接触文档。工厂没有全局 dispose/flush 公开入口。 */
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 再回调,让重入失效进入下一轮。 */
/** 执行时解析仍有效 owner 并按 ID 去重,清 queued/dirty 后 update;重入 invalidate 可安排下一轮。
* update 异常直接传播,本批不自动回队重试;卸载 token 的请求被排除,新 token 须自行 invalidate 才入本批。 */
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]);
@@ -16,7 +18,8 @@ export function createNodeInternalsBatch(update:(ids:string[])=>void,defer:(flus
acquire(id:string){
const owner=Symbol(id);owners.set(owner,id);
return Object.freeze({
/** 仅活跃租约可以变脏,同一批最多安排一次延后 flush。 */
/** 活跃 token 去重变脏,queued 防重复 defer;多个 token 若同 nodeId,flush 时再合成一个 ID。
* 不捕获 defer 异常:若宿主抛错但未排任务,queued 不自动复位,宿主应使用可靠调度端口。 */
invalidate(){if(!owners.has(owner))return;dirty.add(owner);if(!queued){queued=true;defer(flush);}},
/** 卸载同时移除 owner 和其未执行请求;已排微任务可自然执行为空,不取得新实例权限。 */
dispose(){owners.delete(owner);dirty.delete(owner);},
+10 -5
View File
@@ -9,19 +9,24 @@ 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 网格。 */
/** entries 保存 overlayId→{token,entry},snapshot 沿用 Map 插入顺序(替换同 ID 不移位),index 只按屏幕 bounds 查询。
* 不订阅文档/视口,不自行核 target 存活;调用者更新位置,并在 visible 时提供当前目标有效性判断。 */
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 的挂载。 */
/** 先更新 snapshot/index,再同步通知监听集合副本;只发布位置事实,不挂载 DOM。
* 监听器异常直接传播且会中断后续通知,不回滚已发布条目;不能据 register/update 抛错推断它未生效。 */
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 索引校验矩形,拒绝不合法的外部边界。 */
/** 重建并冻结 entry 外壳/target/bounds,校验 ID/placement/目标代次和合法矩形。
* focused/composing 的布尔值由类型合同约束,此处不另验;扩展字段浅保留,不是任意对象深克隆。 */
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 先完整校验,新旧序列化值相同时不发布;过期租约的更新/释放均无效果。 */
* 注册时 ++serial 分配 token,覆盖注册使旧租约失效;update 先检查 token,再捕获并按 JSON 文本比较。
* 等值 update 不发布,register 替换始终发布;release/clear 只移除瞬态浮层,不删除元素。 */
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();}});},
/** 正常浮层按视口交集筛选;已聚焦或输入法组合中的浮层保留,但目标必须仍有效。 */
/** 先验证查询矩形并查网格,再按 snapshot 顺序筛选:current(target) 必须真,之后才考虑焦点/组合状态或交集。
* 焦点/输入法保留不会绕过目标有效性,也不自动移除过期 entries;返回新数组,不发布位置更新。 */
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();},
+9 -4
View File
@@ -12,18 +12,23 @@ const valid=(r:Rect)=>r&&[r.x,r.y,r.width,r.height,r.x+r.width,r.y+r.height].eve
/** 包含边界接触的保守交集,与 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。 */
/** cellSize 决定网格尺度,maxCells 限单区域/查询展开格数,默认512和256。
* rows 存矩形,cells 存格→ID桶,members 存 ID→格键,overflow 存无法安全展开的 ID;全部可从输入重建。
* maxCells 不限制总区域数/总表大小;大区域或大查询保守回退扫描,可能线性耗时。 */
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 走保守路径。 */
/** 用 floor 算包含两端边界的格区间,负坐标也一致;恰好压格线的矩形可占相邻两格。
* 不安全整数或格数超 maxCells 返回 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,每次查询都检查。 */
/** 先复制全部输入矩形并验证非空 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,大查询扫描全部行,最后统一按矩形相交过滤。 */
/** 覆盖格+overflow 合并候选,无法安全展开的大查询改扫全部 rows;最后含边界相交精筛。
* 返回新 Set(只读仅是类型),外部改它不影响索引;不排序,不能用迭代次序当绘制层序。 */
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();},
+4 -2
View File
@@ -6,9 +6,11 @@
/** 最小结构化 DOM 端口,让纯测试无需真实 HTMLElement。 */
export type PointerCapturePort={setPointerCapture(id:number):void;hasPointerCapture(id:number):boolean;releasePointerCapture(id:number):void};
/** 普通事件匹配 pointerId 即可;lostpointercapture 还要匹配原捕获目标且确认它已失去捕获。
* 如果同目标已重新捕获该指针,旧 lost 事件不拥有新手势;脱离 DOM 导致查询抛错则允许匹配目标清理。 */
* 如果同目标已重新捕获该指针,旧 lost 事件不拥有新手势;脱离 DOM 导致查询抛错则允许匹配目标清理。
* 缺 hasPointerCapture 的匹配目标也视为已丢失;此函数不释放 DOM 捕获、不提交/取消 editor 手势。 */
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;}
/** 平台可能因指针失效/节点卸载拒绝捕获,以布尔结果交给宿主决定是否继续手势。 */
/** setPointerCapture 未抛错则返回 true,不再调用 hasPointerCapture 确认平台状态。
* 平台拒绝/节点卸载等异常统一 false,具体是否继续/取消手势仍由调用方决定。 */
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. */}}
+11 -4
View File
@@ -10,7 +10,9 @@ 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,其余节点继续处理。 */
* 只输出超出原生矩形的节点完整 paint AABB,不是只输出外溢条带;每帧遍历所有显示 node。
* 单节点协议失败记 unknown 后继续,不包含边/selection/手势状态,也不决定 renderer 最终是否保留元素。
* 不检查 registry 与 projection 的来源身份,调用方须保持同解释上下文;fallback 按矩形外壳解释。 */
export function projectedPaintOverflow(projection:ReturnType<typeof projectCanvas>,registry:Registry){
const regions:PaintRegion[]=[],unknown:string[]=[];
for(const node of projection.nodes)try{
@@ -36,7 +38,8 @@ const sameGeometry=(a:Geometry|undefined,b:Geometry|undefined)=>a===undefined?b=
/** 变换值比较避免无关根发布反复计算 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,
* 显式声明后只比较列出的字段,声明遗漏会由协议实现者负责,不能由缓存自动推导。 */
* 显式声明后只比较列出的 element.data 顶层字段(不是点分路径),空数组表示声明不依赖 data。
* 依赖列表须随同一 protocol 对象保持不变;遗漏字段/原地修改声明不会被缓存自动推导为新协议。 */
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;
@@ -51,14 +54,18 @@ function captureSignature(node:ProjectionNode,element:ElementRecord|undefined,t:
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 网格,每个节点缓存边界结果,只重新计算签名改变的条目。 */
/** 视图级增量 paint 网格:entries 缓存每个节点的签名及 regions/unknown 结果,index 只存额外范围。
* 不增量维护输入成员表,每次 sync 仍遍历全部 node 并 index.sync 全部 regions;只节省未变几何计算。
* 由 registry 对象/project/canvas 隔离,不额外核 editor/view owner;宿主应给每个视图独立实例。 */
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,再同步网格。 */
/** 切 registry/project/canvas 先清表,逐节点比较签名,变化才重新捕获依赖并调用完整单节点算法。
* unknown 结果同样可缓存至签名变化;数据捕获/比较异常可能直接抛出,整轮并非事务回滚。
* query 是同一可变 index 的方法,旧返回对象的 query 会查询后续 sync 的网格;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[]=[];
+9 -4
View File
@@ -12,14 +12,17 @@ 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 会就地替换传入投影数组中的等价条目,调用方应提供派生数组。 */
/** 创建视图专属结构共享器;reuse 会就地改传入 nodes/edges 数组,调用方应提供自有可变派生数组。
* 不深克隆/冻结显示对象;缓存的对象须按不可变约定使用,不能传入业务记录数组或原地修改旧投影。 */
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 核验模型身份,再逐层共享等价字段;返回仅包含本帧元素的新缓存表。 */
/** 每个显示 ID 须有文档记录;生命周期键只核 incarnation+owner,不另含 component/dataVersion,显示差异由值比较处理。
* 仅 data/style/position 三个嵌套字段单独等价共享,再比较整个显示对象;新表只含本帧项。
* 不查投影 ID 重复、不检查 node/edge 与记录的全部对应语义;这依赖上游合法投影。 */
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++){
@@ -35,7 +38,8 @@ export function createProjectionCache(){
return next;
}
return Object.freeze({
/** 范围改变先整体清空;保存规范化后的对象表及稳定数组,返回包装后的投影。 */
/** owner/project/canvas 改变先清旧表/数组,再分别 retain 节点和边,逐项引用相同才复用整个旧数组。
* 总返回新的投影外壳,nodes/edges 可沿用旧引用;中途抛错可能已改传入数组,不提供事务回滚。 */
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);
@@ -44,7 +48,8 @@ export function createProjectionCache(){
},
/** 读取本轮保留的节点显示对象,供 renderer 测量等显示流程使用。 */
getNode(id:Id){return nodes.get(id)?.value;},
/** 只有上下文、生命周期、renderer type、尺寸和 data 均兼容才可沿用旧测量,不能只比较 ID。 */
/** 核上下文、incarnation/owner、renderer type、width/height 与 data 等价,决定能否借旧测量。
* 不要求 position/style/整个 node 等价,也不执行 DOM 测量;调用者仍负责应用测量结果的 UI 规则。 */
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);
+21 -8
View File
@@ -16,7 +16,8 @@ import { childCanvasTransform, toWorld, transformPath, type Transform } from '..
export { toWorld, toLocal, transformPath, type Transform } from '../canvas-core/coordinates.ts';
/** 当前显示 canvas 自身作为场景原点,不叠加它在外层画布中的布局。 */
const origin:Transform={x:0,y:0,scale:1,rotation:0};
/** 无法解释或缺 renderer 时的降级外壳;只暴露类型/版本及从已知结构派生的连接把手。 */
/** 降级原因/类型/版本及从已知结构派生的把手;节点显示还可保留字符串标题和通用身份/几何。
* 不透传整份未知 data 给旧 renderer,不等于删除原 payload 或保证显示完全不读取任何通用字段。 */
export type FallbackProjection = {reason:'unknown-component'|'unsupported-version'|'renderer-unavailable';componentId:string;dataVersion:number;handles:{id:string;side:'top'|'right'|'bottom'|'left'}[]};
/** renderer 节点形状。__headlessTarget 绑定真实身份,record-v1 另带模型记录与所属 canvas 变换。 */
export type ProjectionNode = {id:Id;type:string;position:Point;width?:number;height?:number;selected?:boolean;draggable?:boolean;zIndex?:number;measured?:{width?:number;height?:number};style?:{width?:number|string;height?:number|string;pointerEvents?:'none'|'auto'};data:CanvasNodeData&{__headlessFallback?:FallbackProjection;__headlessRecord?:ElementRecord;__headlessTransform?:Transform}};
@@ -28,7 +29,10 @@ const object = (value: Json | undefined) => value && typeof value === 'object' &
/** Construct one node. Scene updates and full traversal share exactly the same projection rules. */
/** 优先采用手势预览几何,再计算场景中心/尺寸/旋转;已知组件的移动能力仍由 registry 判断。
* renderer 不可用与数据未知分别记录 fallback 原因,不修改原始 payload 来伪造兼容数据。 */
* renderer 不可用与数据未知分别记录 fallback 原因,不修改原始 payload 来伪造兼容数据。
* 已知 data 字段浅展开后由通用显示字段覆盖,record-v1 另借用原 element/transform,不深复制模型记录。
* draggable 只按 known 与 geometry.move 能力判定,缺 renderer 的 fallback 不必自动只读;策略仍由 editor 检查。
* 返回可变派生对象供后续补把手/结构共享,只有 __headlessTarget 单独冻结,不是完整输出深冻结。 */
export function projectNode(element:Extract<ElementRecord,{structure:'node'}>,canvasId:Id,registry:Registry,selected:{has(id:Id):boolean},geometry:Geometry|undefined,transform:Transform,depth:number,child:Id|undefined,renderers:RendererCatalog,cache?:GeometryProjectionCache):ProjectionNode{
const g = geometry ?? element.geometry, projectedGeometry=cache?.node(element.id,element.incarnation,g,transform),center = projectedGeometry?undefined:toWorld(transform, { x: g.x + g.width / 2, y: g.y + g.height / 2 });
const width = projectedGeometry?.width??g.width * transform.scale, height = projectedGeometry?.height??g.height * transform.scale, resolved=registry.resolve(element),known=resolved==='known';
@@ -47,7 +51,9 @@ export function projectNode(element:Extract<ElementRecord,{structure:'node'}>,ca
/** Endpoints are already resolved to their displayed node IDs by the caller. */
/** 调用方已确定显示 source/target;真实端点被入口替代时清除原 portId,避免端口指向错误外壳。
* 预览只替代本条边的路径;不把 data 内可能残留的 path 混入已验证的结构路径。 */
* compat-v1 只对 target.id 匹配的边采用预览路径,变到场景后通过 connectionPathValid 才输出。
* record-v1 接原记录/transform,不在此嵌入预览或转换结构路径;原生 renderer 须从自己的受控显示上下文读取预览。
* data.path 从兼容 payload 中剔除,避免残留字段覆盖结构 path;输出仍是派生边,不改真实端点。 */
export function projectEdge(element:Extract<ElementRecord,{structure:'connection'}>,source:Id,target:Id,registry:Registry,selected:{has(id:Id):boolean},transform:Transform,edgePreview:{target:{id:Id};path:Json}|null|undefined,renderers:RendererCatalog,cache?:GeometryProjectionCache):ProjectionEdge{
const renderer=renderers.resolve(element),fallbackEdge=registry.resolve(element)!=='known'||!renderer;
// 原生 record-v1 接记录和变换自行绘制;兼容路径转换到场景,未知边不解释自定义路径。
@@ -59,7 +65,10 @@ export function projectEdge(element:Extract<ElementRecord,{structure:'connection
/** Renderer projection retains identity/owner in the model, including nested and collapsed canvases. */
/** 从显示根深度优先投影展开分区,记录可见节点和被折叠祖先代理的子 canvas。
* 只输出 owner 在当前显示范围的连线,再把不可见子节点端点映射到其折叠入口。 */
* 只输出 owner 在当前展开范围的连线,再把隐藏后代端点映射到可见折叠入口。
* 输入须是已验证的文档树/registry;这里查显示根与可选 queries 身份,不重做完整文档校验或检测所有递归环。
* 不做 viewport 裁剪或网络/资源读取;输出全体展开场景,再由 renderer/paint 索引决定屏幕保留。
* 派生容器虽可变,消费者仍应按只读约定使用;transforms 可复用共享 origin/几何缓存值,不应原地改它们。 */
export function projectCanvas(document: Document, canvasId: Id, registry: Registry, selected: ReadonlySet<Id> | { has(id: Id): boolean }, geometry: (id: Id) => Geometry | undefined, edgePreview?: { target: { id: Id }; path: Json } | null, renderers:RendererCatalog=builtinRendererCatalog,services:{queries?:DocumentQueries;geometry?:GeometryProjectionCache;owner?:object}={}) {
if (!document.canvases.has(canvasId)) throw fault('canvas_not_loaded', [canvasId]);
// 索引必须对应同一 document/registry;几何缓存以 owner/project/canvas 隔离。
@@ -72,7 +81,8 @@ export function projectCanvas(document: Document, canvasId: Id, registry: Regist
/** 有查询索引用其稳定成员序;无索引时由模型直接归属派生一次成员表,不维护第二份事实。 */
const getMembers=(id:Id)=>queries?queries.members(id).map(id=>document.elements.get(id)!):members.get(id)??[];
const nodes: ProjectionNode[] = [], visible = new Set<Id>();
/** 递归展开只改变投影:先输出入口,再按其当前几何/单位比例变换子分区。 */
/** 按成员稳定顺序深度优先:先投影入口,再用同一份预览几何/单位构造内部 canvas 变换。
* 隐藏分支用栈遍历后代建立 hiddenBy,不生成内部节点/边变换;无 queries 时反复从 canvas 表找子项。 */
const visit = (id: Id, depth: number) => {
const transform = transforms.get(id)!;
for (const element of getMembers(id)) if (element.structure === 'node') {
@@ -91,10 +101,12 @@ export function projectCanvas(document: Document, canvasId: Id, registry: Regist
}
};
visit(canvasId, 0);
// 只从已展开 owner 找边;边在模型中仍归其原 canvas,显示代理不是把边迁入子 canvas。
// 节点/hiddenBy 全部就绪后再投影边,避免端点代理依赖节点是否已遍历。
// queries 路径汇集展开 owner 的边再按文档层序排,无 queries 则扫描 element 表;模型归属始终不变。
const edges: ProjectionEdge[] = [];
for (const element of queries?queries.orderedElements([...transforms.keys()].flatMap(id=>queries.members(id).filter(id=>document.elements.get(id)?.structure==='connection'))).map(id=>document.elements.get(id)!):document.elements.values()) if (element.structure === 'connection' && transforms.has(element.ownerCanvasId)) {
/** 可见端点保留原 ID;隐藏端点改用折叠祖先入口。无法显示或两端同代理时仅省略本帧边。 */
/** 端点节点可见则保留 ID,否则按其 owner 的 hiddenBy 找折叠代理。
* 缺显示端点或两端同代理时仅省略本帧边,不从模型删除,也不改为自由端点。 */
const endpoint = (id: Id) => visible.has(id) ? id : hiddenBy.get(document.elements.get(id)?.ownerCanvasId ?? '');
const source = endpoint(element.source.elementId), target = endpoint(element.target.elementId);
if (!source || !target || source === target) continue;
@@ -121,7 +133,8 @@ export function projectCanvas(document: Document, canvasId: Id, registry: Regist
const field=end==='source'?'sourceHandle':'targetHandle',handle=edge[field]|| (end==='source'?'right':'left');edge[field]=handle;
const seen=handleIds.get(edge[end])!;if(!seen.has(handle)){seen.add(handle);fallback.handles.push({id:handle,side:end==='source'?'right':'left'});}
}
// 本轮遍历结束后删除未访问的几何缓存项,只返回派生场景,不发布 editor 修改。
// 成功遍历结束才 end 清未访问几何项;不是 finally,异常时由下次完整轮次或视图 clear 清理缓存。
// document 是原不可变引用,nodes/edges/transforms 是本次派生容器,不发布 editor 修改。
cache?.end();
return { document,canvasId,nodes, edges, transforms };
}
+1
View File
@@ -1,6 +1,7 @@
/**
* 显式 React/React Flow 框架入口。按需导出 hooks、组件 renderer 装配和命令 Context,
* 可移植 bindings/index.ts 不引用这些框架依赖;use client 指令保留模块客户端边界。
* 重导出不自动挂 Provider、attach view 或创建 engine;ReactFlowProjection* 只是结构类型,不运行数据转换。
*/
"use client";
/** Explicit framework boundary; consumers install the declared optional peers. */
+9 -4
View File
@@ -15,14 +15,17 @@ export type ReactRenderers={readonly catalog:RendererCatalog;readonly definition
export type CanvasRuntimeExtension={readonly components?:readonly ComponentDefinition[];readonly renderers?:readonly ReactRendererDefinition[]};
// 同一组件引用只创建一次 memo 包装,弱键不永久保留不再使用的插件组件。
const memoizedRenderers=new WeakMap<object,NodeTypes[string]|EdgeTypes[string]>();
/** 已是 memo 的组件原样返回;其它合法组件复用/创建包装,便于配合投影结构共享降低重绘。 */
/** $$typeof 为 react.memo 则原样返回;其它受支持组件按原引用弱缓存 React.memo 包装。
* 不调用 render、不解析 lazy 内容,也不缓存组件状态;包装身份稳定不保证所有 props 变化都免渲染。 */
function compiledRenderer(render:NodeTypes[string]|EdgeTypes[string]):NodeTypes[string]|EdgeTypes[string]{
if((render as {$$typeof?:symbol}).$$typeof===Symbol.for('react.memo'))return render;
const key=render as object,previous=memoizedRenderers.get(key);if(previous)return previous;
const compiled=memo(render as NodeTypes[string]);memoizedRenderers.set(key,compiled);return compiled;
}
/** 先验证纯描述,再验证 React 组件形状与可选模型 registry。
* 同 rendererId 可供多个元素类型共用,但普通/预览组件必须是同一实现,不能后注册静默覆盖。 */
* 同一 structure 内 rendererId 可别名共用,但普通/预览组件引用必须一致;node/edge 类型表彼此独立。
* 标记对象只做结构识别,不证明组件安全、纯净或必能成功渲染;安装来源仍须受信。
* 每次调用新建目录/表/外壳,只有组件 memo 包装共享;冻结注册表不冻结函数闭包或 React 内部状态。 */
export function createReactRenderers(definitions:readonly ReactRendererDefinition[],registry?:Registry):ReactRenderers{
// 类型表无对象原型,保留名也已被描述目录拒绝,防止类型键继承/原型污染歧义。
const catalog=createRendererCatalog(definitions),nodes:NodeTypes=Object.create(null),edges:EdgeTypes=Object.create(null),previewNodes:NodeTypes=Object.create(null),previewEdges:EdgeTypes=Object.create(null),captured:ReactRendererDefinition[]=[];
@@ -31,11 +34,13 @@ export function createReactRenderers(definitions:readonly ReactRendererDefinitio
// 接受函数及 React memo/forward_ref/lazy 标记对象;不是组件的值在装配时拒绝。
if(typeof render!=='function'&&![Symbol.for('react.memo'),Symbol.for('react.forward_ref'),Symbol.for('react.lazy')].includes(tag as symbol))throw fault('invalid_react_renderer');
const previewTag=preview&&typeof preview==='object'?(preview as {$$typeof?:symbol}).$$typeof:undefined;if(preview!==undefined&&typeof preview!=='function'&&![Symbol.for('react.memo'),Symbol.for('react.forward_ref'),Symbol.for('react.lazy')].includes(previewTag as symbol))throw fault('invalid_react_preview');
// 如提供模型 registry,就要求精确 componentId/dataVersion/structure 匹配,不能靠显示组件掩盖 schema 不兼容。
// 可选 registry 只核 componentId/dataVersion/structure 精确存在,不执行某个元素的数据 schema/迁移。
// 未提供 registry 也可装配纯显示目录,真正投影元素时仍需模型解释与 fallback 判断。
if(registry){const model=registry.components.get(descriptor.componentId);if(!model||model.dataVersion!==descriptor.dataVersion||model.structure!==descriptor.structure)throw fault('renderer_model_mismatch',[descriptor.componentId]);}
const table=descriptor.structure==='node'?nodes:edges,previews=descriptor.structure==='node'?previewNodes:previewEdges,compiled=compiledRenderer(render),compiledPreview=preview?compiledRenderer(preview):undefined;
if(Object.hasOwn(table,descriptor.rendererId)&&(table[descriptor.rendererId]!==compiled||previews[descriptor.rendererId]!==compiledPreview))throw fault('renderer_id_conflict',[descriptor.rendererId]);
// 采用目录捕获的标准描述,冻结暴露定义和最终类型表,装配后调用方不能替换注册项。
// 定义保留原 render/preview 引用,类型表存编译后的 memo 引用;不混淆这两层身份。
// 缺 preview 则不创建对应 previewTypes 项,工厂不自动用正式组件充当预览;宿主决定降级路径。
Object.defineProperty(table,descriptor.rendererId,{value:compiled,enumerable:true,configurable:true});if(compiledPreview)Object.defineProperty(previews,descriptor.rendererId,{value:compiledPreview,enumerable:true,configurable:true});captured.push(Object.freeze({...descriptor,render,...(preview?{preview}:{})}) as ReactRendererDefinition);
}
return Object.freeze({catalog,definitions:Object.freeze(captured),nodeTypes:Object.freeze(nodes),edgeTypes:Object.freeze(edges),previewNodeTypes:Object.freeze(previewNodes),previewEdgeTypes:Object.freeze(previewEdges)});
+31 -13
View File
@@ -22,12 +22,15 @@ export type CanvasBinding={readonly engine:ProjectEngine;readonly viewId?:Id};
// 默认空上下文用于明确检测漏装 Provider,避免 hooks 悄悄读错项目。
const CanvasContext=createContext<CanvasBinding|null>(null);
/** Borrows the engine and an already attached view. Unmount never closes the project. */
/** 只 memo 化借用引用并向子组件提供上下文;不 attach/detach 视图,也不创建/销毁项目。 */
/** value 只按 engine/viewId 引用 memo 并冻结外壳,children 使用 React Context;不是项目实例拥有者。
* 不 attach/detach view、不创建/销毁 engine;若使用交互 view,应由外层宿主先装配,卸载 Provider 也不关闭项目。 */
export function CanvasProvider({engine,viewId,children}:{engine:ProjectEngine;viewId?:Id;children?:ReactNode}){const value=useMemo(()=>Object.freeze({engine,viewId}),[engine,viewId]);return createElement(CanvasContext.Provider,{value},children);}
/** 取得当前绑定,缺 Provider 时抛出稳定领域错误交给应用错误边界。 */
export function useCanvasBinding(){const binding=useContext(CanvasContext);if(!binding)throw fault('canvas_provider_missing');return binding;}
/** 将派生源接到 useSyncExternalStore。committed 保存最近已提交渲染的选中值,
* selector 或等价函数换引用后仍能复用等价结果;effect 才推进已提交基线,不在 render 中认领提交。 */
* selector 或等价函数换引用时重建派生 store,但可按 committed.current 复用等价结果,避免无意义选中值换身份。
* effect 才推进已提交基线,不在 render 中认领提交;source/selector/isEqual 须遵守同步不可变读取合同。
* getServerSnapshot 也使用同一 store.getSnapshot,不创建独立服务端模型,宿主须提供可稳定读取的绑定。 */
function useSelected<S,T>(source:ExternalSource<S>,selector:(snapshot:S)=>T,isEqual:(a:T,b:T)=>boolean=Object.is):T{
const committed=useRef<{value:T}|null>(null);
const store=useMemo(()=>createSelectorStore(source,(snapshot:S)=>{const selected=selector(snapshot);return committed.current&&isEqual(committed.current.value,selected)?committed.current.value:selected;},isEqual),[source,selector,isEqual]);
@@ -47,7 +50,9 @@ function elementSourcesFor(engine:ProjectEngine){let sources=elementSources.get(
/** 支持直接元素视图或自定义选中结果;下面两个签名只描述重载,实际订阅逻辑共用一个实现。 */
export function useElement(ref:Id|Target):ElementView;
export function useElement<T>(ref:Id|Target,selector:(view:ElementView)=>T,isEqual?:(a:T,b:T)=>boolean):T;
/** ID 查询跟随当前元素;Target 查询额外核对代次。默认完整视图按值比较,自定义选择默认按引用。 */
/** 字符串 ID 跟随当前生命周期;Target 仅在 present 且 incarnation 不同返回 stale,absent/unloaded 原样保留。
* 每个 engine 共用按 ID 源;hook 再派生代次/selector 结果,默认完整视图 equal,自定义选择默认 Object.is。
* stale 带当前元素供展示,但不自动把旧 Target 升级成新代次的写入权限。 */
export function useElement<T>(ref:Id|Target,selector?:(view:ElementView)=>T,isEqual?:(a:T,b:T)=>boolean):T|ElementView{
const {engine}=useCanvasBinding(),id=typeof ref==='string'?ref:ref.id,incarnation=typeof ref==='string'?undefined:ref.incarnation;
const source=useMemo(()=>elementSourcesFor(engine).forElement(id),[engine,id]);
@@ -55,7 +60,9 @@ export function useElement<T>(ref:Id|Target,selector?:(view:ElementView)=>T,isEq
}
/** 复用 editor 的历史标签/分组选项,不在绑定层发明另一套提交参数。 */
export type ActionOptions=Parameters<ProjectEngine['editor']['dispatch']>[1];
/** 捕获不可变 ID+代次并提供普通数据/几何/删除/移动命令;旧组件的动作由 editor 拒绝旧代次。 */
/** 按 engine/id/incarnation memo 动作外壳并捕获冻结 Target,之后改传入 target 对象不影响旧闭包。
* rename/setData/resize/remove/moveTo 都只是普通 command;resize 接 Partial<Geometry>,不只限 width/height。
* 调用时 editor 再核代次/策略/事务,不乐观修改 React 局部文档,也不在动作闭包里排网络请求。 */
export function useActions(target:Target){const {engine}=useCanvasBinding(),{id,incarnation}=target;return useMemo(()=>{const ref=Object.freeze({id,incarnation});return Object.freeze({
// 各操作只转成正常 command;校验、history 和同步依赖由 editor/runtime 在统一边界处理。
rename:(title:string,options?:ActionOptions)=>engine.editor.dispatch({type:'element.data',target:ref,patch:{title}},options),
@@ -69,16 +76,20 @@ const sessionSources=new WeakMap<ProjectEngine,ReturnType<typeof createSessionSo
/** 惰性绑定 session 源;已关闭 engine 不再订阅其会话。 */
function sessionSourcesFor(engine:ProjectEngine){let sources=sessionSources.get(engine);if(!sources){sources=createSessionSources({getSnapshot:engine.sessions.getSnapshot,subscribe:notify=>engine.getDurability().closed?()=>{}:engine.sessions.subscribe(notify)});sessionSources.set(engine,sources);}return sources;}
/** 把文档身份与瞬态状态成对读取,按两项引用保留稳定元组;第二条订阅失败时撤销第一条。
* 它不是跨两源的独立事务,合法性由使用方的目标代次校验保证。 */
* 顺序读两源,不是跨源事务或原子快照锁;目标合法性仍由 hooks/命令校验。
* 正常退订依次 a()/b(),没有 finally 隔离,宿主取消端口须不抛错;右侧注册失败时尽量撤回左侧。 */
function pairedSource<A,B>(left:ExternalSource<A>,right:ExternalSource<B>):ExternalSource<readonly[A,B]>{
let previous:readonly[A,B]|undefined;
return{getSnapshot(){const a=left.getSnapshot(),b=right.getSnapshot();if(!previous||!Object.is(previous[0],a)||!Object.is(previous[1],b))previous=Object.freeze([a,b] as const);return previous;},subscribe(notify){const a=left.subscribe(notify);try{const b=right.subscribe(notify);return()=>{a();b();};}catch(error){a();throw error;}}};
}
/** 需要交互所有权的 hook 必须有 viewId,不能从任意其它视图借用会话。 */
/** 要求 Provider 有非空 viewId;这里只补绑定形状,不在此 attach 或验证该视图此刻仍注册。
* session 操作入口继续核视图/手势生命周期,不能凭这个返回对象绕过所有权检查。 */
function useView(){const binding=useCanvasBinding();if(!binding.viewId)throw fault('view_session_required');return {...binding,viewId:binding.viewId};}
/** 将绑定控制异常转为 Availability,保留领域码/目标,未知异常归 binding_failed。 */
/** 将 Error 风格异常的 code/targets 转为 Availability,缺 code 用 binding_failed。
* 不归一化任意 thrown 值;null/undefined 不符合这里直接读属性的错误合同。 */
function available(error:unknown):Availability{return{enabled:false,code:(error as {code?:string}).code??'binding_failed',targets:(error as {targets?:readonly Id[]}).targets??[]};}
/** 同时订阅目标身份与该视图模式;旧代次返回 null,设置模式前再次读取当前元素校验。 */
/** 成对订阅元素查询和 view/element 模式;缺元素或旧代次读成 null,不把旧元素的编辑模式套给新元素。
* setMode 调用时再核当前 incarnation,然后由 sessions.mode 检查视图;返回可用性,不直接编辑内容/history。 */
export function useMode(target:Target){
const {engine,viewId}=useView(),{id,incarnation}=target;
const source=useMemo(()=>pairedSource(elementSourcesFor(engine).forElement(id),sessionSourcesFor(engine).mode(viewId,id)),[engine,viewId,id]);
@@ -87,7 +98,9 @@ export function useMode(target:Target){
return useMemo(()=>Object.freeze({mode,setMode}),[mode,setMode]);
}
/** 组合文档几何和 session 预览。begin 取得手势 token,update/commit/cancel 必须仍拥有同 token/view。
* effect 清理只取消本绑定取得的手势,不能因旧组件卸载取消后来另一视图的新手势。 */
* controls 内 lease 仅是本 hook 捕获的数字 token,不是另一份手势事实;真实 active/preview 在 sessions。
* effect 清理先核 token/view/active,仅取消自己取得的手势;旧组件卸载不能取消后来另一视图的新手势。
* begin/update/commit 沿用 session 的结果或异常;拥有手势后进入 commit 的 try/finally 才保证清 lease,未拥有则直接拒绝。 */
export function useGestureBindings(target:Target){
const {engine,viewId}=useView(),{id,incarnation}=target;
const source=useMemo(()=>pairedSource(elementSourcesFor(engine).forElement(id),sessionSourcesFor(engine).gesture(viewId,{id,incarnation})),[engine,viewId,id,incarnation]);
@@ -106,7 +119,8 @@ export function useGestureBindings(target:Target){
const runtimeSources=new WeakMap<ProjectEngine,ReturnType<typeof createRuntimeSources>>();
/** 惰性建立 runtime 源,在 editor 成功根发布时观察表引用变化。 */
function runtimeSourcesFor(engine:ProjectEngine){let sources=runtimeSources.get(engine);if(!sources){sources=createRuntimeSources({getSnapshot:engine.getSnapshot,subscribe:notify=>engine.getDurability().closed?()=>{}:engine.editor.subscribe(notify)});runtimeSources.set(engine,sources);}return sources;}
/** 单操作诊断读取,返回逻辑操作及关联冻结请求,不提供直接改任务状态的方法。 */
/** 每 engine/id memo 派生源,useSyncExternalStore 读取逻辑操作和关联请求;缺 ID 返回 undefined。
* 这是诊断订阅,不轮询服务、不授予直接修改 phase/request 的接口。 */
export function useOperation(id:Id):OperationView{const {engine}=useCanvasBinding(),source=useMemo(()=>runtimeSourcesFor(engine).operation(id),[engine,id]);return useSyncExternalStore(source.subscribe,source.getSnapshot,source.getSnapshot);}
/** 本地可靠保存状态,与网络 ACK/任务结果分开观察。 */
type Durability=ReturnType<ProjectEngine['getDurability']>;
@@ -117,7 +131,8 @@ const inactiveOwner=(value:Durability)=>value.closing||value.closed;
/** 使用 engine 独立的 durability 订阅,磁盘完成/错误不必伪造一次文档编辑来通知 UI。 */
function useDurability<T>(selector:(value:Durability)=>T,isEqual:(a:T,b:T)=>boolean=Object.is){const {engine}=useCanvasBinding();const source=useMemo(()=>({getSnapshot:engine.getDurability,subscribe:engine.subscribeDurability}),[engine]);return useSelected(source,selector,isEqual);}
/** 合成任务计数与项目保存状态,展示优先级为 closed→blocked→closing→saving→syncing→loading→synced。
* canvas 计数局部化,但 dirty/error/closing 是项目保存层级,不能当作仅该 canvas 的独立磁盘状态。 */
* canvas 计数局部化,但 dirty/error/closing 是项目保存层级,不能当作仅该 canvas 的独立磁盘状态。
* 计数与 durability 来自两条订阅,不建立新的事务根;status 是显示优先级归纳,synced 不证明远端生成已结束。 */
export function useSyncStatus(canvasId?:Id){
const {engine}=useCanvasBinding(),source=useMemo(()=>runtimeSourcesFor(engine).counts(canvasId),[engine,canvasId]);
const durable=useDurability(syncDurability,equal),counts=useSyncExternalStore(source.subscribe,source.getSnapshot,source.getSnapshot);
@@ -125,7 +140,10 @@ export function useSyncStatus(canvasId?:Id){
return useMemo(()=>Object.freeze({...counts,dirty,error:durable.error,status:durable.closed?'closed' as const:durable.error||counts.blocked?'blocked' as const:durable.closing?'closing' as const:dirty?'saving' as const:counts.pending?'syncing' as const:counts.loading?'loading' as const:'synced' as const}),[counts,dirty,durable.error,durable.closing,durable.closed]);
}
/** 订阅资源元数据并在 effect 借用显示句柄。engine/id/来源 key 变化重建尝试,retry 显式重取。
* 异步返回先核对 abort;渲染也核对 state 所属 engine/id/key,不能短暂显示上个项目或旧来源的 URL。 */
* 异步返回核 abort,渲染再核 state.engine/id/key;metadata 与句柄分别来自根和 effect,不另存原资源字节。
* key 只含上传 operationId/purge、binding.authority/digest,普通元信息变化不全都重开显示请求。
* attempt 只触发 effect 重试,不在返回 state 的身份键中;同来源 retry 的 loading 状态由 effect 启动时设置。
* ready 表示 URL 租约已取得,不等于图片 DOM 已解码/绘制或上传/同步完成。 */
export function useAsset(id:Id){
const {engine}=useCanvasBinding(),inactive=useDurability(inactiveOwner);
const source=useMemo(()=>runtimeSourcesFor(engine).asset(id),[engine,id]),metadata=useSyncExternalStore(source.subscribe,source.getSnapshot,source.getSnapshot);
@@ -134,7 +152,7 @@ export function useAsset(id:Id){
const [attempt,setAttempt]=useState(0),[state,setState]=useState<{engine:ProjectEngine;id:Id;key:string;status:'loading'|'ready'|'error';url?:string;error?:unknown}>();
// 清理中止本次读取并归还 handle;迟到成功也立即释放,不把旧结果写入当前显示。
useEffect(()=>{if(inactive){setState({engine,id,key,status:'error',error:fault('engine_closing')});return;}const controller=new AbortController();let handle:DisplayHandle|undefined;setState({engine,id,key,status:'loading'});void engine.displayResource(id,{signal:controller.signal}).then(value=>{if(controller.signal.aborted){value.release();return;}handle=value;setState({engine,id,key,status:'ready',url:value.url});},error=>{if(!controller.signal.aborted)setState({engine,id,key,status:'error',error});});return()=>{controller.abort();handle?.release();};},[engine,id,key,attempt,inactive]);
/** 重试仅增加本 hook 的尝试序号,由 effect 清理旧租约再请求新显示句柄。 */
/** retry 仅增本 hook 的尝试序号,effect 清旧租约再取新显示句柄;不调用 engine.retry 或重新上传。 */
const retry=useCallback(()=>setAttempt(value=>value+1),[]);
return useMemo(()=>Object.freeze({...metadata,...(inactive?{status:'error' as const,url:undefined,error:fault('engine_closing')}:state?.engine===engine&&state.id===id&&state.key===key?{status:state.status,url:state.url,error:state.error}:{status:'loading' as const,url:undefined,error:undefined}),retry}),[metadata,state,engine,id,key,retry,inactive]);
}
+2 -1
View File
@@ -13,5 +13,6 @@ export type CanvasRendererCommands={dispatch(command:Command,options?:RendererCo
/** Provider 由上层 renderer 装配;默认 null 表示组件尚未处于合法的编辑上下文。 */
export const CanvasRendererCommandsContext=createContext<CanvasRendererCommands|null>(null);
/** Trusted installed renderers submit normal validated commands, never write a second model. */
/** 已安装的受信 renderer 通过此 hook 获取命令门面,所有业务修改仍经 editor 发布边界。 */
/** 读取最近 Provider 的命令对象,缺失就同步抛错;hook 不缓存命令、不建立订阅或另建 editor。
* Provider 须注入 editor 的受检门面;Context 本身不验证任意自定义函数是否真的经过发布边界。 */
export function useCanvasRendererCommands(){const commands=useContext(CanvasRendererCommandsContext);if(!commands)throw Error('Canvas renderer commands are unavailable');return commands;}
+4 -2
View File
@@ -11,7 +11,8 @@ export type RendererCatalog={readonly definitions:readonly RendererDescriptor[];
/** JSON 元组键避免拼接分隔符歧义,节点与连线即使 componentId 相同也独立匹配。 */
const key=(value:Pick<RendererDescriptor,'componentId'|'dataVersion'|'structure'>)=>JSON.stringify([value.componentId,value.dataVersion,value.structure]);
/** 校验并复制描述项,拒绝重复定义、危险/保留 renderer 名和未知投影协议。
* 工厂不加载代码、不自动迁移 dataVersion,也不把高版本当成兼容旧版本。 */
* 工厂按声明顺序重建标准标量字段/默认 projection,未知扩展字段不复制,Map 键是精确三元组。
* 不加载 React/插件代码、不执行 schema 校验、不自动迁移版本;resolve 不授予编辑或网络权限。 */
export function createRendererCatalog(definitions:readonly RendererDescriptor[]):RendererCatalog{
const entries=new Map<string,RendererDescriptor>();
for(const value of definitions){
@@ -31,5 +32,6 @@ export const builtinRendererCatalog=createRendererCatalog(builtinRendererDefinit
/** Installed renderers without a reference declaration wait for full context. */
/** 渐进加载仅开放 registry 中声明 copy 引用规则的组件 renderer;缺声明先走降级等待完整上下文。
* 保留 definitions 供装配检查,但 resolve 才决定当前是否允许显示实现。 */
* 保留原 catalog.definitions 供装配检查,resolve 每次先查 registry 中 component 是否有 copy 声明。
* 这仅是局部加载门禁,不验证声明覆盖所有引用、schema 或源记录版本,也不证明依赖已加载;精确 renderer 匹配仍由 catalog 执行。 */
export function createLoadingRendererCatalog(registry:Registry,catalog:RendererCatalog):RendererCatalog{return Object.freeze({definitions:catalog.definitions,resolve:(element:Parameters<RendererCatalog['resolve']>[0])=>registry.components.get(element.componentId)?.copy?catalog.resolve(element):undefined});}
+12 -5
View File
@@ -14,7 +14,9 @@ export type ResourceViewSource = { readonly resourceId: Id; readonly url: string
* unchanged nodes, publish each ready source independently, and fence late work.
*/
/** resolve 由 engine/display service 注入,onChange 收到新的已就绪集合和当前失败数。
* sync 输入是完整目标集合,retry 只重建失败项,dispose 幂等释放所有在途/已完成句柄。 */
* sync 输入是完整目标集合,retry 只重建失败项,dispose 请求中止在途并归还已有句柄。
* 工厂不立即 resolve;每个元素一次独立尝试,同资源的多个元素不在此合并(底层缓存可共享读取)。
* resolve 须返回合法 DisplayHandle;本模块不校验 URL/下载权限或图片内容,回调错误仅报告。 */
export function createResourceView(options: {
resolve(resource: Id, signal: AbortSignal): Promise<DisplayHandle>;
onChange(sources: Map<Id, ResourceViewSource>, failures: number): void;
@@ -24,13 +26,16 @@ export function createResourceView(options: {
const records = new Map<Id, Record>(); let targets = new Map<Id, Id>(), disposed = false;
/** 错误展示抛错不影响租约释放或其它图片的加载。 */
const report = (error: unknown) => { try { options.onError?.(error); } catch { /* Observer errors do not own leases. */ } };
/** 只公布已拿到 handle 的元素,失败数另报;先复制 Map,不把内部记录交给界面修改。 */
/** 每次重建 ready 元素→{resourceId,url} 的新 Map,另计 failed;pending 不计失败也不带空 URL。
* Map/条目未运行时冻结,但不暴露内部 record/handle;onChange 抛错被 report 隔离,不撤销已采纳句柄。 */
const publish = () => { try { options.onChange(new Map([...records].flatMap(([id, record]) => record.handle ? [[id, {resourceId:record.resource,url:record.handle.url}] as [Id, ResourceViewSource]] : [])), [...records.values()].filter(record => record.failed).length); } catch (error) { report(error); } };
/** 释放器自身抛错只报告,不让一张图片阻止其它图片清理。 */
const releaseHandle = (handle: DisplayHandle) => { try { handle.release(); } catch (error) { report(error); } };
/** 先中止在途解析,再归还已完成句柄;未完成解析的迟到成功也由下方结果边界归还。 */
const release = (record: Record) => { record.controller.abort(); if(record.handle)releaseHandle(record.handle); };
/** 完整验证元素/资源 ID 与重复项后再更新;删除或换源立即释放旧记录,未变化项继续复用。 */
/** 先验证并捕获完整目标映射(元素 ID 唯一,资源 ID 可重复),再删除/换源/补新尝试。
* 未变化项包括 failed 都保留,因此同集合 sync 不隐式重试;显式 retry 才重建失败项。
* 变化时先发布当前 ready 集合,各 resolve 独立完成再各自发布,不等全组 Promise.all。 */
function sync(next: readonly (readonly [Id, Id])[]) {
if (disposed) throw fault('resource_view_disposed');
const desired = new Map<Id, Id>();
@@ -60,9 +65,11 @@ export function createResourceView(options: {
}
return Object.freeze({
sync,
/** 移除失败记录后按 targets 重新 sync;成功项不重新取 URL。 */
/** 先撤掉失败尝试并释放,再用最近 targets 重新 sync;ready/pending 项继续复用。
* 没有失败项也不额外发布,不重新授权所有成功资源或重建业务元素。 */
retry() { if (disposed) throw fault('resource_view_disposed'); for (const [id, record] of records) if (record.failed) { records.delete(id); release(record); } sync([...targets]); },
/** 中止并归还全部显示租约,发布空集合,后续 sync/retry 明确拒绝。 */
/** 同步标 disposed、请求 abort 并 release 已有句柄,清目标/记录后发布空集合。
* 不等待 resolve Promise 退出;迟到成功由记录身份/ disposed 检查归还,后续 sync/retry 明确拒绝。 */
dispose() { if (disposed) return; disposed = true; for (const record of records.values()) release(record); records.clear(); targets.clear(); publish(); },
});
}
+13 -6
View File
@@ -20,7 +20,9 @@ export type SyncCounts={readonly pending:number;readonly blocked:number;readonly
type Dependencies=readonly(object|boolean|undefined)[];
/** Read-only table projections: one upstream listener, no per-hook Root/table snapshots. */
/** 装配 operation/asset/counts 三类通道,首次订阅连上游,最后一个通道离开后断开。 */
/** 装配 operation/asset/counts 三组:组内按 key 共享桶,所有活动组共用一个上游订阅。
* 只按所声明表对象身份/布尔值检测依赖,要求上游不可变;原地改 Map 成员不会自动生成新依赖戳。
* factories/getSnapshot 不启动网络或修改业务状态,选中记录仍借用 Root 中的不可变事实。 */
export function createRuntimeSources(source:ExternalSource<EngineSnapshot>){
// 每个 group 负责自己的桶更新,上游先推进所有组,再运行去重后的外部通知。
type Group={update(root:EngineSnapshot,notifications:Set<()=>void>):void};
@@ -33,7 +35,8 @@ export function createRuntimeSources(source:ExternalSource<EngineSnapshot>){
for(const group of active)group.update(root,notifications);
let failed=false,error:unknown;for(const notify of notifications)try{notify();}catch(caught){if(!failed){failed=true;error=caught;}}if(failed)throw error;
}
/** 为共享依赖的一类按键查询创建桶。依赖不变跳过计算,失败桶即使签名未变仍可重新尝试。 */
/** 一组依赖对应多个 key 桶;observedSignature 决定路由,failed 键可在戳未变时再试。
* cachedSignature 只说明读取缓存对应哪个依赖戳,不能替代 observed 的通知基线。 */
function group<K,V>(dependencies:(root:EngineSnapshot)=>Dependencies,read:(root:EngineSnapshot,key:K)=>V){
// Direct reads may precede notification, so read caches cannot replace observed baselines.
// cached 服务即时读取,observed 是上次通知基线;两者不能合并,否则渲染提前读取会漏通知。
@@ -44,7 +47,8 @@ export function createRuntimeSources(source:ExternalSource<EngineSnapshot>){
if(bucket.cachedSignature!==stamp){const value=read(root,key);if(!equal(bucket.cached,value))bucket.cached=value;bucket.cachedSignature=stamp;}
return bucket.cached;
}
// 只更新有订阅的键;失败时仍通知使用方,让其读取/错误边界重新解释,不造一个成功值。
// 戳变化扫描全部已订阅 key,否则只扫描 failed;readCached 失败不会推进该桶缓存戳。
// 失败/失败后恢复都通知,cached/observed 不造一个假成功值;其它正常等价桶不通知。
const owner:Group={update(root,notifications){
const stamp=signature(dependencies(root)),all=stamp!==observedSignature;if(!all&&!failed.size)return;observedSignature=stamp;
for(const key of all?buckets.keys():[...failed]){
@@ -67,7 +71,8 @@ export function createRuntimeSources(source:ExternalSource<EngineSnapshot>){
const listener=()=>notify();bucket.listeners.add(listener);
/** 撤销本订阅;空桶和空组立即删除,失败键也同时清理。 */
const remove=()=>{bucket!.listeners.delete(listener);if(!bucket!.listeners.size){buckets.delete(key);failed.delete(key);}if(!buckets.size){active.delete(owner);observedSignature=undefined;}};
// 首次注册上游失败撤回桶;正常退订只有最后一个活动组离开才停止共享监听。
// 首次注册上游失败撤回本次 listener/空桶;各订阅包装回调身份,传同一个 notify 也可独立退订。
// 最后活动组离开才 stop 上游;stop 抛错不兜底重置,宿主取消端口须可靠完成。
if(!stop)try{stop=source.subscribe(changed);}catch(error){remove();throw error;}
let live=true;return()=>{if(!live)return;live=false;remove();if(!active.size){stop?.();stop=undefined;}};
}});
@@ -80,7 +85,8 @@ export function createRuntimeSources(source:ExternalSource<EngineSnapshot>){
let countStamp:string|undefined,projectCounts:SyncCounts,emptyCounts:SyncCounts,canvasCounts=new Map<Id,SyncCounts>();
/** 同步计数只取操作表、请求表和是否 loading,不因文档几何或历史变化全表重数。 */
const countDependencies=(root:EngineSnapshot):Dependencies=>[root.runtime.ledger.operations,root.runtime.ledger.requests,!!root.runtime.loading];
// 同一依赖戳一次扫描得出项目及各画布计数,多个 canvas hook 共享结果。
// 同一依赖戳只扫描一次得到项目/各 canvas 计数,空 canvas 共享零计数;loading 是项目级布尔值。
// canvas 归属沿用 intent.canvasIds,不在这里从当前 owner 推导;上传等空参与者任务只进入项目计数。
const counts=group<Id|undefined,SyncCounts>(countDependencies,(root,canvasId)=>{
const stamp=signature(countDependencies(root));
if(stamp!==countStamp){
@@ -92,7 +98,8 @@ export function createRuntimeSources(source:ExternalSource<EngineSnapshot>){
const ids=operation.intent.canvasIds.length>1?new Set(operation.intent.canvasIds):operation.intent.canvasIds,blocked=operation.phase==='blocked'&&operation.blockers.some(reason=>reason!=='waiting_digest');project.pending++;if(blocked)project.blocked++;
for(const id of ids){const row=scope(id);row.pending++;if(blocked)row.blocked++;}
}
// 请求只统计仍关联未决操作的项。无 retryAt 的请求错误计 blocked,已发出但结果未知计 unknown。
// 请求仅在仍关联未决操作时计数,canvas 集合取这些操作参与者的并集,不直接用 request.canvasIds。
// 请求 blocked 可与操作 blocked 叠加,unknown 也可同时非零;它们不是互斥任务/节点数量。
for(const request of root.runtime.ledger.requests.values())if(request.phase!=='resolved'){
let matched=false;const ids=new Set<Id>();for(const id of request.operationIds){const operation=root.runtime.ledger.operations.get(id);if(operation&&operation.phase!=='resolved'){matched=true;for(const canvas of operation.intent.canvasIds)ids.add(canvas);}}
if(!matched)continue;const blocked=!!request.error&&request.retryAt===undefined,unknown=request.outcome==='unresolved'&&['dispatch-marked','waiting'].includes(request.phase);
+14 -6
View File
@@ -11,13 +11,16 @@ import type {RendererCatalog} from './renderers.ts';
/** 与完整 projectCanvas 保持同一结果结构,避免增量路径自行定义另一套显示规则。 */
type Projection=ReturnType<typeof projectCanvas>;
/** 连续发布的可信写集合;from/to 必须恰好连接旧投影与新文档,topology 表示禁止局部修补。 */
/** from/to 按 Document 对象身份连接连续发布,ids 是宿主提供的完整写集合;这里不重算其覆盖证明。
* topology=true 禁用局部修补,Set 可由 scene coordinator 累加,不是持久 Change 日志或业务事件队列。 */
export type SceneChangeHint={from:Document;to:Document;ids:Set<Id>;topology:boolean};
/** 几何手势的瞬态预览,不将 pointermove 变成文档修改。 */
type Preview=NonNullable<SessionSnapshot['gesture']>['preview']|undefined;
/** 真正影响场景内容的输入;viewport/tool/mode 等状态由别的显示通道处理。 */
export type SceneInputs={owner:object;document:Document;registry:Registry;catalog:RendererCatalog;canvasId:Id;selection:ViewState['selection'];preview:Preview;edgeId:Id|undefined;path:NonNullable<SessionSnapshot['connectionGesture']>['path']|undefined};
/** 为上一帧建立 ID→数组位置、分区入口及深度索引;发现降级节点/边后禁用快速修补。 */
/** 建上一帧 ID→数组下标、入口 elementId→子 canvasId、显示 canvas 深度索引。
* 深度沿 transforms 迭代顺序累计,依赖完整投影按父先子后产出;不在这里重新排序/校验整棵树。
* unsupported 标记当前已有 fallback node/unsupported edge,供 patchScene 直接回退。 */
export function indexScene(projection:Projection){
const nodes=new Map(projection.nodes.map((node,index)=>[node.id,index])),edges=new Map(projection.edges.map((edge,index)=>[edge.id,index])),entries=new Map<Id,Id>(),depths=new Map<Id,number>([[projection.canvasId,0]]);
for(const canvas of projection.document.canvases.values())if(canvas.parent)entries.set(canvas.parent.entryElementId,canvas.id);
@@ -26,14 +29,17 @@ export function indexScene(projection:Projection){
}
/** Reuse existing projections for checked field edits and ordinary node insertions. */
/** 纯候选投影修补:合并文档/选择/预览脏项,先核对全体脏项再构造新数组。
* 允许普通节点新增与受检字段编辑;删除、代次/类型/owner/连线端点变更交给完整路径。 */
* 允许普通节点新增与受检字段编辑;删除、代次/类型/owner/连线端点变更交给完整路径。
* 调用者须先核 owner/registry/catalog/canvas 兼容,layout 对应 base,queries 对应 next.document;本函数不重复全部护栏。
* undefined 表示本次不适合快路径,不是业务拒绝;投影器/外部服务抛错则直接传播,没有错误吞并或业务发布。 */
export function patchScene(base:Projection,layout:ReturnType<typeof indexScene>,next:SceneInputs,previous:SceneInputs,connection:SessionSnapshot['connectionGesture'],hint?:SceneChangeHint,queries?:DocumentQueries):Projection|undefined{
const {document,selection,preview,registry,catalog,canvasId}=next;
const {selection:oldSelection,preview:oldPreview,edgeId:oldEdgeId,path:oldPath}=previous;
if(layout.unsupported||document.projectId!==base.document.projectId)return;
const dirty=new Set<Id>(),created=new Set<Id>();
if(document!==base.document){
// 可信连续 changes 可直接路由 ID;拓扑提示则立刻放弃。无提示时需要逐项核对表顺序及内容。
// 匹配 from/to 的 hint 按完整写集合合同直接取 ids,不额外全表证明“没有遗漏其它修改”。
// 缺可信 hint 才比较 canvas 表及 element 表大小/顺序/内容;这条扫描路径本身不支持插入或删除。
if(hint?.from===base.document&&hint.to===document){if(hint.topology)return;for(const id of hint.ids)dirty.add(id);}
else{
// Canonical replacement and missed publications carry no trustworthy write set.
@@ -59,7 +65,8 @@ export function patchScene(base:Projection,layout:ReturnType<typeof indexScene>,
// 入口几何/单位展示或其预览变化会影响整棵子树变换,不能只修补入口本身。
if(before.structure==='node'&&after.structure==='node'&&layout.entries.has(id)&&(!equal(before.geometry,after.geometry)||!equal(before.data.presentation,after.data.presentation)||!equal(oldPreview?.get(id),preview?.get(id))))return;
}
// 懒复制:未受影响的数组与节点/边对象沿用原引用,新对象仍调用完整路径共用的投影函数。
// 先通过脏项形状检查再懒复制受影响数组,未变对象仍共享;所有新节点/边调用完整路径共用投影函数。
// base.transforms 只在拓扑兼容时复用;未显示 canvas 的普通变更可以只更新返回 document 而不增加显示节点。
let nodes=base.nodes,edges=base.edges;const inserted=new Map<Id,Projection['nodes'][number]>();
for(const id of dirty){const element=document.elements.get(id)!;
if(element.structure==='node'){
@@ -81,7 +88,8 @@ export function patchScene(base:Projection,layout:ReturnType<typeof indexScene>,
// without reconstructing unaffected nodes, transforms or edge projections.
const members=new Map<Id,Id[]>();if(!queries)for(const element of document.elements.values()){const list=members.get(element.ownerCanvasId)??[];list.push(element.id);members.set(element.ownerCanvasId,list);}
const ordered:Projection['nodes']=[];let complete=true;
/** 只重排节点引用,不重算未变化节点;任一所需投影缺失就放弃这份候选。 */
/** 按 owner 成员顺序深度优先重排节点引用,只进已在 transforms 显示的子 canvas。
* 缺任一所需投影就令 complete=false 回退;依赖已验证树,无独立循环/递归深度预算。 */
const visit=(owner:Id)=>{for(const id of queries?queries.members(owner):members.get(owner)??[]){const element=document.elements.get(id)!;if(element.structure!=='node')continue;const projected=inserted.get(id)??nodes[layout.nodes.get(id)!];if(!projected){complete=false;return;}ordered.push(projected);const child=layout.entries.get(id);if(child&&base.transforms.has(child))visit(child);}};
visit(canvasId);if(!complete)return;nodes=ordered;
}
+11 -4
View File
@@ -12,13 +12,17 @@ import {indexScene,patchScene,type SceneChangeHint,type SceneInputs} from './sce
import type {RendererCatalog} from './renderers.ts';
/** One view's derived scene. Viewport/tool/mode and task status do not change scene inputs. */
/** 每个视图独立创建;advance 接 editor 发布,project 接当前显示输入,clear 释放全部派生缓存。 */
/** 每个视图一份 previous/value/layout/pending 与几何缓存;advance 只积写集合,project 才按需算场景。
* 缓存借用不可变文档/选择/预览/registry/catalog 身份,不能在原对象中改字段后期待自动发现失效。
* 实例不订阅 editor、不请求帧,宿主负责接线 advance/project 与视图 clear 生命周期。 */
export function createSceneProjection(){
let geometry=createGeometryProjectionCache();
let previous:SceneInputs|undefined,value:ReturnType<typeof projectCanvas>|undefined,layout:ReturnType<typeof indexScene>|undefined,pending:SceneChangeHint|undefined;
return Object.freeze({
/** 累积从上次投影文档到当前文档的连续写集合;丢发布、无 changes 的替换或大范围改动退回全量。 */
/** 累积上次投影文档→当前文档的连续发布链;同一文档对象直接忽略,未有场景时无需积累。
* 断链只在 previous.document===before 时能重起;空 changes 丢提示,canvas 改动/脏 ID>16384 只标 topology。
* 这里只影响快路径选择,不重放 Change、不写 doc,也不保证输入 changes 本身语义完整。 */
advance(before:Document,after:Document,changes:readonly Change[]){
if(before===after)return;
if(!previous||previous.document===after){pending=undefined;return;}
@@ -31,7 +35,9 @@ export function createSceneProjection(){
if(pending.topology)pending.ids.clear();
},
/** 固定 document/registry/catalog/显示 canvas 和捕获的 session 输入;相同引用直接复用场景。
* 可兼容才尝试 patch,失败正常回退 projectCanvas;查询必须与同一份文档/registry 绑定。 */
* 可兼容才尝试 patch,undefined 正常回退 projectCanvas;异常不自动吞成另一份成功结果。
* queries 先查 document/registry 身份;其对象身份不在场景键中,单独更换 queries 不触发重算。
* 空 preview 规范为 undefined,viewport/tool/mode 不在键中;传入的 sessions 是捕获帧,不读取更新的 live 会话。 */
project(owner:object,document:Document,view:ViewState,sessions:SessionSnapshot,registry:Registry,catalog:RendererCatalog,queries?:DocumentQueries){
if(queries&&queries.document!==document)throw fault('stale_query_snapshot');
if(queries&&queries.registry!==registry)throw fault('query_registry_changed');
@@ -43,7 +49,8 @@ export function createSceneProjection(){
const compatible=previous&&value&&layout&&previous.owner===owner&&previous.registry===registry&&previous.catalog===catalog&&previous.canvasId===view.canvasId;
const patched=compatible?patchScene(value!,layout!,next,previous!,connection,pending,queries):undefined;
const result=patched??projectCanvas(document,view.canvasId,registry,view.selection,id=>next.preview?.get(id),connection,catalog,{queries,geometry,owner});
// 全量重建或节点数量改变才重建布局索引;成功采用结果后消费并清掉 changes 提示。
// 全量结果或节点数变化才重建 layout,其余兼容 patch 沿用数组下标/拓扑索引。
// 成功后一次推进 previous/value 并清 pending;异常不写这三项,但本轮几何缓存可能已有计算,不承诺整缓存回滚。
if(!patched||result.nodes.length!==value!.nodes.length)layout=indexScene(result);
previous=next;value=result;pending=undefined;return result;
},
+7 -3
View File
@@ -5,15 +5,19 @@
/** One immutable source, one cached selection; no second document or operation state. */
/** 最小快照/订阅协议,可由 editor、session 或派生源实现,订阅返回释放函数。 */
export type ExternalSource<S>={getSnapshot():S;subscribe(notify:()=>void):()=>void};
/** 用 selector 与等价判断派生稳定快照;等价的新值沿用旧引用,避免 UI 的无效刷新。 */
/** 一个 store 共享一份最近源快照/selection 缓存,每个订阅另存 observed 引用。
* selector/isEqual 须同步且适合不可变源;不 clone/freeze 选中值,不修复上游原地修改后身份不变的问题。 */
export function createSelectorStore<S,T>(source:ExternalSource<S>,selector:(snapshot:S)=>T,isEqual:(a:T,b:T)=>boolean=Object.is){
let initialized=false,snapshot:S,selection:T;
/** 根未变则跳过计算;根变但结果等价也保持 selection 引用,只更新已观察的根身份。 */
/** 根身份相同则跳过 selector;新根等价则沿用旧 selection,仅推进已观察源身份。
* selector/isEqual 抛错发生在缓存赋值前,原缓存保留,getSnapshot 仍向调用者抛错。 */
function getSnapshot():T{
const current=source.getSnapshot();if(initialized&&Object.is(snapshot,current))return selection;
const next=selector(current),same=initialized&&isEqual(selection,next);
snapshot=current;if(!same)selection=next;initialized=true;return selection;
}
// 每个订阅单独跟踪 observed;计算失败仍通知,让使用方在自己的读取/错误边界重新处理。
// subscribe 先同步读取初值(失败则尚未接上游);每个 observer 独立比较 selection 身份。
// 上游通知时计算失败仍 notify,供使用方重读/错误边界处理;不把 notify 的异常吞成成功。
// 直接返回上游 stop,无额外 dispose:退订幂等性、异常与通知时序沿用源合同。
return Object.freeze({getSnapshot,subscribe(notify:()=>void){let observed=getSnapshot();return source.subscribe(()=>{let next:T;try{next=getSnapshot();}catch{notify();return;}if(!Object.is(observed,next)){observed=next;notify();}});}});
}
+4 -1
View File
@@ -6,7 +6,9 @@ import type {SessionSnapshot} from '../canvas-core/gestures.ts';
import type {ExternalSource} from './selectors.ts';
import {createFrameQueue,type FrameClock} from './frame-queue.ts';
/** One subscription owns one disposable frame queue. Reads always see current sessions. */
/** 对比前后会话引用生成 view/element 脏 key;退订时同步撤销帧队列。 */
/** 每个订阅保留上一份 session 引用,按不可变 view/gesture 身份发现失效,任意 dirty 最终只触发 notify()。
* key 集合是帧内去重依据,不向消费者发布逐元素差分;getSnapshot 直接借用源方法,须不依赖 this 绑定。
* 此包装不使用 immediate 标志,也不对上游 subscribe/stop 抛错作 finally 清理,宿主须保证正常订阅生命周期。 */
export function createSessionFrameSource(source:ExternalSource<SessionSnapshot>,clock:FrameClock):ExternalSource<SessionSnapshot>{
return Object.freeze({getSnapshot:source.getSnapshot,subscribe(notify:()=>void){
let previous=source.getSnapshot();const queue=createFrameQueue<string>(clock,()=>notify());
@@ -17,6 +19,7 @@ export function createSessionFrameSource(source:ExternalSource<SessionSnapshot>,
if(previous.gesture!==next.gesture)for(const state of [previous.gesture,next.gesture])for(const target of state?.targets??[])dirty.add(`element:${target.id}`);
// 连线手势与普通几何手势分别跟踪,改变或取消时都让旧/新目标失效。
if(previous.connectionGesture!==next.connectionGesture)for(const state of [previous.connectionGesture,next.connectionGesture])if(state)dirty.add(`element:${state.target.id}`);
// 先推进 previous 再排队,因此一帧内多次变化相对每次上游快照计算,不只比较帧起点与终点。
previous=next;queue.invalidate(dirty);
});return()=>{stop();queue.dispose();};
}});
+16 -7
View File
@@ -10,7 +10,9 @@ import type {ExternalSource} from './selectors.ts';
/** preview 可被所有匹配代次的视图读取,active 则只标记拥有该手势的视图。 */
export type GestureSelection={readonly preview:Geometry|undefined;readonly active:boolean};
/** Derived view/target subscriptions. Values retain no document or full per-hook session. */
/** 建立按视图模式和元素手势分离的订阅通道,比较派生值后才通知观察者。 */
/** 建立按视图模式和几何手势分离的通道;只追踪 modes/gesture,不把 selection/viewport/connectionGesture 混入。
* modes 是 viewId→elementId→观察器桶,gestures 是 elementId→观察器桶,同一上游变化先选候选再比值。
* 有观察器时 previous 持有一份上游快照引用供比较,不复制完整 session;最后退订后释放该引用。 */
export function createSessionSources(source:ExternalSource<SessionSnapshot>){
// update 先推进内部观察值,notify 后执行外部回调,避免某监听器读到其它桶尚未更新。
type Observer={update(snapshot:SessionSnapshot):boolean;notify():void};
@@ -18,9 +20,11 @@ export function createSessionSources(source:ExternalSource<SessionSnapshot>){
// 按不可变 targets 数组的身份弱缓存 ID→代次索引,避免每个元素订阅逐次线性搜索目标列表。
const targetIndexes=new WeakMap<readonly Target[],ReadonlyMap<Id,number>>();
let stop:(()=>void)|undefined,previous:SessionSnapshot|undefined;
/** 首次遇到一组目标时建索引;相同 ID 的新 incarnation 不会匹配旧手势。 */
/** 按 targets 数组身份缓存 elementId→incarnation,要求上游不原地改 targets。
* 合法会话应保证同组 ID 唯一;Map 对重复 ID 留最后一项,此 helper 不另验目标表形状。 */
function targets(gesture:GestureState){let index=targetIndexes.get(gesture.targets);if(!index){index=new Map(gesture.targets.map(target=>[target.id,target.incarnation]));targetIndexes.set(gesture.targets,index);}return index;}
/** 收集可能受影响的观察器,全部 update 完再逐个 notify;一个监听器失败也不饿死其余监听器。 */
/** 在 changed 入口取得同一份 next,先推进 previous,再用 Set 合并所有候选,避免观察器重复更新。
* 先更新全部候选 observed 后 notify;各 notify 异常暂存,余者仍执行,最后重抛第一个异常(包括假值)。 */
function changed(){
if(!previous)return;const before=previous,next=source.getSnapshot();previous=next;
const candidates=new Set<Observer>(),add=(items:Set<Observer>|undefined)=>{for(const item of items??[])candidates.add(item);};
@@ -40,7 +44,9 @@ export function createSessionSources(source:ExternalSource<SessionSnapshot>){
const notifications:Observer[]=[];for(const observer of candidates)if(observer.update(next))notifications.push(observer);
let failed=false,error:unknown;for(const observer of notifications)try{observer.notify();}catch(caught){if(!failed){failed=true;error=caught;}}if(failed)throw error;
}
/** 一个派生通道的稳定读取和订阅装配。读取失败通过通知触发使用方重读,不把错误吞成旧成功值。 */
/** 每个 channel 独享 getSnapshot 的派生值缓存;每个订阅另存 observed,二者不靠回调共享可变业务数据。
* equal 深比较派生 JSON 风格值,等价时保留旧引用;选中 preview 仍借用原不可变 Geometry,不做深克隆。
* observer.read 失败会标记失败并通知,后续成功也通知一次恢复;直接 getSnapshot 的读取异常仍向外抛出。 */
function channel<T>(read:(snapshot:SessionSnapshot)=>T,register:(observer:Observer)=>()=>void):ExternalSource<T>{
let initialized=false,value:T;
return Object.freeze({
@@ -51,18 +57,21 @@ export function createSessionSources(source:ExternalSource<SessionSnapshot>){
const remove=register(observer);
// 第一个观察器才连上游;注册失败撤回本次桶,防止留下无人管理的观察器。
if(!stop){previous=source.getSnapshot();try{stop=source.subscribe(changed);}catch(error){remove();previous=undefined;throw error;}}
// 单次退订幂等;所有桶清空后释放全局上游订阅及上一份会话快照。
// 退订 token 先置 inactive,remove 清桶;最后一桶消失才 stop 上游并清 previous。
// stop 异常不在这里捕获,状态重置不保证继续执行;正常宿主清理端口须不抛错。
let active=true;return()=>{if(!active)return;active=false;remove();if(!modes.size&&!gestures.size){stop?.();stop=undefined;previous=undefined;}};
},
});
}
return Object.freeze({
/** 派生某视图中单元素的编辑模式;视图/模式缺失时返回 null。 */
/** 捕获 viewId/id 两个标量,按 view→element 桶登记观察器;视图/模式缺失返回 null。
* 调用 mode 只创建派生通道,首次 subscribe 才注册桶和必要的上游监听。 */
mode(viewId:Id,id:Id):ExternalSource<string|null>{return channel(snapshot=>snapshot.views.get(viewId)?.modes.get(id)??null,observer=>{
let byId=modes.get(viewId);if(!byId){byId=new Map();modes.set(viewId,byId);}let bucket=byId.get(id);if(!bucket){bucket=new Set();byId.set(id,bucket);}bucket.add(observer);
return()=>{bucket!.delete(observer);if(!bucket!.size)byId!.delete(id);if(!byId!.size)modes.delete(viewId);};
});},
/** 按目标 ID+代次读取预览;同元素跨视图可见预览,但只有原 viewId 拥有 active。 */
/** 入口捕获 target.id/incarnation,之后改调用者 target 不影响本通道;订阅按 ID 分桶,读值再匹配代次。
* 匹配元素跨视图可见 preview,只有 gesture.viewId 对应视图 active;没有 preview 不代表不在手势 targets 中。 */
gesture(viewId:Id,target:Target):ExternalSource<GestureSelection>{const{id,incarnation}=target;return channel(snapshot=>{
const gesture=snapshot.gesture,matched=!!gesture&&targets(gesture).get(id)===incarnation;
return{preview:matched?gesture!.preview.get(id):undefined,active:matched&&gesture!.viewId===viewId};
+2 -1
View File
@@ -4,5 +4,6 @@
import type {Rect} from '../canvas-core/geometry.ts';
/** Positive-area native node visibility. Degenerate nodes are retained for measurement. */
/** 正面积矩形要求与视口有正面积交叠,只有边界接触不算可见。
* 宽/高非正的退化节点保留,让 renderer 有机会测量它,而不是提前裁掉。 */
* 宽/高非正的节点直接保留,即使远离视口,让 renderer 有机会测量;负数也走同一分支,并不在此修正。
* 调用方须提供同一坐标系中的轴对齐矩形;此函数不处理旋转、zoom 或 NaN/Infinity 合法性。 */
export function nativeRectVisible(node:Rect,viewport:Rect){if(node.width<=0||node.height<=0)return true;return Math.min(node.x+node.width,viewport.x+viewport.width)>Math.max(node.x,viewport.x)&&Math.min(node.y+node.height,viewport.y+viewport.height)>Math.max(node.y,viewport.y);}
+8 -3
View File
@@ -2,6 +2,7 @@
* 嵌套 canvas 的坐标换算:Transform 把一个 canvas 的局部空间映射到项目世界空间。
* 统一采用平移、等比缩放和旋转,不处理 skew/非等比父变换。owner 改变时通过世界空间中转,
* 保持节点外观和连线曲线位置;表现单位的 data 修正由结构/编辑规划器另行生成。
* 数学辅助函数依赖已校验的有限几何/正比例;它们不逐项代替 schema、registry 或可见性校验。
*/
import type { Document, Geometry, Id, Json, Point } from './types.ts';
import { ancestors } from './document.ts';
@@ -22,9 +23,10 @@ export function toWorld(transform: Transform, point: Point): Point { const value
export function toLocal(transform: Transform, point: Point): Point { return rotate({ x: (point.x - transform.x) / transform.scale, y: (point.y - transform.y) / transform.scale }, -transform.rotation); }
/** 将可识别的 JSON 对象视为字段表,空值/原始值/数组按空表处理,不修改源数据。 */
const object = (value: Json | undefined) => value && typeof value === 'object' && !Array.isArray(value) ? value : {};
/** 对已识别的 bezier-chain 换空间;未知路径原样返回,严格写入边界由 convertPath 把关。
/** type/knots 可识别时逐点换空间;其它路径原样返回,不在本函数完整验证曲线形状。
* knot.position 是位置,in/out 与首尾 control 是偏移向量,向量不能叠加平移。 */
export function transformPath(path: Json, transform: Transform, inverse = false): Json {
// 直接调用可对部分字段做转换;实际需要换空间时,convertPath 会先检查支持的路径格式。
const raw = object(path);
if (raw.type !== 'bezier-chain' || !Array.isArray(raw.knots)) return path;
/** 保留点的扩展字段;vector 模式把变换平移归零,仅施加旋转/比例。 */
@@ -42,13 +44,15 @@ export function transformPath(path: Json, transform: Transform, inverse = false)
/** 合成父变换与分区入口几何,得到子 canvas 内容区原点/比例/旋转。
* 24/36 是格式 1 的内容内边距,要乘 presentation.unitScale,和 renderer 的投影保持一致。 */
export function childCanvasTransform(parent:Transform,geometry:Geometry,presentation:Json|undefined):Transform {
// unitScale 由专用读取器检查;contentScale 在这里仅按 number 且 >0 选择,有限性等约束依赖上游 schema。
const data=object(presentation),units=unitScale(presentation),scale=(typeof data.contentScale==='number'&&data.contentScale>0?data.contentScale:1)*units;
// 入口绕中心旋转,因此先把内容内边距写成相对中心偏移,再旋转并加回中心。
const offset=rotate({x:24*units-geometry.width/2,y:36*units-geometry.height/2},geometry.rotation);
const start=toWorld(parent,{x:geometry.x+geometry.width/2+offset.x,y:geometry.y+geometry.height/2+offset.y});
return{...start,scale:parent.scale*scale,rotation:((parent.rotation+geometry.rotation)%360+360)%360};
}
/** 从根向目标逐级合成入口变换;ancestors 同时检查缺失父项与循环。 */
/** 从根向目标逐级合成入口变换;ancestors 检查缺失父项与循环。
* 这里只要求每层入口存在且为 node,不检查组件是否已知/展开;可编辑性与可见性由上层决定。 */
export function canvasTransform(document:Document,canvasId:Id):Transform {
let result:Transform={...origin};
for(const id of ancestors(document,canvasId).reverse()){
@@ -65,7 +69,8 @@ export function convertGeometry(geometry:Geometry,from:Transform,to:Transform):G
const center=toLocal(to,toWorld(from,{x:geometry.x+geometry.width/2,y:geometry.y+geometry.height/2})),ratio=from.scale/to.scale,width=geometry.width*ratio,height=geometry.height*ratio;
return{x:center.x-width/2,y:center.y-height/2,width,height,rotation:((geometry.rotation+from.rotation-to.rotation)%360+360)%360};
}
/** 严格曲线换空间:null/同变换无需改;不支持的路径拒绝,不擅自解释未知 payload。 */
/** null/同变换先原样返回;只有实际需要换空间时才检查支持的曲线格式,不支持就拒绝。
* 两次转换分别完成旧局部→世界、世界→新局部;无须改变 knot ID 或曲线段拓扑。 */
export function convertPath(path:Json,from:Transform,to:Transform):Json {
if(path===null||sameTransform(from,to))return path;
if(!connectionPathValid(path))throw fault('unsupported_coordinate_path');
+5 -3
View File
@@ -1,6 +1,7 @@
/**
* 组件声明式引用路径的遍历器,供复制/解散等纯规划复用。
* 只访问自有字段,不用字符串相似度猜 ID;这里 yield 可写 slot,调用者必须先复制数据再改。
* 普通路径段只访问自有字段,不用字符串相似度猜 ID;这里 yield 可写 slot,调用者必须先复制数据再改。
* path 应先经组件定义校验:本遍历器不重新禁止空路径或原型敏感键,数组 * 按下标位置枚举。
*/
import type { Json } from './types.ts';
import { fault } from './values.ts';
@@ -14,7 +15,7 @@ export function* copySlots(data: Json, path: readonly string[]): Generator<{pare
const {value, depth} = work.pop()!, key = path[depth];
if (key === '*') {
if (!Array.isArray(value)) throw fault('invalid_copy_reference');
// 终端 * 直接枚举数组项位置;非终端 * 继续匹配下一路径段。
// 终端 * 直接枚举 0..length-1,不另用 hasOwn 检查空洞;非终端 * 继续匹配下一路径段。
if (depth === path.length - 1) { for (let i = 0; i < value.length; i++) yield {parent: value as unknown as Record<string, Json>, key: String(i)}; }
else for (let i = value.length - 1; i >= 0; i--) work.push({value: value[i], depth: depth + 1});
} else if (value && typeof value === 'object' && Object.hasOwn(value, key)) {
@@ -23,7 +24,8 @@ export function* copySlots(data: Json, path: readonly string[]): Generator<{pare
}
}
}
/** 在 slot 上读取引用 ID;null 代表无引用,many 要求数组,每个 ID 必须非空字符串。 */
/** 在 slot 上读取引用 ID;整个 slot 为 null 表示无引用,many 要求数组,每个 ID 必须非空字符串。
* many 数组里的 null 不等于无引用,会拒绝;many 与路径中的 * 是独立的两层展开规则。 */
export function* copyReferenceIds(data: Json, ref: {path: readonly string[]; many?: boolean}): Generator<string> {
for (const {parent, key} of copySlots(data, ref.path)) {
const value = parent[key]; if (value === null) continue;
+11 -5
View File
@@ -1,6 +1,7 @@
/**
* 同步事务内的大批创建优化。追加表用固定成员的 base+added 视图避免每新增一项都复制整表,
* 累计 64 项后折叠为新 base。只用于尚未发布的 draft,提交前统一物化为标准文档表。
* 已有追加层达到 64 项时,下一次追加先折叠为新 base。只用于未发布 draft,提交前统一物化文档表。
* 输入表须已具固定成员;视图借用 base 和未变记录,不是任意可变 Map 的隔离器。
*/
import {detachElementData} from './element-data.ts';
import type {CanvasRecord,Change,Document,ElementRecord,Id} from './types.ts';
@@ -14,12 +15,14 @@ const overlays=new WeakMap<object,{base:ReadonlyMap<Id,unknown>;added:ReadonlyMa
function append<T>(input:ReadonlyMap<Id,T>,id:Id,value:T):ReadonlyMap<Id,T>{
if(input.has(id))throw fault('duplicate_identity',[id]);
let previous=overlays.get(input) as {base:ReadonlyMap<Id,T>;added:ReadonlyMap<Id,T>}|undefined;
// 控制覆盖层大小;到阈值后重建 base,而不是让链条随着事务长度无限加深。
// 当前 input 若含 64 个 added,本次先通过 readMap(input) 折叠,再以新 added 放入第 65 个;不是整个事务最多 64 项。
if(previous&&previous.added.size>=64)previous=undefined;
// overlays 记录的是框架创建的视图身份;普通输入直接借用,只有已知旧 overlay 才需要折叠。
const base=previous?.base??(overlays.has(input)?readMap(input):input),added=new Map(previous?.added);
added.set(id,value);
/** 迭代先 base 后 added,保持插入顺序;视图的 get/has 优先查 appended 成员。 */
const entries=function*():Generator<[Id,T],undefined,unknown>{yield* base;yield* added;return undefined;};
// get 用 has 区分“added 中显式存 undefined”与缺失;迭代顺序始终 base 在前、追加成员在后。
// keys/values/iterator 复用 entries;forEach 第三个参数仍是安全视图,不泄漏 added Map。
const view:ReadonlyMap<Id,T>=Object.freeze({size:base.size+added.size,get:(key:Id)=>added.has(key)?added.get(key):base.get(key),has:(key:Id)=>added.has(key)||base.has(key),entries,
*keys():Generator<Id,undefined,unknown>{for(const [key]of entries())yield key;return undefined;},*values():Generator<T,undefined,unknown>{for(const [,item]of entries())yield item;return undefined;},[Symbol.iterator]:entries,
@@ -29,14 +32,17 @@ function append<T>(input:ReadonlyMap<Id,T>,id:Id,value:T):ReadonlyMap<Id,T>{
}
/** Internal editor path: input tables already have fixed membership. */
/** 仅接收 before=null 且 after 存在的创建变化,校验 JSON/ID 后捕获记录并追加相应表。
* 整图依赖可在后续命令补齐,因此此处不提前做完整树/端点校验。 */
* 整图依赖可在后续命令补齐,因此此处不提前做完整树/端点校验。
* 本层只检查创建形状、JSON、ID 一致及活表重复;registry 支持与墓碑禁复用由 editor/最终校验负责。 */
export function stageCreation(document:Document,change:Extract<Change,{kind:'canvas'|'element'}>):Document{
if(change.before!==null||!change.after)throw fault('invalid_creation_draft');
if(!validJson(change.after))throw fault(change.kind==='canvas'?'invalid_canvas':'invalid_element',[change.id]);
if(change.after.id!==change.id)throw fault('identity_mismatch',[change.id]);
const value=change.kind==='element'?detachElementData(change.after as ElementRecord):freeze(clone(change.after));
// 只替换被追加的表,另一表/墓碑沿用;新 Document 标记为 draft,旧视图不随本次 added 改变。
const next=Object.freeze({...document,...(change.kind==='canvas'?{canvases:append(document.canvases,change.id,value as CanvasRecord)}:{elements:append(document.elements,change.id,value as ElementRecord)})});drafts.add(next);return next;
}
/** Only published documents use the core's owned table envelopes. */
/** 只有已标记的 draft 需要通过空 changes 重新捕获表;普通文档原样复用。 */
/** Materialize transaction-local append tables before publication. */
/** 只有已标记 draft 通过空 changes 物化成工厂 envelope/readMap;其它文档原样返回。
* 不在这里校验 schema/树,也不把任何外来 Document 自动视为已校验或深度不可变。 */
export const materializeCreations=(document:Document)=>drafts.has(document)?applyChanges(document,[]):document;
+17 -8
View File
@@ -22,12 +22,14 @@ export function validateCreationSizeEffect(value: unknown): asserts value is Cre
* Already-issued operation bodies are deliberately outside this function.
*/
/** 仅原创建仍在 undo、目标仍活且未被用户缩放时,准备默认尺寸与历史修正。
* 返回候选 document/history/changes;原输入始终不变,失败不留下半份历史。 */
* 返回候选 document/history/changes,不发布状态;输入须来自已捕获的不可变文档与历史。
* 这里按 geometry.resize 能力判定,不限定 componentId 必须是 image;图片授权条件由外层 grant 决定。 */
export function planCreationSize(document: Document, history: HistoryState, registry: Registry,
entryId: Id, target: Target, size: { readonly width: number; readonly height: number }) {
/** 统一将失败绑定到目标 ID,便于调用方解释为何不能再自动调整。 */
const fail = (code: string): never => { throw fault(code, [target.id]); };
if (![size.width, size.height].every(value => Number.isFinite(value) && value > 0)) fail('invalid_creation_size');
// 创建项必须仍在已应用 undo 时间线上;已撤销到 redo、被淘汰或目标换代的尺寸结果不能采用。
const index = history.undo.indexOf(entryId), entry = history.entries.get(entryId);
const current = document.elements.get(target.id);
if (index < 0 || !entry || current?.incarnation !== target.incarnation || current.structure !== 'node') fail('creation_size_target_changed');
@@ -40,7 +42,7 @@ export function planCreationSize(document: Document, history: HistoryState, regi
const units = unitScale(created.data.presentation);
const baseline = { width: created.geometry.width / units, height: created.geometry.height / units };
const ratio = { width: size.width / baseline.width, height: size.height / baseline.height };
/** 同一生命周期的节点才属于本次自动尺寸修正对象。 */
/** 调用处已按 target.id 取记录,这里再限制 incarnation 与 node 结构;不单独比较元素 ID。 */
const matches = (element: ElementRecord | undefined) => element?.incarnation === target.incarnation && element.structure === 'node';
/** 比较宽高除 unitScale 后的视觉值,移动/换 owner 不等同于用户主动缩放。 */
const unchanged = (element: ElementRecord | undefined) => matches(element) && element!.structure === 'node'
@@ -52,9 +54,11 @@ export function planCreationSize(document: Document, history: HistoryState, regi
// Rewind on private roots. applyChanges intentionally preserves historical
// incarnations here; this is inspection, not a user-visible delete/restore.
let original = document;
// 从创建项起回退已应用历史,得到创建前私有基线;不产生用户可见的删除/恢复代次。
// 从创建项起回退全部已应用项,得到创建前私有基线;此处用 applyChanges 而非 planHistoryChanges,
// 因而允许检查途中存在历史代次/墓碑组合,不把它们作为最终文档发布。
const applied = history.undo.slice(index);
for (const id of [...applied].reverse()) original = applyChanges(original, invert(history.entries.get(id)!.changes));
// original 追踪旧时间线,amended 追踪修正后的时间线;inheriting 只在匹配创建之后开启。
let amended = original, inheriting = false;
const entries = new Map(history.entries);
// 先按 undo 的正向顺序重建已应用项,再倒序遍历 redo 栈还原未来重做时间线。
@@ -63,11 +67,12 @@ export function planCreationSize(document: Document, history: HistoryState, regi
const id = timeline[cursor], source = entries.get(id)!;
const end = applyChanges(original, source.changes), isApplied = cursor < applied.length;
if (isApplied && !unchanged(end.elements.get(target.id))) fail('creation_size_user_resized');
// A future explicit resize remains a resize to the user's chosen dimensions.
// Only its before value inherits the asynchronous default correction.
// A future resize preserves each dimension explicitly changed by the user.
// Its before value and unchanged dimensions may inherit the default correction.
const futureResize = !isApplied && inheriting && matches(end.elements.get(target.id)) && !unchanged(end.elements.get(target.id));
let resizeConsumed = false;
/** redo 中用户显式改过的维度停止继承默认比例;仅修正其 before,不改用户指定的 after。 */
/** redo 中按宽/高分别判断:被用户改过的维度将 ratio 置 1,保留其 after;
* before 重取修正时间线,未被用户改变的另一维仍可继承默认比例。每项最多消费一次。 */
const preserveFutureResize = () => {
if (!futureResize || resizeConsumed) return;
const value = end.elements.get(target.id) as ElementRecord & { structure: 'node' };
@@ -76,9 +81,10 @@ export function planCreationSize(document: Document, history: HistoryState, regi
resizeConsumed = true;
};
const changes: Change[] = [];
// 每条 next 都基于 amended 的实际 before 重建,保证修正后的连续历史仍条件匹配。
// 目标整记录/geometry 变化从 amended 重取 before;其它变化保留原条件,继续用 applyChanges 校验。
for (const change of source.changes) {
let next = change;
// 整记录变化也要重取真实 before;其它对象/字段沿用原变化,保证整条时间线仍按原次序检查。
if (change.kind === 'element' && change.id === target.id) {
const before = amended.elements.get(target.id) ?? null;
const birth = id === entryId && change === creations[0];
@@ -98,6 +104,7 @@ export function planCreationSize(document: Document, history: HistoryState, regi
amended = applyChanges(amended, [next]);
changes.push(next);
}
// 整项完成后再推进旧时间线;只有变化内容确实不同才替换 entries 中这一项,operationIds/标签保持。
original = end;
if (!equal(changes, source.changes)) entries.set(id, freeze({ ...source, changes }));
}
@@ -112,10 +119,12 @@ export function planCreationSize(document: Document, history: HistoryState, regi
]);
const nextDocument = applyChanges(document, changes);
validateDocument(nextDocument, registry);
// 最后用组件自己的 resize handler 对照约束,自动尺寸不能绕过组件 min/max/aspect 政策。
// 调用已安装 resize handler,把其生成变化应用后的目标记录与直接候选比较;不一致就拒绝。
// 这能执行组件实际实现的限制,不凭 capability 名称宣称所有组件都有 min/max/aspect 政策。
const handler = registry.handlers.get(current!.componentId)?.get('geometry.resize');
const proposed = handler!({ element: current!, input: freeze({ patch: { width: after.width, height: after.height } }) });
for (const change of proposed) if (change.target.id !== target.id || change.target.incarnation !== target.incarnation || change.field !== 'geometry') fail('invalid_handler_change');
if (!equal(applyChanges(document, proposed).elements.get(target.id), nextDocument.elements.get(target.id))) fail('creation_size_constraint');
// 仅返回当前几何 changes,历史 entries 是整条修正时间线;两栈顺序沿用,不把上方 redo 检查变成实际重做。
return Object.freeze({ document: nextDocument, changes, history: Object.freeze({ undo: history.undo, redo: history.redo, entries: readMap(entries) }) });
}
+9 -2
View File
@@ -1,6 +1,7 @@
/**
* 有限预算的三次曲线命中精判。先用控制点包围盒排除,再用弦线距离与平坦度误差判断;
* 不能确定时用 de Casteljau 二分。预算/数值精度不足明确返回 possible,不能伪称已证明 miss。
* 不能确定时用 de Casteljau 二分。预算/数值精度不足明确保留可能命中,不能伪称已证明 miss。
* 上层须提供合法曲线/点、非负半径和有效预算,本函数没有独立参数校验或 registry 检查。
*/
import type {Point} from './types.ts';
import type {Cubic} from './geometry.ts';
@@ -16,12 +17,18 @@ function split([a,b,c,d]:Cubic):[Cubic,Cubic]{const ab=mid(a,b),bc=mid(b,c),cd=m
* maxSteps 限总工作,maxDepth 限细分深度,error 控制可接受平坦度;stack 用显式 DFS。
* 每轮:包围盒排除→near±flat 判定→足够平/深度已满则记 possible→否则入栈左右子曲线。
* near 是到端点弦线段距离,flat 包括两控制点到该弦的偏差及 roundoff。
* 剩余 stack 非空表示总预算耗尽,仍可能有未检查的命中区域。 */
* 剩余 stack 非空表示总预算耗尽,仍可能有未检查的命中区域。
* certainty 只在 hit=true 时区分 confirmed/possible;hit=false 的正常返回也固定带 possible,不是“可能没命中”的三态枚举。 */
export function hitCubic(curve:Cubic,point:Point,radius:number,options:{error:number;maxDepth:number;maxSteps:number}){
// 栈中保存尚未证明可排除的子曲线,steps 按实际取出的子曲线计数;possible 记住已停止细分的模糊区域。
const stack:{curve:Cubic;depth:number}[]=[{curve,depth:0}];let steps=0,possible=false,estimate=Infinity,error=0;
while(stack.length&&steps<options.maxSteps){const item=stack.pop()!;steps++;const xs=item.curve.map(p=>p.x),ys=item.curve.map(p=>p.y),dx=Math.max(0,Math.min(...xs)-point.x,point.x-Math.max(...xs)),dy=Math.max(0,Math.min(...ys)-point.y,point.y-Math.max(...ys));if(Math.hypot(dx,dy)>radius)continue;
// 弦距离 near 与 flat 给出保守区间:near-flat 超半径可排除,near+flat 在半径内可确认。
// 数值非有限时直接返回 possible 且 budget=true;该标志此时不只是“栈还有工作”的含义。
const[a,b,c,d]=item.curve,roundoff=Number.EPSILON*64*Math.max(1,Math.abs(point.x),Math.abs(point.y),...xs.map(Math.abs),...ys.map(Math.abs)),flat=Math.max(distance(b,a,d),distance(c,a,d))+roundoff,near=distance(point,a,d);if(!Number.isFinite(flat)||!Number.isFinite(near))return{hit:true,certainty:'possible' as const,steps,distance:Infinity,error:Infinity,budget:true};if(near-flat>radius)continue;if(near+flat<=radius)return{hit:true,certainty:'confirmed' as const,steps,distance:near,error:flat,budget:false};
// 达到平坦误差或深度阈值只保留 possible,budget 可仍为 false;继续二分时先压右再压左,按左优先 DFS。
if(flat<=options.error||item.depth>=options.maxDepth){possible=true;if(near<estimate){estimate=near;error=flat;}continue;}const[left,right]=split(item.curve);stack.push({curve:right,depth:item.depth+1},{curve:left,depth:item.depth+1});
}
// estimate/error 来自已记住的最小 near 模糊区,不是所有曲线的精确最近距离;全被排除时 estimate 仍是 Infinity。
return{hit:possible||stack.length>0,certainty:'possible' as const,steps,distance:estimate,error,budget:stack.length>0};
}
+3 -1
View File
@@ -4,12 +4,14 @@
*/
// Private shape provenance: values may still be mutable and require capture.
// Always copy; external Object.freeze is never accepted as ownership evidence.
// WeakSet 以新容器身份为键,不按内容相等判断;容器不再被外部引用时,标记不会单独阻止回收。
const owned = new WeakSet<object>();
/** 消费 iterable 得到新数组并浅冻结;不会冻结数组中的业务值。 */
export function dataArray<T>(values: Iterable<T>): readonly T[] {
const result = Object.freeze(Array.from(values)); owned.add(result); return result;
}
/** 浅复制对象的可枚举自有属性再冻结外壳;不是任意对象的深复制器。 */
/** 浅复制对象的可枚举自有属性再冻结外壳;不是任意对象的深复制器。
* 对象展开会读取 getter 并保留嵌套值引用,也不保留原对象原型或属性描述符。 */
export function dataRecord<T extends object>(value: T): Readonly<T> {
const result = Object.freeze({...value}); owned.add(result); return result;
}
+8 -4
View File
@@ -12,7 +12,8 @@ import {repairConnectionOwners} from './structure.ts';
import {clone,equal,fault,freeze} from './values.ts';
/** Remove an owning canvas, retaining its entry and promoting only direct members. */
/** 验证 canvas/入口生命周期,规划成员和引用转换;根不能解散,任何不支持路径整体拒绝。 */
/** 验证 canvas/入口生命周期并规划成员/引用转换;根不能解散,必需转换遇到不支持能力或格式时抛错。
* 源应是有效不可变文档;本函数不执行最终 validateDocument,也不调用宿主 policy 或直接发布。 */
export function planDissolveCanvas(document:Document,registry:Registry,canvasId:Id,entryTarget:Target):{readonly changes:readonly Change[];readonly commands:readonly Command[]}{
const canvas=document.canvases.get(canvasId);if(!canvas)throw fault('canvas_not_loaded',[canvasId]);
if(!canvas.parent)throw fault('cannot_dissolve_root',[canvasId]);
@@ -23,7 +24,7 @@ export function planDissolveCanvas(document:Document,registry:Registry,canvasId:
for(const element of document.elements.values())if(element.ownerCanvasId===canvasId&&element.structure==='node'){
if(!registry.supports(element,'geometry.move'))throw fault('unsupported_capability',[element.id,'geometry.move']);
const target={id:element.id,incarnation:element.incarnation},geometry=convertGeometry(element.geometry,from,to),presentation=withUnitScale(element.data.presentation,unitScale(element.data.presentation)*from.scale/to.scale);
// 即使最终用 changes 原子提交,也要向 editor 暴露逐成员 policy 检查,防止入口权限越权。
// commands 仅给 editor 逐成员做 policy 检查,不在此执行 element.move handler;几何转换直接由上面的坐标数学生成。
commands.push({type:'element.move',target,canvasId:parent});
changes.push({kind:'field',target,field:'ownerCanvasId',before:canvasId,after:parent});
if(!equal(geometry,element.geometry))changes.push({kind:'field',target,field:'geometry',before:element.geometry,after:geometry});
@@ -35,7 +36,8 @@ export function planDissolveCanvas(document:Document,registry:Registry,canvasId:
// 在私有候选文档上计算边的新公共上级,并把曲线从旧 owner 空间一并转换。
const promoted=applyChanges(document,changes);changes.push(...repairConnectionOwners(document,promoted));
// Only declared canvas references have meaning here. Opaque payloads are retained.
// 只解释已知组件明确声明的 canvas 引用;opaque payload 和普通字面 ID 文本保留。
// 扫描整个 promoted 文档中已知组件声明的 canvas 引用,因此子树外的引用也可能需要修正。
// opaque payload 与未声明的普通字面 ID 保留;引用路径复用 copy schema,但这里不执行复制的 external 策略。
for(const element of promoted.elements.values()){
if(registry.resolve(element)!=='known')continue;
const references=registry.components.get(element.componentId)?.copy?.references.filter(reference=>reference.target==='canvas')??[];
@@ -48,12 +50,14 @@ export function planDissolveCanvas(document:Document,registry:Registry,canvasId:
const map=(value:Json):Json=>{if(typeof value!=='string'||!value)throw fault('invalid_copy_reference',[element.id]);return value===canvasId?parent:value;};
slot.parent[slot.key]=reference.many?(value as Json[]).map(map):map(value);
}
// 仅产生实际改变的顶层 data 字段,并要求 content.edit 能力/宿主 policy 通过。
// 仅产生实际改变的顶层 data 字段;此处检查 content.edit 已安装,宿主 policy 由返回 commands 交给 editor 重查。
// 不调用 content.edit handler 生成变化,最终 schema/关系检查仍由事务发布路径执行。
for(const key of Object.keys(data))if(!equal(data[key],element.data[key])){
if(!registry.supports(element,'content.edit'))throw fault('unsupported_capability',[element.id,'content.edit']);
changes.push({kind:'field',target:{id:element.id,incarnation:element.incarnation},field:`data.${key}`,before:element.data[key],after:data[key]});
commands.push({type:'element.data',target:{id:element.id,incarnation:element.incarnation},patch:{[key]:data[key]}});
}
}
// 保留原入口元素与边 ID;只移除 canvas 记录,不用 removalChanges 的子树删除语义。
return freeze({changes,commands});
}
+20 -7
View File
@@ -16,7 +16,8 @@ function documentEnvelope(document:Document):Document{const value=Object.freeze(
export const isFactoryDocument=(document:Document)=>factoryDocuments.has(document);
/** 捕获输入记录、拒绝同一表内重复 ID,并脱离调用者的可变数据。
* 不在此处验证整棵树或所有跨记录引用;完整发布前仍需 validateDocument。 */
* 不在此处验证整棵树、组件 schema 或所有跨记录引用;完整发布前仍需 validateDocument。
* canvas 总是复制冻结,element 可复用元素捕获模块已持有的记录;墓碑复制表成员,元素固有证明只省去重复形状遍历。 */
export function createDocument(projectId: Id, canvases: readonly CanvasRecord[], elements: readonly ElementRecord[] = [], tombstones: ReadonlyMap<Id, number> = new Map()): Document {
for (const element of elements) { const id = element.id; if (!staticElementProofs.has(element)&&!validJson(element)) throw fault('invalid_element', [id]); }
for (const canvas of canvases) if (!validJson(canvas)) throw fault('invalid_canvas');
@@ -61,14 +62,19 @@ const staticElementProofs=new WeakSet<object>();
/** 校验单条记录的基础形状、墓碑代次和已知组件数据。
* staticElementProofs 仅缓存已捕获记录的固有形状;墓碑和 registry 相关检查每次仍执行。 */
export function validateElementRecord(element: ElementRecord, registry: Registry, tombstones: ReadonlyMap<Id,number> = new Map(), componentData = true): void {
// 静态证明仅随不可变记录身份复用,不能因浅冻结或一个文档 envelope 标记就跳过校验。
const proven=!!element&&staticElementProofs.has(element);
if (!proven&&(!element || !validJson(element) || typeof element.id !== 'string' || !element.id || typeof element.ownerCanvasId !== 'string' || !element.ownerCanvasId || typeof element.componentId !== 'string' || !element.componentId || !['node','connection'].includes(element.structure) || !Number.isSafeInteger(element.incarnation) || element.incarnation < 1 || !Number.isSafeInteger(element.dataVersion) || element.dataVersion < 1 || !element.data || typeof element.data !== 'object' || Array.isArray(element.data) || !validJson(element.data))) throw fault('invalid_element', [element?.id]);
// 墓碑属于当前文档上下文,即使同一记录以前通过过形状校验,也要重新核对已死亡代次。
const dead=tombstones.get(element.id);if(dead!==undefined&&element.incarnation<=dead)throw fault('stale_incarnation',[element.id]);
// 只有精确支持的组件版本进入 schema 检查;未知组件/不支持版本仍须通过通用形状约束,不在这里解释其 payload。
const definition=registry.components.get(element.componentId);
if(componentData&&registry.resolve(element)==='known'&&(!definition||definition.structure!==element.structure||!componentDataValid(element,definition)))throw fault('invalid_component_data',[element.id]);
// 命中固有证明只能略过下方几何/端点形状遍历,不能略过上方墓碑与本次启用的 schema 检查。
if(proven)return;
if(element.structure==='node'){if(!geometryValid(element.geometry))throw fault('invalid_geometry',[element.id]);}
else if(!element.source||!element.target||typeof element.source.elementId!=='string'||!element.source.elementId||typeof element.target.elementId!=='string'||!element.target.elementId||[element.source,element.target].some(endpoint=>endpoint.portId!==undefined&&(typeof endpoint.portId!=='string'||!endpoint.portId))||!validJson(element.path))throw fault('invalid_endpoint',[element.id]);
// 拓扑规划可 componentData=false;此时仍可记固有证明,但以后启用 schema 的调用依然会重查 data。
if(hasOwnedElementData(element))staticElementProofs.add(element);
}
@@ -101,6 +107,7 @@ function validateElementInDocument(document:Document,element:ElementRecord,regis
function* validateDocumentChecks(document:Document,registry:Registry,componentData:boolean):Generator<void,void>{
if(!document.canvases.size)throw fault('missing_root');
for(const[id,incarnation]of document.tombstones){yield;validateTombstone(id,incarnation);}
// 一次遍历累计入口占用和根数;必须继续到最后,才能得到根数及所有元素的完整结论。
const entries=new Set<Id>();let roots=0;
for(const canvas of document.canvases.values()){yield;roots+=validateCanvasInDocument(document,canvas,entries);}
if(roots!==1)throw fault('invalid_roots');
@@ -111,7 +118,8 @@ function* validateDocumentChecks(document:Document,registry:Registry,componentDa
export function* validateDocumentTopologySteps(document:Document,registry:Registry):Generator<void,void>{yield* validateDocumentChecks(document,registry,false);}
/** 含组件 payload 的完整分片校验;必须迭代到结束才证明整份文档通过。 */
export function* validateDocumentSteps(document:Document,registry:Registry):Generator<void,void>{yield* validateDocumentChecks(document,registry,true);}
/** 同步完整校验,要求恰好一个根,所有拥有关系、端点和组件数据均合法。 */
/** 同步走完整文档检查:要求一个根,检查拥有关系、入口、节点端点及连线共同 owner。
* 已支持的精确组件版本检查 data schema;未知/不支持版本按通用记录规则保留,不冒称已理解其 payload。 */
export function validateDocument(document:Document,registry:Registry):void{
if(!document.canvases.size)throw fault('missing_root');
for(const[id,incarnation]of document.tombstones)validateTombstone(id,incarnation);
@@ -120,21 +128,24 @@ export function validateDocument(document:Document,registry:Registry):void{
if(roots!==1)throw fault('invalid_roots');
for(const element of document.elements.values())validateElementInDocument(document,element,registry,true);
}
/** 一批有序修改及其允许的 canvas 上下文;回放不能跨越已冻结的操作范围。 */
/** 一批有序修改及可选 canvas 上下文;当前执行器用它检查 field 目标的 owner。
* 这不是覆盖所有 change 种类的授权器;整元素/树修改还需调用方规划、权限及发布前校验。 */
export type ReplayChangeBatch = {readonly changes:readonly Change[];readonly canvasIds?:readonly Id[]};
/** 在私有表副本中应用一批变化;默认校验 before 条件,失败不会修改输入文档。 */
/** 在已捕获的不可变文档上,以私有表副本应用变化;默认校验 before 条件。
* 未改记录沿用原引用,不是任意外部 Document 的深隔离入口;修改记录的 freeze 也可能触及共享嵌套值。 */
export function applyChanges(document: Document, changes: readonly Change[], conditional = true): Document {
return applyChangeBatches(document,[{changes}],conditional,false);
}
/** Ordered conditional replay against one private draft; no intermediate document escapes. */
/** 顺序回放多批变化,已经满足的 after 可跳过,但仍检查目标的 scope。 */
/** 顺序回放多批变化,已经满足的 after 可跳过;field 变化在跳过前仍检查当前 owner 范围。
* 批次未提供 canvasIds 或提供空数组时,这一局部 owner 范围检查不启用。 */
export function replayChanges(document:Document,batches:Iterable<ReplayChangeBatch>):Document {
return applyChangeBatches(document,batches,true,true);
}
/** 三类变化共用的执行器:field 改字段、element 增删元素、canvas 改树记录。
* 仅在全部循环成功后返回冻结 envelope,不泄漏半成品;此处不做整图合法性校验。 */
function applyChangeBatches(document:Document,batches:Iterable<ReplayChangeBatch>,conditional:boolean,replay:boolean):Document {
// 复制表成员而复用未改记录;出错时丢弃这些私有 Map,输入快照保持不变。
// 复制表成员而复用未改记录;失败不返回私有 Map。对有效不可变输入,不会发布或改写原快照。
const elements = new Map(document.elements), canvases = new Map(document.canvases), tombstones = new Map(document.tombstones);
for (const batch of batches) for (const change of batch.changes) {
if(replay){
@@ -158,7 +169,8 @@ function applyChangeBatches(document:Document,batches:Iterable<ReplayChangeBatch
if (change.afterPresent === false) delete data[dataKey]; else data[dataKey] = clone(change.after);
elements.set(element.id, freeze({ ...element, data }));
} else elements.set(element.id, freeze({ ...element, [change.field]: clone(change.after) }) as ElementRecord);
// 整元素修改必须匹配 before;删除同时保留墓碑,使旧 incarnation 永远不能复活。
// 整元素修改在 conditional=true 时匹配 before;删除只记录已死亡的最大代次。
// 此执行器不直接以墓碑拒绝后续插入,发布前 validateElementRecord/validateDocument 才拦截旧代次。
} else if (change.kind === 'element') {
if (conditional && !equal(elements.get(change.id) || null, change.before)) throw fault('element_conflict', [change.id]);
if (change.after) {
@@ -184,6 +196,7 @@ function applyChangeBatches(document:Document,batches:Iterable<ReplayChangeBatch
}
/** 检测回放目标效果是否已经存在;字段目标必须仍是同一个 incarnation 才可视为满足。 */
function changeSatisfied(elements:ReadonlyMap<Id,ElementRecord>,canvases:ReadonlyMap<Id,CanvasRecord>,change:Change):boolean {
// canvas/element 使用整记录 after 比较;删除后的缺失等同于 after:null,重复删除可跳过。
if(change.kind==='canvas')return equal(canvases.get(change.id)??null,change.after);
if(change.kind==='element')return equal(elements.get(change.id)??null,change.after);
const element=elements.get(change.target.id);
+13 -6
View File
@@ -2,6 +2,7 @@
* 可暂停/恢复的深复制准备状态机。检查点保存 ID、阶段、游标、DFS stack 和稳定映射,
* 不保存元素 payload 的第二份副本;输出记录是由源与映射确定性重建的缓存。
* 每次 step 只推进有限语义工作,但持久源校验、分片调度、checkpoint 保存和渐进加入由 runtime 负责。
* 构造器仍同步展开源表并校验保存状态,step 也没有按字节/毫秒强制切断单记录转换。
*/
import {detachJsonData} from './json-data.ts';
import type { CanvasRecord, Change, Document, ElementRecord, Id, Point, Registry, Target } from './types.ts';
@@ -22,7 +23,8 @@ type Immutable<T>=T extends object?{readonly [K in keyof T]:Immutable<T[K]>}:T;
export type DuplicateCheckpoint=Immutable<MutableCheckpoint>;
/** 形状辅助检查:数组成员必须是非空字符串;是否存在/重复由后续检查负责。 */
const ids=(value:unknown):value is Id[]=>Array.isArray(value)&&value.every(id=>typeof id==='string'&&!!id);
/** 校验外部检查点格式、阶段/计数/去重与前缀边界;与实际源的一致性在工厂中进一步核验。 */
/** 检查 DTO 中明确列出的格式、阶段/计数、去重与前缀边界;不是按源重新推导整个已保存执行轨迹。
* 与实际源的部分成员/映射一致性在工厂核验;可信持久记录与 token 来源由 runtime 边界保证。 */
export function validateDuplicateCheckpoint(value:DuplicateCheckpoint):void{
if(!value||!validJson(value)||value.version!==1)throw fault('copy_checkpoint_interpreter_unavailable');
if(![value.projectId,value.sourceToken,value.schemaToken].every(id=>typeof id==='string'&&!!id)||!phases.includes(value.phase)||!Number.isSafeInteger(value.cursor)||value.cursor<(value.phase==='seed'?-1:0)||![value.steps,value.emitted,value.emittedEdges].every(n=>Number.isSafeInteger(n)&&n>=0)||!Array.isArray(value.selection)||!value.selection.length||value.selection.some(t=>!t||typeof t.id!=='string'||!t.id||!Number.isSafeInteger(t.incarnation)||t.incarnation<1)||!value.offset||![value.offset.x,value.offset.y].every(Number.isFinite))throw fault('invalid_copy_checkpoint');
@@ -33,7 +35,8 @@ export function validateDuplicateCheckpoint(value:DuplicateCheckpoint):void{
if(!Array.isArray(value.members)||value.members.some(pair=>!Array.isArray(pair)||pair.length!==2||typeof pair[0]!=='string'||!pair[0]||!ids(pair[1]))||new Set(value.members.map(pair=>pair[0])).size!==value.members.length||value.emitted>value.order.length||value.emittedEdges>value.edges.length||value.emittedEdges>0&&value.emitted!==value.order.length||value.expanding&&(typeof value.expanding.canvas!=='string'||!value.expanding.canvas||!Number.isSafeInteger(value.expanding.index)||value.expanding.index< -1))throw fault('invalid_copy_checkpoint');
}
/** 创建或恢复解释器;绑定 project/sourceToken/schemaToken/选区/偏移/预留映射,任一不符拒绝续跑。
* token 的真实源摘要/定义签名由调用方提供并验证,这里不读取 Blob 或计算 SHA。 */
* token 的真实源摘要/定义签名由调用方提供并验证,这里只比较字符串,不读取 Blob 或计算 SHA。
* 绑定匹配不等于任意外来检查点已获防篡改证明;范围/阶段状态应来自可信恢复记录。 */
export function createDuplicatePlanner(document:Document,registry:Registry,selection:readonly Target[],options:DuplicateOptions,binding:{sourceToken:string;schemaToken:string;checkpoint?:DuplicateCheckpoint;topologyOnly?:boolean}){
const offset=options.worldOffset??{x:32,y:32},idFactory=options.createId,suppliedMapping=options.mapping?freeze(clone(options.mapping)):undefined;
// 从检查点复制出私有可变 state,旧 checkpoint 保持不变;新任务从 selection 开始。
@@ -56,7 +59,8 @@ export function createDuplicatePlanner(document:Document,registry:Registry,selec
const supplied=suppliedMapping?[...Object.keys(suppliedMapping.elements).map(id=>({id,kind:'element' as const})),...Object.keys(suppliedMapping.canvases).map(id=>({id,kind:'canvas' as const}))]:[];
// 这些 Map 是派生输出缓存,overlay 内部可读其增长,不作为第二份发布文档。
const outputElements=new Map<Id,ElementRecord>(),outputCanvases=new Map<Id,CanvasRecord>(),output=duplicateOverlay(document,outputElements,outputCanvases),changes:Change[]=[];
// 源校验每次重开重新执行;topologyOnly 仅用于明确的结构源,最终输出仍执行完整数据校验。
// 源校验每次重开重新执行;topologyOnly 跳过源的组件 payload schema。
// 本解释器继续到 result 时校验源+副本完整输出;若 runtime 在 ready 接管逐记录生成,则由那条消费路径完成后续校验。
let treeCache=0,edgeCache=0,sourceChecks:Generator<void>|undefined=(binding.topologyOnly?validateDocumentTopologySteps:validateDocumentSteps)(document,registry),outputChecks:Generator<void>|undefined,validated=false;
/** 切换解释阶段并重置/指定该阶段 cursor,不改稳定 ID 或输出前缀。 */
const phase=(next:DuplicateCheckpoint['phase'],cursor=0)=>{state.phase=next;state.cursor=cursor;};
@@ -69,7 +73,8 @@ export function createDuplicatePlanner(document:Document,registry:Registry,selec
/** 只为尚未分配的源项取新 ID;优先宿主预留值,used 同时覆盖源、墓碑与全部新值。 */
function allocate(id:Id,kind:'elements'|'canvases'){const map=suppliedMapping?.[kind],value=map&&Object.hasOwn(map,id)?map[id]:idFactory();if(typeof value!=='string'||!value||used.has(value))throw fault('duplicate_identity',[value]);used.add(value);return value;}
return Object.freeze({
/** 源已校验且刚到 emit,范围/映射固定,可以交给运行时逐记录转换,不要求完整计划已生成。 */
/** 源检查迭代已结束、阶段刚到 emit 且 emitted=0 时可接管范围/映射;不是完整计划已生成或已加入画布。
* topologyOnly 下“源检查结束”仍只证明拓扑路径通过,不证明所有组件 payload schema。 */
ready():boolean{return !sourceChecks&&state.phase==='emit'&&state.emitted===0;},
/** 返回阶段、语义 steps 和已分配数;不冒称解析/加入/网络已完成。 */
progress(){return Object.freeze({phase:state.phase,steps:state.steps,allocated:state.elementIds.length+state.canvasIds.length});},
@@ -98,7 +103,8 @@ export function createDuplicatePlanner(document:Document,registry:Registry,selec
if(task.kind==='canvas'){if(canvases.has(task.id))break;canvases.add(task.id);state.canvases.push(task.id);state.order.push(task);state.expanding={canvas:task.id,index:(members.get(task.id)?.length??0)-1};}
else{if(copied.has(task.id))break;copied.add(task.id);state.copied.push(task.id);state.order.push(task);const child=children.get(task.id);if(child)state.work.push({kind:'canvas',id:child});}break;
}
// 扫描上级入边与范围内边;随后 element/canvas 分配都把新 ID 写回 state,恢复可重用。
// 与同步规划器相同:显式选边、复制 canvas 的边及指向复制节点的入边;只有源端被复制不足以自动带上出边。
// 随后 element/canvas 分配都把新 ID 写回 state,恢复重用已有分配,不为同一前缀再造身份。
case 'edges':if(state.cursor<sourceElements.length){const record=sourceElements[state.cursor++];if(record.structure==='connection'&&(selected.has(record.id)||canvases.has(record.ownerCanvasId)||copied.has(record.target.elementId))){state.edges.push(record.id);copied.add(record.id);state.copied.push(record.id);}}else phase('allocate-elements');break;
case 'allocate-elements':if(state.cursor<state.copied.length){const id=state.copied[state.cursor++],value=allocate(id,'elements');elementIds.set(id,value);state.elementIds.push([id,value]);}else phase('allocate-canvases');break;
case 'allocate-canvases':if(state.cursor<state.canvases.length){const id=state.canvases[state.cursor++],value=allocate(id,'canvases');canvasIds.set(id,value);state.canvasIds.push([id,value]);}else phase('mapping');break;
@@ -110,7 +116,8 @@ export function createDuplicatePlanner(document:Document,registry:Registry,selec
// 每次状态机工作/阶段切换单调计数;step 的 wall time 和 yield 时机由宿主软预算决定。
state.steps++;return false;
},
/** 仅 done 且本轮最终校验成功后返回完整冻结计划;不能把未完成缓存当成可提交计划。 */
/** 仅 done 且本轮最终校验成功后返回完整冻结计划;不能把未完成缓存当成可提交计划。
* 返回的是准备 changes,不是 editor 提交/持久化/服务端确认结果;调用 result 也不会发网络请求。 */
result():DuplicatePlan{if(!validated||state.phase!=='done')throw fault('copy_plan_not_ready');return freeze({version:1,roots:state.roots.map(id=>elementIds.get(id)!),elementIds:Object.fromEntries(elementIds),canvasIds:Object.fromEntries(canvasIds),changes:[...changes]});},
});
}
+15 -5
View File
@@ -2,6 +2,7 @@
* 完整快照上的深复制纯规划:先确定范围和稳定 ID 映射,再生成节点/canvas,最后生成边。
* 同时选分区入口和其内部节点时只复制一次;原上级 C→F 保留,新增边可连接原 C 与新 F′。
* 这里只返回计划,不改 editor、不上传图片、不启动生成;runtime 负责调度、持久源和逐批加入。
* 源须在规划期间保持不可变;资源 ID 沿 data/copy 声明处理,不在这里复制文件字节或创建资源绑定。
*/
import type { CanvasRecord, Change, Data, Document, ElementRecord, Id, Json, Point, Registry, Target } from './types.ts';
import { copySlots } from './copy-paths.ts';
@@ -20,6 +21,7 @@ export type DuplicateOptions = { readonly createId: () => Id; readonly worldOffs
export function remapCopyData(element: ElementRecord, registry: Registry, elements: ReadonlyMap<Id, Id>, canvases: ReadonlyMap<Id, Id>, document: Document): Data {
const definition = registry.components.get(element.componentId), schema = definition?.copy;
if (registry.resolve(element) !== 'known' || !schema) throw fault('copy_schema_unavailable', [element.id]);
// data 完整 JSON 复制后再改声明路径;未声明为引用的字符串(即使看起来像 ID)原样保留。
const data = clone(element.data) as Record<string, Json>;
for (const ref of schema.references) {
for (const slot of copySlots(data, ref.path)) {
@@ -37,13 +39,15 @@ export function remapCopyData(element: ElementRecord, registry: Registry, elemen
slot.parent[slot.key] = ref.many ? (value as Json[]).map(mapped) : mapped(value);
}
}
// omit 删除对象字段,拒绝删除数组下标造成稀疏数据;生成任务状态通过声明省略,不复制成新任务。
// omit 只删除声明的对象字段,数组下标不能直接删成空洞;是否清除任务状态取决于该组件 copy.omit,
// 规划器本身不识别所有插件的任务字段,也不把复制出的配置当成启动生成命令。
for (const path of schema.omit) for (const slot of copySlots(data, path)) { if (Array.isArray(slot.parent)) throw fault('invalid_copy_omit', [element.id]); delete slot.parent[slot.key]; }
return data;
}
/** Pure, complete-snapshot planning. Runtime owns time slicing, durable source/mapping and partial progress. */
/** 分步迭代器:yield 给调用者调度机会,本身不 await、不落盘;耗尽后才返回完整已校验计划。 */
/** 分步迭代器:yield 给调用者调度机会,本身不 await、不落盘;耗尽后才返回完整已校验计划。
* yield 点不是字节/耗时硬上限,单条记录的 clone、引用转换或祖先遍历仍可能较重。 */
export function* planDuplicateSteps(document: Document, registry: Registry, selection: readonly Target[], options: DuplicateOptions): Generator<void, DuplicatePlan> {
yield* validateDocumentSteps(document, registry);
const offset = options.worldOffset ?? { x: 32, y: 32 };
@@ -78,6 +82,7 @@ export function* planDuplicateSteps(document: Document, registry: Registry, sele
}
}
// 边另外扫描:显式选中、属于复制 canvas、或指向复制节点的入边均纳入,源端可仍指向原上级节点。
// 仅 source 被复制、target 在外部的出边,不因 source 在范围内就自动纳入;显式选中等其它条件仍可纳入。
const edges: ElementRecord[] = [];
for (const element of document.elements.values()) { yield; if (element.structure === 'connection' && (selected.has(element.id) || copiedCanvases.has(element.ownerCanvasId) || copied.has(element.target.elementId))) { edges.push(element); copied.add(element.id); } }
// ID 分配前先保留源元素/canvas/墓碑全部 ID;同一 used 集合还防止两种新 ID 互撞。
@@ -124,6 +129,7 @@ export function* planDuplicateSteps(document: Document, registry: Registry, sele
/** 共享确定性转换器:canvas 改 parent/entry 映射,节点改身份/owner/data 与根级偏移。
* 同步及 checkpoint planner 都使用这里,避免两套复制产品规则。 */
export function duplicateTreeChange(document:Document,registry:Registry,kind:'element'|'canvas',record:ElementRecord|CanvasRecord,elementIds:ReadonlyMap<Id,Id>,canvasIds:ReadonlyMap<Id,Id>,copiedCanvases:ReadonlySet<Id>,offset:Point):Exclude<Change,{kind:'field'}>{
// 调用方须提供已固定且覆盖范围的映射;此转换器不自行分配缺失 ID,canvas 分支依赖非空 parent、只供子 canvas 使用。
if (kind === 'canvas') {
const canvas=record as CanvasRecord,parent=canvas.parent!;
const after = { ...clone(canvas), id: canvasIds.get(canvas.id)!, parent: { ...parent, canvasId: canvasIds.get(parent.canvasId) ?? parent.canvasId, entryElementId: elementIds.get(parent.entryElementId)! } };
@@ -140,9 +146,11 @@ export function duplicateTreeChange(document:Document,registry:Registry,kind:'el
return { kind: 'element', id: after.id, before: null, after };
}
}
/** 映射边端点(外部端点沿用原 ID),按新端点算共同 owner,转换曲线后创建新 ID/incarnation=1。 */
/** 映射边端点(外部端点沿用原 ID),按新端点算共同 owner,转换曲线后创建新 ID/incarnation=1。
* 端点结构与组件定义的 copy.references 是两套路径:前者在此映射,后者交给 remapCopyData。 */
export function duplicateEdgeChange(document:Document,registry:Registry,edge:ElementRecord,elementIds:ReadonlyMap<Id,Id>,canvasIds:ReadonlyMap<Id,Id>,nodes:Document):Extract<Change,{kind:'element'}>{
if (edge.structure !== 'connection') throw fault('invalid_copy_endpoint');
// 与 convertPath 的同变换早返回不同,这里即使 owner 空间不变,也先要求复制支持该曲线格式。
if (!connectionPathValid(edge.path)) throw fault('unsupported_copy_path', [edge.id]);
const source = { ...edge.source, elementId: elementIds.get(edge.source.elementId) ?? edge.source.elementId }, target = { ...edge.target, elementId: elementIds.get(edge.target.elementId) ?? edge.target.elementId };
const owner = commonCanvas(nodes, nodes.elements.get(source.elementId)!.ownerCanvasId, nodes.elements.get(target.elementId)!.ownerCanvasId), transform = canvasTransform(nodes, owner);
@@ -155,7 +163,8 @@ export function duplicateEdgeChange(document:Document,registry:Registry,edge:Ele
const from = toWorld(canvasTransform(document, a.ownerCanvasId), { x: a.geometry.x + a.geometry.width / 2, y: a.geometry.y + a.geometry.height / 2 }), to = toWorld(canvasTransform(nodes, b.ownerCanvasId), { x: b.geometry.x + b.geometry.width / 2, y: b.geometry.y + b.geometry.height / 2 });
return { x: to.x - from.x, y: to.y - from.y };
};
// knot 按链中序号在两端位移间插值;一端仍为原节点时,不把整条线简单平移。
// knot 按序号 t=(index+1)/(count+1) 在线两端世界位移间插值,不按弧长或几何距离加权。
// 这里只移动 knot.position,in/out 等偏移向量已在前面的空间转换中处理,不再次加位移。
const from = displacement(edge.source.elementId, source.elementId), to = displacement(edge.target.elementId, target.elementId), knots = path.knots;
path = { ...path, knots: knots.map((raw, index) => { const knot = raw as Record<string, Json>, point = knot.position as { x: number; y: number }, world = toWorld(transform, point), t = (index + 1) / (knots.length + 1); return { ...knot, position: toLocal(transform, { x: world.x + from.x * (1 - t) + to.x * t, y: world.y + from.y * (1 - t) + to.y * t }) }; }) };
}
@@ -167,7 +176,8 @@ export function duplicateOverlay(document:Document,elements:ReadonlyMap<Id,Eleme
/** Internal overlay avoids an O(document size) clone between planning phases. Additions own unique IDs. */
/** 只读接口覆盖层:新增 ID 保证唯一,先查 additions,迭代则 base 后 additions。
* additions 在规划过程中仍会增长,故这个 view 只供内部计算,不是固定成员的发布快照。 */
* additions 在规划过程中仍会增长,故这个 view 只供内部计算,不是固定成员的发布快照。
* 本函数不验证 base/additions 的 ID 不相交;规划器分配时保证此约定,否则 size/迭代会出现重复。 */
function overlay<K,V>(base: ReadonlyMap<K,V>, additions: ReadonlyMap<K,V>): ReadonlyMap<K,V> {
const view: ReadonlyMap<K,V> = {
// get/has 与所有迭代器读取同一组当前表;forEach 的第三参数仍是这个只读接口。
+39 -15
View File
@@ -1,7 +1,8 @@
/**
* 纯编辑器与单 Root 发布协调器:命令先在私有 draft 上规划,再统一发布 document/history/operations/runtime。
* 不在这里执行网络或 IndexedDB;engine 通过同步准备钩子装配 ledger 等事实,调度器在发布后异步处理。
* 修改入口共用 entered 防重入和 disposed 生命周期检查;协调提案另用 rootRevision 拒绝过期计划。
* 命令/协调发布入口共用 entered 防重入和 disposed 检查;协调提案另用 rootRevision 拒绝过期计划。
* dispose/subscribe 和只读查询有各自生命周期规则,entered 是同步重入闸门,不是跨线程或网络锁。
*/
import {dataArray,dataRecord} from './data-containers.ts';
import {stageCreation,materializeCreations} from './creation-draft.ts';
@@ -29,9 +30,13 @@ export type EditorOptions<Runtime = undefined> = {
policy?: (command: Command, document: Document) => Availability;
/** 撤销/重做独立准入钩子,runtime 可据依赖、未知请求或权限阻止栈移动。 */
traversalPolicy?: (entry: HistoryEntry, direction: 'undo' | 'redo') => Availability;
/** listener 抛错后的诊断回调;诊断本身失败也不能回滚已经发布的状态。 */
onListenerError?: (error: unknown) => void;
/** 明确开放 changes.apply 给可信宿主;仍执行 policy 和发布前文档检查。 */
allowRawChanges?: boolean;
/** 恢复层预先校验的核心快照;初始化直接沿用其中 history/operations,不在此完整解码恢复 DTO。 */
recovery?: CoreSnapshot & { readonly rootRevision: number };
/** 与初始核心快照配套的宿主 runtime;其内部不可变性由宿主负责。 */
runtime?: Runtime;
/** engine 安装的同步 reducer,将核心提交变成下一份 runtime;抛错拒绝整次发布。 */
reduceRuntime?: (previous: Runtime, next: CoreSnapshot, commit: Commit) => Runtime;
@@ -58,17 +63,20 @@ export function createEditor<Runtime = undefined>(options: EditorOptions<Runtime
// 文档索引与 runtime 覆盖范围同次绑定;同一个 doc 对象的加载事实也可能更新。
const bindCoverage=(snapshot:Snapshot<Runtime>,queries:ReturnType<typeof createDocumentQueries>)=>options.queryCoverage?withQueryCoverage(queries,options.queryCoverage(snapshot)):queries;
let queries=bindCoverage(state,createDocumentQueries(document,options.registry,state.revision,options.queryBudget));
// WeakMap 按确切快照身份保存查询,既拒绝错误快照,也不永久保留已废弃文档。
// WeakMap 按 Document 对象身份保存查询,不按整个 Root 身份;同文档的 runtime-only 发布会更新这条关联。
// 旧 Document 尚有外部引用时可查旧索引;未登记的外来 Document 明确拒绝,WeakMap 不独自保活旧文档。
const queriesByDocument=new WeakMap([[document,queries]]);
// entered 覆盖准备、发布和通知,防止 handler/reducer/observer 同步重入造成半份状态。
let entered = false, disposed = false, sequence = 0;
for (const intent of state.operations.values()) sequence = Math.max(sequence, intent.sequence);
const listeners = new Set<(commit: Commit, snapshot: Snapshot<Runtime>) => void>();
// createId 可能在最终发布前被调用;失败的候选不会回收已经向宿主申请过的 ID。
const createId = options.createId || (() => globalThis.crypto.randomUUID());
const limit = options.historyLimit ?? 100;
if (!Number.isSafeInteger(limit) || limit < 1) throw fault('invalid_history_limit');
/** 命令→候选文档/changes 的纯规划;不创建历史、不发送请求,也不改当前 state。 */
/** 命令→候选文档/changes 的同步规划,不创建历史、不发送请求,也不替换当前 state。
* policy/handler 由宿主提供,须遵守无外部写入约定;本函数不能回滚插件自己的副作用。 */
function plan(document: Document, command: Command): { document: Document; changes: readonly Change[] } {
const permission = options.policy?.(command, document);
if (permission && !permission.enabled) throw fault(permission.code, permission.targets);
@@ -107,11 +115,13 @@ export function createEditor<Runtime = undefined>(options: EditorOptions<Runtime
if (!validJson(command.path)) throw fault('invalid_connection_path', [element.id]);
changes = options.registry.handlers.get(element.componentId)!.get('connection.edit')!({ element, input: freeze({ patch: { path: clone(command.path) } }) });
for (const change of changes) if (change.kind !== 'field' || change.field !== 'path' || change.target.id !== element.id || change.target.incarnation !== element.incarnation) throw fault('invalid_handler_change', [element.id]);
// 端点命令只要求 connection.edit 已安装,直接生成 source/target 变化,不调用该 handler。
} else if(command.type==='connection.endpoints'){
if(element.structure!=='connection'||!options.registry.supports(element,'connection.edit'))throw fault('unsupported_capability',[element.id]);
changes=[];
for(const [field,endpoint]of [['source',command.source],['target',command.destination]] as const)if(endpoint&&!equal(element[field],endpoint))changes=[...changes,{kind:'field',target:command.target,field,before:element[field],after:clone(endpoint)}];
if(changes.length)changes=[...changes,...repairConnectionOwners(document,applyChanges(document,changes))];
// 删除使用结构规划器收集子树/附边,不通过单一内容或几何 handler。
} else if (command.type === 'element.remove') {
changes = removalChanges(document,command.target,queriesByDocument.get(document));
// 跨 owner 移动需要同时处理归属、几何、视觉单位、入口子 canvas 与相关边。
@@ -142,7 +152,7 @@ export function createEditor<Runtime = undefined>(options: EditorOptions<Runtime
if (command.type === 'element.data') run('content.edit', command.patch);
else {
if (element.structure !== 'node' || Object.keys(command.patch).some(key => !['x', 'y', 'width', 'height', 'rotation'].includes(key))) throw fault('invalid_geometry_patch', [element.id]);
// 混合几何 patch 按 move→resize→rotate 分发,各 handler 只能处理自己的字段。
// 按输入字段拆成 move→resize→rotate;上方检查输出目标与 geometry 字段,具体几何语义仍由 handler 实现。
const parts: [Capability, Partial<Geometry>][] = [
['geometry.move', Object.fromEntries(Object.entries(command.patch).filter(([key]) => key === 'x' || key === 'y'))],
['geometry.resize', Object.fromEntries(Object.entries(command.patch).filter(([key]) => key === 'width' || key === 'height'))],
@@ -153,7 +163,7 @@ export function createEditor<Runtime = undefined>(options: EditorOptions<Runtime
changes = output;
}
}
// 创建可先放入临时 draft 容器,整批结束统一物化,减少大量创建时的重复表复制。
// 创建先放入临时 draft 容器,整批结束统一物化;其它修改沿有序 changes 应用,返回的变化另做 JSON 捕获。
const next = command.type==='element.create'||command.type==='canvas.create'?stageCreation(document,changes[0] as Extract<Change,{kind:'canvas'|'element'}>):applyChanges(document, changes);
return { document: next, changes: freeze(clone(changes)) };
}
@@ -170,7 +180,8 @@ export function createEditor<Runtime = undefined>(options: EditorOptions<Runtime
if(isThenable(runtime))throw fault('async_transaction');
return runtime===next.runtime?next:Object.freeze({...next,runtime});
}
/** 发布后通知 listener 快照;逐个隔离异常,观察者失败不能回滚已提交的 Root。 */
/** 发布后通知 listener 快照;逐个隔离异常,观察者失败不能回滚已提交的 Root。
* 本轮遍历 listener 集合的数组副本,通知期间退订不撤回副本中尚未调用的 listener。 */
function emit(commit: Commit) {
for (const listener of [...listeners]) try { listener(commit, state); } catch (error) { try { options.onListenerError?.(error); } catch { /* Observers cannot roll back a committed state. */ } }
}
@@ -199,7 +210,8 @@ export function createEditor<Runtime = undefined>(options: EditorOptions<Runtime
history = Object.freeze({ ...history, entries: readMap(entries) });
}
if (outbound) {
// 收集旧/新 owner、父 canvas 和端点 owner,形成跨图版本/claim 的完整参与者范围。
// 按变化种类收集参与者:canvas 自身及父项、元素旧/新 owner、新建边或端点变化涉及的端点 owner。
// 这是出站意图的 canvasIds 来源,不是一次对所有可能插件引用的全图扫描。
const canvasIds = new Set<Id>();
for (const change of changes) {
if (change.kind === 'canvas') {
@@ -239,6 +251,7 @@ export function createEditor<Runtime = undefined>(options: EditorOptions<Runtime
}
const next: CoreSnapshot = Object.freeze({ revision: state.revision + 1, document, history, operations: readMap(operations) });
const commit = freeze({ revision: next.revision, origin, changes, operationIds });
// reducer/prepareRuntime/查询构建都在 state 赋值前完成;拒绝只丢弃候选,不能撤回宿主钩子的外部副作用。
const runtime = options.reduceRuntime ? options.reduceRuntime(state.runtime, next, commit) : state.runtime;
if (isThenable(runtime)) throw fault('async_transaction');
// 查询更新也在赋值之前准备;订阅者不会看到新文档搭配旧索引的短暂组合。
@@ -249,14 +262,15 @@ export function createEditor<Runtime = undefined>(options: EditorOptions<Runtime
return Object.freeze({ status: 'applied', revision: state.revision, operationIds, ...(historyEntryId ? { historyEntryId } : {}) });
}
/** 同步事务:多条 dispatch 共享 draft,任一命令失败使整批拒绝;回调结束后 tx 关闭。 */
/** 同步事务:多条 dispatch 共享 draft,正常 Error 失败会阻止发布;回调结束后 tx 关闭。
* failed 是 unknown 但当前分支用真值判断;宿主 policy/handler 应抛 Error,不应依赖抛 null/false/0 的拒绝语义。 */
function transaction(callback: (transaction: Transaction) => unknown, settings: { label?: string; groupId?: Id; history?: boolean; origin?: Origin } = {}): Result {
try { assertOpen(); } catch (error) { return reject(error); }
entered = true;
let active = true, document = state.document, failed: unknown;
const changes: Change[] = [];
const tx: Transaction = Object.freeze({
/** 私有 dispatch 记住第一次失败,后续命令不再继续推进 draft。 */
/** 私有 dispatch 记录规划抛错;常规 Error 被 failed 真值分支记住,后续命令停止推进。 */
dispatch(command: Command): Availability {
if (!active) throw fault('transaction_closed');
if (failed) return { enabled: false, ...reject(failed) };
@@ -277,6 +291,7 @@ export function createEditor<Runtime = undefined>(options: EditorOptions<Runtime
// 全部命令规划结束才物化创建记录并做整图校验,允许同批先创建节点再接边。
document=materializeCreations(document);
validateDocument(document, options.registry);
// 无变化或表内容/迭代顺序与原文档相同,直接报告 applied;不分配历史/操作 ID,也不通知订阅者。
if (!changes.length || documentsEqual(document, state.document)) return Object.freeze({ status: 'applied', revision: state.revision, operationIds: Object.freeze([]) });
let history = state.history, historyEntryId: Id | undefined;
if (settings.history !== false && (!settings.origin || settings.origin === 'user')) {
@@ -292,10 +307,11 @@ export function createEditor<Runtime = undefined>(options: EditorOptions<Runtime
entries.set(historyEntryId, dataRecord(freeze({ id: historyEntryId, label: settings.label || 'Edit', changes: dataArray(freeze([...changes])), operationIds: dataArray<Id>([]), ...(settings.groupId ? { groupId: settings.groupId } : {}) })));
undo.push(historyEntryId);
}
// 容量淘汰移除最旧 entry;资源 pin 的后续扫描以更新后的 history 为准。
// 仅从当前 history 淘汰最旧 entry;未解决操作的 historyTransition 等证据仍可能 pin 住旧资源。
while (undo.length > limit) entries.delete(undo.shift()!);
history = Object.freeze({ undo: Object.freeze(undo), redo: Object.freeze([]), entries: readMap(entries) });
}
// history:false 或非 user origin 可不建历史项,但 outbound 由 publish 的 origin 规则独立决定。
return publish(document, freeze([...changes]), settings.origin || 'user', history, historyEntryId);
} catch (error) { active = false; return reject(error); }
finally { entered = false; }
@@ -304,7 +320,8 @@ export function createEditor<Runtime = undefined>(options: EditorOptions<Runtime
function dispatch(command: Command, settings?: Parameters<typeof transaction>[1]): Result {
return transaction(tx => { tx.dispatch(command); }, settings);
}
/** 只规划和校验可用性,不分配操作 ID、不发布;提交时还要重新校验当前状态。 */
/** 只规划和校验可用性,不分配操作 ID、不发布;提交时还要重新校验当前状态。
* can 只检查 disposed,不占 entered;宿主提供的 policy/handler 可能仍被调用,不应有外部写入。 */
function can(command: Command): Availability {
try { if (disposed) throw fault('editor_disposed'); const result = plan(state.document, command); validateDocument(result.document, options.registry); return { enabled: true }; }
catch (error) { const result = reject(error); return { enabled: false, code: result.code, targets: result.targets }; }
@@ -319,6 +336,7 @@ export function createEditor<Runtime = undefined>(options: EditorOptions<Runtime
const entry = state.history.entries.get(id)!;
const allowed = options.traversalPolicy?.(entry, direction);
if (allowed && !allowed.enabled) throw fault(allowed.code, allowed.targets);
// undo 反转变化顺序和值,redo 使用正向变化;私有执行先通过最终文档校验,才构造新的两栈。
const planned=direction==='undo'?invert(entry.changes):entry.changes;
const {document,changes,remap,restored}=planHistoryChanges(state.document,planned,options.registry);
const entries=new Map(state.history.entries);
@@ -326,6 +344,7 @@ export function createEditor<Runtime = undefined>(options: EditorOptions<Runtime
// Frozen operations retain their original identities and incarnations.
// 显式恢复的生命周期影响可继续遍历的历史目标;已冻结操作身份不在这里改写。
if(restored)for(const [otherId,other]of entries)if(otherId!==id)entries.set(otherId,dataRecord(freeze({...other,changes:dataArray(freeze(other.changes.map(remap)))})));
// 当前 entry 仍存“可重做的正向 changes”;undo 后把实际逆向变化再 invert,保留新恢复代次。
entries.set(id,dataRecord(freeze({...entry,changes:dataArray(freeze(direction==='undo'?invert(changes):changes))})));
const history: HistoryState = Object.freeze({ undo: Object.freeze(direction === 'undo' ? state.history.undo.slice(0, -1) : [...state.history.undo, id]), redo: Object.freeze(direction === 'redo' ? state.history.redo.slice(0, -1) : [...state.history.redo, id]), entries: readMap(entries) });
return publish(document, changes, direction, history, id);
@@ -335,7 +354,8 @@ export function createEditor<Runtime = undefined>(options: EditorOptions<Runtime
// 公开只读快照与绑定查询;指定未知 document 查查询会拒绝,不隐式混用当前索引。
return Object.freeze({ getSnapshot: () => state,getQueries:(document=state.document)=>{const value=queriesByDocument.get(document);if(!value)throw fault('query_snapshot_unavailable');return value;}, registry: options.registry, dispatch, transaction, can,
/** Coordinator entry: document, amended history and outbound fact share one Root. */
/** 异步图片尺寸解析只提交仍匹配 Root/目标的计划,并修正原创建历史,不额外造用户项。 */
/** 接收异步解码后的尺寸,入口本身同步;匹配 Root/目标后修正原创建历史,不额外造用户项。
* asset-resolution 仍可产生出站 operation,并与原创建 entry 关联,不是完全跳过 history/runtime。 */
resolveCreationSize(effect: CreationSizeEffect, expectedRootRevision: number): Result {
try { assertOpen(); } catch (error) { return reject(error); }
entered = true;
@@ -363,6 +383,7 @@ export function createEditor<Runtime = undefined>(options: EditorOptions<Runtime
if (expectedRootRevision !== state.rootRevision) throw fault('stale_plan');
const runtime = reduce(state.runtime);
if (isThenable(runtime)) throw fault('async_transaction');
// 相同 runtime 引用视为 no-op,prepareRuntime 不执行;调用方须返回新对象而非原地改旧状态。
if (runtime !== state.runtime) {
const nextState=prepareState(Object.freeze({ ...state, rootRevision: state.rootRevision + 1, runtime })),nextQueries=bindCoverage(nextState,queries);
state=nextState;queries=nextQueries;queriesByDocument.set(state.document,queries);
@@ -373,7 +394,7 @@ export function createEditor<Runtime = undefined>(options: EditorOptions<Runtime
finally { entered = false; }
},
/** Engine-only canonical proposal: validation and runtime state publish as one root. */
/** 将已校验的 ACK/恢复提案与 runtime 一起发布;旧 rootRevision 提案明确拒绝。
/** 接收 ACK/恢复提案并重新捕获校验文档,与 runtime 一起发布;旧 rootRevision 提案明确拒绝。
* 可替换 history/operations,但不允许改写原有操作身份;文档实际变化才推进 revision。 */
reconcile(document: Document, runtime: Runtime, expectedRootRevision: number, replacement?: { readonly history: HistoryState; readonly operations: ReadonlyMap<Id,OperationIntent> }): Result {
try { assertOpen(); } catch (error) { return reject(error); }
@@ -384,7 +405,8 @@ export function createEditor<Runtime = undefined>(options: EditorOptions<Runtime
const next = createDocument(document.projectId, [...document.canvases.values()], [...document.elements.values()], document.tombstones);
validateDocument(next, options.registry);
let nextSequence=sequence;
// 恢复替换必须让两栈精确覆盖 entry,entry 的 operation ID 必须存在,序列保持单调。
// 替换时要求两栈精确覆盖 entries、历史关联的 operation ID 存在,已有操作内容完全相等。
// 新操作的 sequence 在此仅检查正安全整数,再取最大值供后续分配;完整恢复校验由宿主边界完成。
if(replacement){
const {history,operations}=replacement,ids=[...history.undo,...history.redo];
if(new Set(ids).size!==ids.length||ids.length!==history.entries.size||ids.some(id=>!history.entries.has(id)))throw fault('invalid_history');
@@ -392,6 +414,7 @@ export function createEditor<Runtime = undefined>(options: EditorOptions<Runtime
for(const [id,intent]of operations){if(id!==intent.id||!Number.isSafeInteger(intent.sequence)||intent.sequence<1)throw fault('invalid_operation_sequence');nextSequence=Math.max(nextSequence,intent.sequence);}
for(const [id,entry]of history.entries)if(entry.id!==id||entry.operationIds.some(operationId=>!operations.has(operationId)))throw fault('invalid_history');
}
// 即使文档相等,reconcile 仍发布一个新 rootRevision 与 ACK 通知;仅文档变化推进 revision。
const changed = !documentsEqual(next, state.document);
const nextState=prepareState(Object.freeze({ ...state, ...(replacement?{history:replacement.history,operations:replacement.operations}:{}), document: changed ? next : state.document, runtime, revision: state.revision + Number(changed), rootRevision: state.rootRevision + 1 })),nextQueries=bindCoverage(nextState,changed?advanceDocumentQueries(queries,next,options.registry,state.revision+1):queries);
state=nextState;queries=nextQueries;queriesByDocument.set(state.document,queries);
@@ -407,7 +430,8 @@ export function createEditor<Runtime = undefined>(options: EditorOptions<Runtime
dispose() { disposed = true; listeners.clear(); },
});
}
/** 比较规范化表,先做身份/大小快判;无净变化的事务不增加 revision 或历史。 */
/** 比较表的迭代顺序与内容,先做身份/大小快判;不是忽略插入顺序的 Map 集合等价。
* transaction 用它省去重复发布,reconcile 用它决定是否推进文档 revision。 */
function documentsEqual(a: Document, b: Document): boolean {
if(a===b)return true;
if(a.canvases.size!==b.canvases.size||a.elements.size!==b.elements.size||a.tombstones.size!==b.tombstones.size)return false;
+5 -2
View File
@@ -8,7 +8,8 @@ import {detachJsonData} from './json-data.ts';
// Provenance comes from detaching JSON data, never from an external freeze or
// document envelope. This does not assert record validity or registry support.
const owned=new WeakSet<object>();
/** 已由本模块捕获的记录直接复用;外部记录重新复制冻结,再标记新记录身份。 */
/** 已由本模块捕获的记录直接复用;外部记录重新按 JSON 语义复制冻结,再标记新记录身份。
* 此处不调用组件 schema;捕获成功与记录合法是两项独立事实。 */
export function detachElementData(record:ElementRecord):ElementRecord{
if(record&&typeof record==='object'&&owned.has(record))return record;
const value=detachJsonData(record);
@@ -21,7 +22,8 @@ export const hasOwnedElementData=(record:ElementRecord)=>!!record&&typeof record
// Framework-owned validators depend only on detached immutable data. Custom
// validators remain live, including their method receiver and external context.
const builtinValidators=new WeakSet<Function>(),dataProofs=new WeakMap<object,ComponentDefinition['validate']>();
/** 标记框架内置、仅依赖输入的纯校验器;不要用它包装依赖外部可变状态的校验器。 */
/** 标记框架内置、仅依赖输入的纯校验器;不要用它包装依赖外部可变状态的校验器。
* WeakSet 只记录这项声明,不能自动证明函数无副作用或不读取闭包状态。 */
export function builtinDataValidator<T extends ComponentDefinition['validate']>(validate:T):T{builtinValidators.add(validate);return validate;}
/** 检查组件 data,只有“owned 记录 + 内置 validator”组合才允许复用成功结果。
* 失败不缓存;更换 validator 后缓存也不命中;Reflect.apply 保留定义对象作为 this。 */
@@ -29,6 +31,7 @@ export function componentDataValid(record:ElementRecord,definition:ComponentDefi
// record 身份和具体 validator 函数同时匹配,才能跳过一次已成功的 schema 检查。
const validate=definition.validate,reusable=hasOwnedElementData(record)&&builtinValidators.has(validate);
if(reusable&&dataProofs.get(record)===validate)return true;
// 校验器抛错会直接传播给上层;这里只缓存正常返回且通过的结果,不吞错或缓存失败。
const valid=Reflect.apply(validate,definition,[record.data]);
if(valid&&reusable)dataProofs.set(record,validate);
return valid;
+20 -6
View File
@@ -1,6 +1,7 @@
/**
* 内置 feature 与 component 装配。Handler 是纯规划器:读取目标与 patch,只返回字段变化。
* 权限、事务与发布仍由 editor 负责;UI 不能绕过 editor 直接调用 handler 来写文档。
* capability 表示安装了哪类行为,schema 与各 handler 的实际检查仍须分别阅读。
*/
import {builtinDataValidator} from './element-data.ts';
import {rectangleGeometry,bezierChainSegments,type BezierChain} from './geometry.ts';
@@ -15,12 +16,14 @@ const geometryHandler = (allowed: readonly (keyof Geometry)[], constrain?: (geom
if (element.structure !== 'node') throw fault('unsupported_capability', [element.id]);
const patch = input.patch as Partial<Geometry>;
if (!patch || Object.values(patch).some(value => typeof value !== 'number' || !Number.isFinite(value)) || Object.keys(patch).some(key => !allowed.includes(key as keyof Geometry))) throw fault('invalid_geometry_patch', [element.id]);
// constrain 在字段形状检查后执行;此处不归一 rotation 或单独证明最终宽高为正,交给文档几何校验。
const after = constrain ? constrain({ ...element.geometry, ...patch }) : { ...element.geometry, ...patch };
return equal(element.geometry, after) ? [] : [{ kind: 'field', target: { id: element.id, incarnation: element.incarnation }, field: 'geometry', before: element.geometry, after }];
};
/** 移动仅允许 x/y,不能顺带修改大小、旋转或 owner。 */
export const movable = () => defineFeature('geometry.move', { 'geometry.move': geometryHandler(['x', 'y']) });
/** 捕获 min/max/aspect 配置,构造只修改 width/height 的缩放行为。 */
/** 浅捕获 min/max/aspect 选项,检查显式 min/max,再构造只修改 width/height 的缩放行为。
* 缺省 min=1、max=100000;aspect 默认不启用,内置图片也复用这份默认缩放 feature。 */
export const resizable = (inputOptions: { min?: number; max?: number; aspect?: boolean } = {}) => {
const options = Object.freeze({ ...inputOptions });
if (options.min !== undefined && (!Number.isFinite(options.min) || options.min <= 0) || options.max !== undefined && (!Number.isFinite(options.max) || options.max < (options.min ?? 1))) throw fault('invalid_resize_constraint');
@@ -32,6 +35,7 @@ export const resizable = (inputOptions: { min?: number; max?: number; aspect?: b
return geometryHandler(['width', 'height'], geometry => {
// owner 变换后 unitScale 也会改变,因此 min/max 要乘它才能维持同一视觉尺寸约束。
const units=unitScale(element.data.presentation),min=(options.min??1)*units,max=(options.max??100000)*units;
// 先逐维 clamp,再按原 width/height 比例重算另一维;同时给宽高且 aspect=true 时以宽度为主。
let width = Math.min(max, Math.max(min, geometry.width)), height = Math.min(max, Math.max(min, geometry.height));
// 宽度优先;只给高度时反算宽度。保持原比例后越界则拒绝,不分别截断而破坏比例。
if (options.aspect) {
@@ -44,13 +48,16 @@ export const resizable = (inputOptions: { min?: number; max?: number; aspect?: b
},
});
};
/** 旋转只产生 rotation 修改;[0,360) 等最终范围约束由几何校验处理。 */
/** 旋转 handler 只接受 rotation patch,但输出的是整份 geometry 字段变化。
* 不把 360/-1 自动归一到 [0,360),最终几何校验负责拒绝越界结果。 */
export const rotatable = () => defineFeature('geometry.rotate', { 'geometry.rotate': geometryHandler(['rotation']) });
/** 内容编辑按顶层 data 键生成独立条件变化,保存存在性,区分缺失字段与 null。 */
export const editable = () => defineFeature('content.edit', {
'content.edit': ({ element, input }) => {
const patch = input.patch as Data;
if (!patch || !validJson(patch) || Array.isArray(patch)) throw fault('invalid_data_patch', [element.id]);
// 签名要求 Data 对象;上面的运行时 guard 检查 JSON 值域并排除数组,不是任意未知输入的完整对象 schema。
// patch 中出现的键都表示写入,null 是显式值;此 handler 没有以 undefined 表示删除的约定。
// 每个字段携带 before,ACK/重放时才能检查冲突,而不是用整份节点覆盖后来的编辑。
return Object.entries(patch).flatMap(([key, after]): FieldChange[] => {
const before = element.data[key] ?? null;
@@ -58,7 +65,8 @@ export const editable = () => defineFeature('content.edit', {
});
},
});
/** 内置节点的基础 data 合同:title/text 为字符串,允许其它合法 JSON 扩展字段。 */
/** 内置节点共用的基础 data 合同:title/text 为字符串,检查已识别 unitScale,允许其它合法 JSON 扩展。
* 图片 URL/资源身份、图标名或任务字段并不由此函数逐项验证;它也不代表所有表现字段都有专门 schema。 */
const textData = builtinDataValidator((data: Data) => presentationUnitsValid(data.presentation) && validJson(data) && typeof data.title === 'string' && typeof data.text === 'string');
/** 安装文本、图片、脚本、图标、frame 和 connection 的默认定义。
* 节点共用基础行为;frame 不安装旋转行为。复制声明与资源字段也是定义的一部分。 */
@@ -67,18 +75,23 @@ export function builtinComponents(): readonly ComponentDefinition[] {
id, dataVersion: 1, structure: 'node', validate: textData,
features: [movable(), resizable(), ...(id === 'frame' ? [] : [rotatable()]), editable()],
resourceFields: ['image', 'plain-image'].includes(id) ? ['assetId','asset'] : [],
// frame 的 ports 引用必须随复制映射;任务/run/progress 不复制成新的执行任务。
// frame ports[].nodeId 是显式 element 引用,复制范围外引用按 reject;局部 port id/handle 不是这里的映射目标。
// 只 omit 下列四个顶层任务状态字段,不递归猜测任意插件任务结构;资源字段按声明另供 runtime 扫描。
geometry:rectangleGeometry,copy: { references: id === 'frame' ? [{path:['ports','*','nodeId'],target:'element',external:'reject'}] : [], omit: [['taskId'], ['runId'], ['progress'], ['runStatus']] },
// 后续 connection 使用单独定义:strokeWidth 取 appearance.width 的真值或 1.5,并不在这里验证其类型/正数范围。
// segments 要求 displayPath 已提供且可解释,再调用共享 Bezier 生成器;输出有效性由几何查询边界继续检查。
})).concat([defineComponent({ id: 'connection', dataVersion: 1, structure: 'connection', validate: builtinDataValidator((data:Data) => validJson(data)), features: [editable(), connectionEditable()], resourceFields: [],geometry:{kind:'connection',version:1,strokeWidth:({element})=>{const appearance=element.data.appearance;return (appearance&&typeof appearance==='object'&&!Array.isArray(appearance)?appearance.width:undefined) as number||1.5;},segments:context=>{if(context.displayPath===undefined||!connectionPathValid(context.displayPath))throw fault('unsupported_connection_geometry');return bezierChainSegments(context.source,context.target,context.displayPath as unknown as BezierChain|null);}}, copy: { references: [], omit: [] } })]);
}
/** null 表示默认连线;否则仅支持最多 64 个 knot 的 bezier-chain,并校验控制点/唯一 ID。 */
/** null 表示默认连线;否则仅支持最多 64 个 knot 的 bezier-chain,并校验控制点/唯一 ID。
* 这里只检查所需路径字段,不拒绝所有扩展键;corner/smooth/symmetric 是模式标签,不在此验证控制柄相切或镜像关系。 */
export function connectionPathValid(path: Json): boolean {
if (path === null) return true;
if (typeof path !== 'object' || Array.isArray(path) || path.type !== 'bezier-chain' || !Array.isArray(path.knots) || path.knots.length > 64) return false;
// 控制点只能是有限 x/y;缺少坐标、数组或非对象值均不接受。
const point = (value: Json | undefined) => value && typeof value === 'object' && !Array.isArray(value) && typeof value.x === 'number' && typeof value.y === 'number' && Number.isFinite(value.x) && Number.isFinite(value.y);
if (path.startControl !== undefined && !point(path.startControl) || path.endControl !== undefined && !point(path.endControl)) return false;
// 每个 knot 的 ID 非空且本路径内唯一;位置和 in/out 都必须是有限点,空 knots 数组也合法。
const ids = new Set<string>();
for (const knot of path.knots) {
if (!knot || typeof knot !== 'object' || Array.isArray(knot) || typeof knot.id !== 'string' || !knot.id || ids.has(knot.id) || !['corner', 'smooth', 'symmetric'].includes(knot.mode as string) || !point(knot.position) || !point(knot.in) || !point(knot.out)) return false;
@@ -86,7 +99,8 @@ export function connectionPathValid(path: Json): boolean {
}
return true;
}
/** 连线编辑 handler:旧 path 也必须可解释,拒绝用当前协议重写未知曲线路径。 */
/** 连线编辑 handler:旧 path 也必须可解释,拒绝用当前协议重写未知曲线路径。
* feature ID 为 connection.path,但注册 capability 键是 connection.edit;editor 按后者查 handler。 */
export const connectionEditable = () => defineFeature('connection.path', { 'connection.edit': ({ element, input }) => {
const path = (input.patch as { path?: Json }).path;
if (element.structure !== 'connection' || !connectionPathValid(element.path)) throw fault('unsupported_connection_path', [element.id]);
+7 -3
View File
@@ -1,6 +1,7 @@
/**
* 与渲染框架无关的基本几何协议。所有计算输入必须在同一坐标空间;
* bounds 负责粗筛,hit 负责矩形精判,曲线 segments 由模型命中和 SVG 绘制共享。
* 函数不主动校验输入几何/容差/控制点,也不换算 viewport 或 presentation.unitScale,调用方先统一空间。
*/
import type {Geometry,Point} from './types.ts';
/** 轴对齐包围盒,用于空间索引粗筛;旋转节点的盒会大于其实际轮廓。 */
@@ -14,12 +15,15 @@ export type BezierChain={readonly knots:readonly{readonly position:Point;readonl
/** 绕节点中心旋转后的 AABB:宽=|cos|·w+|sin|·h,高=|sin|·w+|cos|·h。 */
export function rectangleBounds(g:Geometry):Rect{const angle=g.rotation*Math.PI/180,c=Math.abs(Math.cos(angle)),s=Math.abs(Math.sin(angle)),width=g.width*c+g.height*s,height=g.width*s+g.height*c;return{x:g.x+g.width/2-width/2,y:g.y+g.height/2-height/2,width,height};}
/** 将点反向旋转到矩形自身坐标,计算到矩形的距离,再与容差比较。
* 角外区域使用欧氏距离而非扩展 AABB;浮点误差余量随坐标尺度调整。 */
* 角外区域使用欧氏距离而非扩展 AABB;浮点误差余量随坐标尺度调整。
* 点在矩形内部时距离为 0;tolerance 由调用方保证非负,正常边界含在命中内。 */
export function hitRectangle(g:Geometry,point:Point,tolerance=0):boolean{const angle=-g.rotation*Math.PI/180,c=Math.cos(angle),s=Math.sin(angle),dx=point.x-g.x-g.width/2,dy=point.y-g.y-g.height/2,x=dx*c-dy*s,y=dx*s+dy*c,ox=Math.max(0,Math.abs(x)-g.width/2),oy=Math.max(0,Math.abs(y)-g.height/2);return Math.hypot(ox,oy)<=tolerance+Number.EPSILON*64*Math.max(1,Math.abs(point.x),Math.abs(point.y),g.width,g.height);}
/** 默认矩形节点协议;空 dataDependencies 表示形状仅依赖 geometry,不因 title 等数据变化重建。 */
export const rectangleGeometry=Object.freeze({kind:'node' as const,version:1 as const,dataDependencies:Object.freeze([] as string[]),bounds:({geometry}:{geometry:Geometry})=>rectangleBounds(geometry),hit:({geometry,point,tolerance}:{geometry:Geometry;point:Point;tolerance:number})=>hitRectangle(geometry,point,tolerance)});
/** Shared by the built-in model protocol and the SVG renderer; all arguments are in one display space. */
/** 由锚点链生成每段三次曲线:上一 knot.out 和下一 knot.in 分别作为控制偏移。
* 无显式首尾控制柄时,按端点距离的 0.4 倍选择长度并限制在 40–240。
* add 把相对偏移变成绝对控制点;map 对每对相邻 anchors 输出一段。 */
* 无显式首尾控制柄时,reach 按两端直线距离的 0.4 倍限制在 40–240,再乘相应法向量。
* 本函数不归一 normal;只有单位法向量才使 reach 等于控制柄的实际长度。
* add 把相对偏移变成绝对控制点;map 对每对相邻 anchors 输出一段,共 knots.length+1 段。
* 无 knot 时直接生成端点间一段;这里不根据 smooth/symmetric 标签自动修正控制柄。 */
export function bezierChainSegments(from:Connector,to:Connector,path?:BezierChain|null):Cubic[]{const reach=Math.min(240,Math.max(40,Math.hypot(to.point.x-from.point.x,to.point.y-from.point.y)*.4)),knots=path?.knots??[],anchors=[from.point,...knots.map(knot=>knot.position),to.point],add=(a:Point,b:Point)=>({x:a.x+b.x,y:a.y+b.y});return anchors.slice(0,-1).map((point,index)=>[point,add(point,index?knots[index-1].out:path?.startControl??{x:from.normal.x*reach,y:from.normal.y*reach}),add(anchors[index+1],index<knots.length?knots[index].in:path?.endControl??{x:to.normal.x*reach,y:to.normal.y*reach}),anchors[index+1]]);}
+5 -1
View File
@@ -12,8 +12,10 @@ type PlacementStep={canvasId:Id;parentCanvasId:Id;entryId:Id;incarnation:number;
/** A short-lived reader of the visible owner chain relative to one view, not the project root.
* Cache by owner only during this admission/publication, so multi-selection shares ancestry work. */
/** 给定不可变文档/view/registry,返回短生命周期 owner→入口链读取器。
* Map 只在一次准入或发布比较中复用,多选共享祖先扫描,不能跨文档更新继续使用。 */
* Map 只在一次准入或发布比较中复用,多选共享祖先扫描,不能跨文档更新继续使用。
* 返回数组/步骤没有运行时 freeze,geometry 也沿用记录引用;readonly 是调用约定,不应修改缓存结果。 */
export function createGesturePlacementReader(document:Document,viewCanvasId:Id,registry:Registry){
// 链按 owner→view 方向排列,不包含 view 自身在更上级的入口;读同一 owner 会返回同一个数组。
const values=new Map<Id,readonly PlacementStep[]>();
/** 向 view 回溯目标拥有链,缺失/循环/隐藏入口明确拒绝;成功后才缓存完整链。 */
return(ownerCanvasId:Id):readonly PlacementStep[]=>{
@@ -24,6 +26,7 @@ export function createGesturePlacementReader(document:Document,viewCanvasId:Id,r
while(id!==viewCanvasId){
if(seen.has(id))throw fault('canvas_cycle',[id]);seen.add(id);
const parent=document.canvases.get(id)?.parent;
// owner 缺失或已回溯到无父根,统一视为不在此 view;仅 view 本身缺失在上方报 canvas_missing。
if(!parent)throw fault('target_outside_view',[ownerCanvasId]);
const entry=document.elements.get(parent.entryElementId);
// A future/unknown entry is a collapsed placeholder; never interpret its payload.
@@ -31,6 +34,7 @@ export function createGesturePlacementReader(document:Document,viewCanvasId:Id,r
// 未知/未来入口已在上方拒绝;已知入口仍可能折叠,折叠子内容不允许开始手势。
const raw=entry.data.presentation,presentation=raw&&typeof raw==='object'&&!Array.isArray(raw)?raw:{};
if(presentation.collapsed===true)throw fault('target_hidden',[ownerCanvasId]);
// 签名只读这些坐标字段,不校验整个 presentation;contentScale 这里仅检查 number 且 >0。
const contentScale=typeof presentation.contentScale==='number'&&presentation.contentScale>0?presentation.contentScale:1;
chain.push({canvasId:id,parentCanvasId:parent.canvasId,entryId:entry.id,incarnation:entry.incarnation,componentId:entry.componentId,dataVersion:entry.dataVersion,geometry:entry.geometry,contentScale,unitScale:unitScale(raw)});
id=parent.canvasId;
+35 -17
View File
@@ -1,7 +1,9 @@
/**
* 每个 editor 的临时交互会话:多个 view 各自保存选择/视口/工具/mode,共用一份已提交文档。
* 同时至多存在一个节点或连线手势;baseline/preview 均是短生命周期状态,不写 history/ledger。
* pointerup 时才通过 editor 提交净修改;取消仅清预览,不回写旧整节点,因此不会撤掉晚到资源 ACK。
* 一个 manager 的临时交互会话:多个 view 各自保存选择/视口/工具/mode,共用 editor 已提交文档。
* 单个 manager 内至多一个节点或连线手势;同一 editor 若另建 manager,并不共享这项互斥。
* baseline/preview 均是短生命周期状态,不写 history/ledger。
* 输入绑定通常在 pointerup 调用 commit;本模块不接 DOM 事件,只有显式 commit 才发 editor 命令。
* 取消仅清预览,不回写旧整节点,因此不会撤掉晚到资源 ACK;session listener 仍由本模块同步通知。
*/
import type { Editor } from './editor.ts';
import type { Availability, Capability, ElementRecord, Geometry, Id, Json, Result, Target } from './types.ts';
@@ -24,10 +26,11 @@ export function createSessions(editor: Editor) {
let snapshot: SessionSnapshot = Object.freeze({ views: readMap(new Map<Id, ViewState>()), gesture: null, connectionGesture: null, lastCancelReason: null });
// 开始手势时捕获原记录,用它计算净几何差;提交时仍以 editor 当前数据为准,不恢复整条记录。
let baseline = new Map<Id, ElementRecord>(), connectionBaseline: ElementRecord | undefined, disposed = false, nextGestureToken = 0;
// Compare adjacent published identities without duplicating the per-view mode table.
// 相邻 editor 发布的文档身份用于 mode/坐标签名比较;runtime-only 发布仍会重新检查 editor.can 的当前准入。
let publishedDocument=editor.getSnapshot().document;
const listeners = new Set<() => void>();
/** 对监听列表做副本迭代并隔离异常,renderer 出错不能影响已提交模型。 */
/** 对监听列表做副本迭代并隔离异常,renderer 出错不能影响已提交模型。
* 会话本身没有 entered 重入闸门;监听器不应同步递归制造新会话操作。 */
const notify = () => { for (const listener of [...listeners]) try { listener(); } catch { /* A renderer cannot invalidate model commits. */ } };
/** 一次发布 views;若被中断的是手势拥有者,同时清 baseline 和预览,只通知一次。 */
function publishViews(views:Map<Id,ViewState>,cancelOwnedView?:Id,reason='view_interrupted'){
@@ -37,7 +40,8 @@ export function createSessions(editor: Editor) {
}
/** 复制视图表并替换一项,可在同一发布中带上切工具/切 canvas 等取消原因。 */
function setView(view: ViewState,cancelOwnedReason?:string) { const views = new Map(snapshot.views); views.set(view.id, view); publishViews(views,cancelOwnedReason?view.id:undefined,cancelOwnedReason); }
/** 幂等取消当前手势,保留 committed 文档及资源状态;重复 cancel 不重复通知。 */
/** 幂等取消本 manager 当前手势,保留 committed 文档及资源状态;重复 cancel 不重复通知。
* cancel 不接 viewId,属于 manager 级清理入口,不执行单 view 所有权校验。 */
function cancel(reason = 'canceled') {
if (!snapshot.gesture && !snapshot.connectionGesture) return;
baseline.clear(); connectionBaseline = undefined; snapshot = Object.freeze({ ...snapshot, gesture: null, connectionGesture: null, lastCancelReason: reason }); notify();
@@ -49,6 +53,7 @@ export function createSessions(editor: Editor) {
for (const [id, view] of views) {
// 当前 canvas 被删除时回到根;失效 selection 清理,模式还需检查相邻发布的元素身份。
const canvasMissing = !document.canvases.has(view.canvasId);
// selection 只保留仍存在的 ID,不按 incarnation/owner/可见性重新筛;mode 才比较相邻发布的完整身份字段。
const selection = [...view.selection].filter(target => document.elements.has(target)), modes = new Map([...view.modes].filter(([target]) => {
const before=beforeDocument.elements.get(target),after=document.elements.get(target);
return !!before&&!!after&&before.incarnation===after.incarnation&&before.ownerCanvasId===after.ownerCanvasId&&before.structure===after.structure&&before.componentId===after.componentId&&before.dataVersion===after.dataVersion;
@@ -64,7 +69,8 @@ export function createSessions(editor: Editor) {
const oldPlacement=activeView?createGesturePlacementReader(beforeDocument,activeView.canvasId,editor.registry):undefined,newPlacement=activeView?createGesturePlacementReader(document,activeView.canvasId,editor.registry):undefined;
/** 同文档无需重算;旧/新链不再可读或值变化都按坐标上下文失效处理。 */
const placementChanged=(owner:Id)=>{if(beforeDocument===document)return false;try{return !oldPlacement||!newPlacement||!equal(oldPlacement(owner),newPlacement(owner));}catch{return true;}};
// 连线预览只依赖 path、身份、owner 和准入;普通内容或资源 ACK 不应自动取消它。
// 连线预览检查 path、身份、owner、坐标祖先链和当前准入;普通内容/资源 ACK 本身不直接取消,
// 但若该发布使 policy 不再允许 connection.path,后面的 editor.can 仍会取消。
if (!cancelReason&&snapshot.connectionGesture?.phase === 'active') {
const current = document.elements.get(snapshot.connectionGesture.target.id), before = connectionBaseline;
if (!current || current.structure !== 'connection' || before?.structure !== 'connection' || current.incarnation !== before.incarnation || current.ownerCanvasId !== before.ownerCanvasId || current.componentId!==before.componentId || current.dataVersion!==before.dataVersion || !equal(current.path, before.path)) cancelReason='target_changed';
@@ -100,7 +106,8 @@ export function createSessions(editor: Editor) {
['geometry.resize', Object.fromEntries(Object.entries(patch).filter(([key]) => key === 'width' || key === 'height'))],
['geometry.rotate', Object.fromEntries(Object.entries(patch).filter(([key]) => key === 'rotation'))],
];
// move/resize/rotate 依次读取上一步预览结果;handler 只能给同目标生成合法 geometry。
// update 不以空 patch 做整命令 policy 预检,而是逐项检查实际使用的 capability、handler 输出目标与几何。
// move/resize/rotate 依次读取上一步预览结果;真正 commit 仍会通过 editor 再查 policy。
for (const [capability, value] of parts) if (Object.keys(value).length) {
if (!editor.registry.supports(element, capability)) throw fault('unsupported_capability', [target.id]);
const changes = editor.registry.handlers.get(element.componentId)!.get(capability)!({ element, input: freeze({ patch: value }) });
@@ -112,6 +119,7 @@ export function createSessions(editor: Editor) {
}
}
// 更新 preview 不触发 editor 命令,所以 pointermove 不制造 history 或出站请求。
// 即使 patch 结果相同,本路径仍替换会话快照并 notify,不像 tool/mode 有相同值早返回。
const preview = new Map(snapshot.gesture.preview); preview.set(target.id, (element as ElementRecord & { structure: 'node' }).geometry);
snapshot = Object.freeze({ ...snapshot, gesture: freeze({ ...snapshot.gesture, preview: readMap(preview) }) }); notify();
return { enabled: true };
@@ -120,36 +128,42 @@ export function createSessions(editor: Editor) {
return Object.freeze({
/** 外部订阅读取当前只读会话,不能直接修改内部 Map/Set。 */
getSnapshot: () => snapshot,
/** 创建独立 view,返回带 attached 标记的幂等卸载函数;只释放这个 view 与其手势。 */
/** 创建独立 view,返回内部带 attached 布尔标记的幂等卸载闭包;只释放这个 view 与其手势。
* 已 dispose 或重复 view ID 都报 duplicate_view;卸载不关闭 editor,也不停止 runtime 持久任务。 */
attach(id: Id, canvasId = [...editor.getSnapshot().document.canvases.values()].find(canvas => !canvas.parent)!.id) {
if (disposed || snapshot.views.has(id)) throw fault('duplicate_view', [id]);
if (!editor.getSnapshot().document.canvases.has(canvasId)) throw fault('canvas_missing', [canvasId]);
setView(freeze({ id, canvasId, selection: readSet<Id>([]), viewport: { x: 0, y: 0, zoom: 1 }, tool: 'select', modes: readMap(new Map<Id, string>()) }));
let attached=true;return () => { if(!attached)return;attached=false;if(!snapshot.views.has(id))return;const views=new Map(snapshot.views);views.delete(id);publishViews(views,id,'view_detached'); };
},
/** 切换查看的 canvas,清 selection/modes,并取消属于该 view 的旧坐标手势。 */
/** 设置查看的 canvas,清 selection/modes,并取消属于该 view 的旧坐标手势。
* 即使传相同 canvasId 也走清理/通知;它不是相同值 no-op 的入口。 */
canvas(id: Id, canvasId: Id) {
const current = view(id);
if (!editor.getSnapshot().document.canvases.has(canvasId)) throw fault('canvas_missing', [canvasId]);
setView(freeze({ ...current, canvasId, selection: readSet<Id>([]), modes: readMap(new Map<Id, string>()) }),'canvas_changed');
},
/** 捕获有效元素 ID 为只读选择集合;这是会话选择,不是文档命令。 */
/** 捕获存在的元素 ID 为去重选择集合;不要求属于此 view、可见或已安装组件。
* selection 不携带 incarnation,也不是授权证据;begin/提交另行核对目标与准入。 */
select(id: Id, ids: readonly Id[]) {
const current = view(id), document = editor.getSnapshot().document;
for (const target of ids) if (!document.elements.has(target)) throw fault('element_missing', [target]);
setView(freeze({ ...current, selection: readSet(ids) }));
},
/** 保存有限 pan 和正 zoom;改变观察视口不修改元素几何或 history。 */
/** 保存有限 pan 和正 zoom;不修改元素几何/history,也不自动取消当前手势。
* 屏幕输入如何随 pan/zoom 换算由输入绑定负责,此处不保存 DOM 指针坐标。 */
viewport(id: Id, viewport: ViewState['viewport']) {
if (![viewport.x, viewport.y, viewport.zoom].every(Number.isFinite) || viewport.zoom <= 0) throw fault('invalid_viewport');
setView(freeze({ ...view(id), viewport: { ...viewport } }));
},
/** select/pan 切换会取消本 view 的手势;重复设置相同工具不发通知。 */
tool(id: Id, tool: ViewState['tool']) { const current=view(id);if(tool!=='select'&&tool!=='pan')throw fault('invalid_tool');if(current.tool===tool)return;setView(freeze({ ...current, tool }),'tool_changed'); },
/** 保存某元素的临时编辑模式;空值移除,不创建持久的业务字段。 */
/** 保存某元素的临时模式字符串;null/空串移除,非空值要求元素存在。
* 不在此枚举模式名或检查 capability;mode 不是可编辑权限,也不是持久业务字段。 */
mode(id: Id, target: Id, mode: string | null) { const current = view(id);if(mode!==null&&typeof mode!=='string')throw fault('invalid_mode');if(mode&&!editor.getSnapshot().document.elements.has(target))throw fault('element_missing',[target]);if((current.modes.get(target)??null)===(mode||null))return;const modes = new Map(current.modes); if (mode) modes.set(target, mode); else modes.delete(target); setView(freeze({ ...current, modes: readMap(modes) })); },
/** 节点手势准入:select 工具、无其它手势、目标去重/版本支持、可见拥有链及 policy 全部通过。
* 先把整组选区检查完再替换 baseline,拒绝不会留下半组手势。 */
* 先把整组目标检查完再替换 baseline,拒绝不会留下半组手势;targets 不必等于 view.selection。
* 空 geometry patch 的 editor.can 不调用 move/resize/rotate handler,实际能力仍在 update/commit 检查。 */
begin(viewId: Id, targets: readonly Target[]): Availability {
try {
if(view(viewId).tool!=='select')throw fault('tool_not_select');
@@ -188,7 +202,8 @@ export function createSessions(editor: Editor) {
return { enabled: true };
} catch (error) { const result = reject(error); return { enabled: false, code: result.code, targets: result.targets }; }
},
/** 连线结束只 dispatch 一次;先标 committing,结束后无论成功/拒绝都清预览。 */
/** 连线结束只 dispatch 一次;先标 committing,使 editor 发布监听暂不按 active 取消自己的提交。
* 正常提交和 catch 都调用 manager 级 cancel;错误 view 的提交调用也可能取消当前另一 view 的手势。 */
commitConnection(viewId: Id): Result {
try {
view(viewId);
@@ -200,7 +215,9 @@ export function createSessions(editor: Editor) {
} catch (error) { cancel('commit_failed'); return reject(error); }
},
update,
/** 节点手势提交一个同步事务,把各目标相对 baseline 的净字段变化合成一个 Transform 历史项。 */
/** 节点手势提交一个同步事务,仅遍历 preview 中的目标,合并相对 baseline 的净字段变化。
* 有实际文档变化时通常形成一个 Transform 历史项;空预览/无净变化遵守 editor 的 no-op 语义。
* 与连线提交相同,异常路径会取消本 manager 当前手势,而不只清理传入的 view。 */
commit(viewId: Id): Result {
try {
view(viewId);
@@ -221,7 +238,8 @@ export function createSessions(editor: Editor) {
} catch (error) { cancel('commit_failed'); return reject(error); }
},
cancel,
/** 渲染时优先读取预览,否则读取最新 committed 几何;取消后自然显露最新模型。 */
/** 按元素 ID 优先读取本 manager 预览,否则读取最新 committed 几何;取消后自然显露最新模型。
* 无 viewId 参数,不过滤调用者视图;投影/renderer 负责决定这个元素是否应在该 view 显示。 */
effectiveGeometry(id: Id): Geometry | undefined {
const element = editor.getSnapshot().document.elements.get(id);
return snapshot.gesture?.preview.get(id) || (element?.structure === 'node' ? element.geometry : undefined);
+11 -6
View File
@@ -18,11 +18,12 @@ export type HistoryTransition={readonly version:1;readonly rootRevision:number;r
// only its flattened changes, never registry-dependent references or callbacks.
const transitionChanges=new WeakMap<HistoryTransition,readonly Change[]>();
const immutableData=new WeakSet<object>();
/** 检查可安全跨持久切点复用的深不可变普通数据,最多 64 层。
/** 检查缓存候选的冻结数据描述符与普通原型,最多递归 64 层;不是单独的 JSON/schema 校验器。
* 冻结的 getter 仍可能返回不同值,稀疏数组也不是完整区间,因此都不允许缓存。 */
function immutablePlainData(value:unknown,depth=0):boolean{
if(depth>64)return false;
if(value===null||typeof value!=='object')return typeof value!=='function';
// 只记住已遍历成功的对象身份;祖先链若太深仍会先被 depth 条件拒绝。
if(immutableData.has(value))return true;
if(!Object.isFrozen(value)||Object.getPrototypeOf(value)!==(Array.isArray(value)?Array.prototype:Object.prototype))return false;
if(Array.isArray(value))for(let i=0;i<value.length;i++)if(!Object.hasOwn(value,i))return false;
@@ -44,7 +45,8 @@ function splice<T>(before:readonly T[],after:readonly T[]):Splice<T>{
}
/** Store changed ranges only. Clearing redo/eviction still retains the removed entry records needed to reverse that transition. */
/** 对比两份 history,记录新增、删除和修改的 entry 及两栈顺序。
* 默认无变化不造记录;recordNoop 用于调用方显式需要记录发布边界的情形。 */
* 默认无变化不造记录;recordNoop 用于调用方显式需要记录发布边界的情形。
* 来源应是不可变 history:两栈和区间内的 Change 沿用引用,最终 freeze 不等于任意外部输入的深复制。 */
export function describeHistoryTransition(before:HistoryState,after:HistoryState,rootRevision:number,entryId?:Id,recordNoop=false):HistoryTransition|undefined{
const entries:EntryPatch[]=[];
for(const id of new Set([...before.entries.keys(),...after.entries.keys()])){const a=before.entries.get(id),b=after.entries.get(id);if(equal(a,b))continue;entries.push({id,before:meta(a),after:meta(b),changes:splice(a?.changes??[],b?.changes??[]),operationIds:splice(a?.operationIds??[],b?.operationIds??[])});}
@@ -52,9 +54,10 @@ export function describeHistoryTransition(before:HistoryState,after:HistoryState
return freeze({version:1,rootRevision,...(entryId?{entryId}:{}),before:{undo:before.undo,redo:before.redo},after:{undo:after.undo,redo:after.redo},entries});
}
/** 验证外部/恢复的增量 DTO,未知 version 明确要求其它解释器。
* checkChanges 可由调用方注入更严格的 Change 校验,本函数本身不依赖 registry。 */
* checkChanges 可由调用方注入更严格的 Change 校验;未传时只检查 DTO/区间形状,不解释每条 Change 的领域语义。
* rootRevision 只在此检查非负整数,不在此证明 transition 属于哪次实际发布。 */
export function validateHistoryTransition(value:unknown,checkChanges?:(value:unknown)=>unknown):asserts value is HistoryTransition{
// 四个局部校验器统一抛领域错误,分别约束普通记录、非负整数、单 ID 和 ID 数组。
// 四个局部校验器统一抛领域错误,分别约束非数组记录形状、非负整数、非空 ID 和 ID 数组。
const object=(v:unknown):Record<string,unknown>=>{if(!v||typeof v!=='object'||Array.isArray(v))throw fault('invalid_history_transition');return v as Record<string,unknown>;};
const integer=(v:unknown)=>{if(typeof v!=='number'||!Number.isSafeInteger(v)||v<0)throw fault('invalid_history_transition');};
const id=(v:unknown)=>{if(typeof v!=='string'||!v)throw fault('invalid_history_transition');};
@@ -72,8 +75,9 @@ export function validateHistoryTransition(value:unknown,checkChanges?:(value:unk
}
}
/** Conditional metadata-only replay. A mismatch leaves the supplied history untouched. */
/** 先检查栈和每个区间仍匹配预期,再在私有副本上正向/反向回放元数据。
* 任一冲突抛出而不修改输入 history;不能拿旧 journal 覆盖新的历史顺序。 */
/** 先检查两栈,再逐 entry 检查元数据/数组区间并在私有副本上正向或反向回放。
* 不改写输入两栈/entries 表;不能拿旧 journal 覆盖新的历史顺序。
* history/transition 应已捕获:补丁内 Change 会沿用并冻结,不应把此接口当任意外部值的深隔离入口。 */
export function applyHistoryTransition(history:HistoryState,transition:HistoryTransition,direction:'forward'|'reverse'):HistoryState{
validateHistoryTransition(transition);const reverse=direction==='reverse',source=reverse?transition.after:transition.before,destination=reverse?transition.before:transition.after;
if(!equal({undo:history.undo,redo:history.redo},source))throw fault('history_transition_conflict');
@@ -91,6 +95,7 @@ export function applyHistoryTransition(history:HistoryState,transition:HistoryTr
export function historyTransitionChanges(transition:HistoryTransition):readonly Change[]{
const cached=transitionChanges.get(transition);if(cached)return cached;
validateHistoryTransition(transition);
// 仅收集被记录的变化区间两侧,未重复保存的共同前后缀不在本结果中;它不是完整 history 的展开。
const changes=transition.entries.flatMap(patch=>[...patch.changes.before,...patch.changes.after]);
// 属性来自原型或 getter 时不可把一次结果当永久事实,避免错误释放资源 pin。
const ownRanges=Object.hasOwn(transition,'entries')&&transition.entries.every(patch=>Object.hasOwn(patch,'changes')&&Object.hasOwn(patch.changes,'before')&&Object.hasOwn(patch.changes,'after'));

Some files were not shown because too many files have changed in this diff Show More