62 Commits

Author SHA1 Message Date
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
216 changed files with 4707 additions and 422 deletions
+2
View File
@@ -1,2 +1,4 @@
# Next 服务端访问 Foundry 的源站;本地代理从这里发起后端 HTTP 请求,不是浏览器页面地址。
FOUNDRY_API_ORIGIN=https://api.foundry.entroia.com FOUNDRY_API_ORIGIN=https://api.foundry.entroia.com
# 公开的项目路由标识,可进入浏览器构建;不是密码、会话令牌或授权凭证。
NEXT_PUBLIC_FOUNDRY_PROJECT=endoleap NEXT_PUBLIC_FOUNDRY_PROJECT=endoleap
+7
View File
@@ -1,8 +1,15 @@
# 安装依赖不入库;依赖版本由包清单与 lock 文件记录。
node_modules/ node_modules/
# Next 开发/生产构建缓存与产物,不作为源代码提交。
.next/ .next/
# 独立 headless ESM 包由 scripts/build-headless.mjs 重建。
.headless-dist/ .headless-dist/
# 忽略本机环境变量文件;下一条只放行可公开的示例模板。
.env* .env*
!.env.example !.env.example
# TypeScript 增量检查缓存,由工具重新生成。
*.tsbuildinfo *.tsbuildinfo
# macOS Finder 的目录元数据。
.DS_Store .DS_Store
# 仓库根 docs 是本地研究/草案目录,当前约定不跟踪其中内容。
/docs/ /docs/
+70
View File
@@ -142,3 +142,73 @@ Core `subgraphs.member_positions` 能力启用时,最终落点(源画布局
上传图片时立即在画布显示本地预览,并在后台上传原始文件;上传成功才把稳定附件引用加入快照。服务端预览加载完成前继续显示本地图片,避免网络等待期间空白。失败保留文件供重试/取消,重试沿用同一幂等键;本地 blob URL 不保存到服务端。 上传图片时立即在画布显示本地预览,并在后台上传原始文件;上传成功才把稳定附件引用加入快照。服务端预览加载完成前继续显示本地图片,避免网络等待期间空白。失败保留文件供重试/取消,重试沿用同一幂等键;本地 blob URL 不保存到服务端。
上传中的图片也是正常本地节点,可立即选中、拖动、缩放、改名、删除和撤销;上传完成只补齐附件引用,不重置节点。上传前改变分区归属会先在本地完成,文件准备好后保存到当时的实际所属画布。临时上传标记和 blob 地址由传输层排除,不发送给 Core;文件仍未可用时,依赖服务端文件的 AI 生成需等待上传完成。 上传中的图片也是正常本地节点,可立即选中、拖动、缩放、改名、删除和撤销;上传完成只补齐附件引用,不重置节点。上传前改变分区归属会先在本地完成,文件准备好后保存到当时的实际所属画布。临时上传标记和 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. /** 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 * 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. * 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 {projectCanvas,createRendererCatalog,builtinRendererDefinitions,createGeometryProjectionCache,hitProjectedCanvas} from '@endoleap/headless-canvas/bindings';
import { createFoundryPersistence, loadFoundryModel } from '@endoleap/headless-canvas/service'; import { createFoundryPersistence, loadFoundryModel } from '@endoleap/headless-canvas/service';
// 示例固定账户/工作区/项目作用域;真实宿主应从已验证身份和项目上下文建立隔离范围。
const scope={accountId:'10000000-0000-4000-8000-000000000001',workspaceId:'10000000-0000-4000-8000-000000000002',projectId:'10000000-0000-4000-8000-000000000003'}; const 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:[]}})]); 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}],[]); const original=createDocument(scope.projectId,[{id:scope.projectId,parent:null}],[]);
let image=null,leader,fence=0,server=original,version=1; let image=null,leader,fence=0,server=original,version=1;
// files保存示例字节,requests按requestId保存原body和回执,供相同请求重试查询;均在进程结束后丢失。
const files=new Map(),requests=new Map(); const files=new Map(),requests=new Map();
/** 通过对象身份及active校验本地leader句柄;仅演示单进程fence,不是数据库条件写。 */
const check=handle=>{assert.strictEqual(handle,leader);assert.equal(handle.active,true);}; const check=handle=>{assert.strictEqual(handle,leader);assert.equal(handle.active,true);};
// StoragePort示例:claim取得独占句柄,recover读克隆镜像,commit先准备镜像/文件再一次同步发布。
const storage={ 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;}, 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);}, async recover(handle){check(handle);return structuredClone(image);},
/** 先检查文件新增不覆盖和删除列表,准备完成才发布;返回实际镜像rootRevision作为本地确认。 */
async commit(handle,batch){ async commit(handle,batch){
check(handle);const cut=structuredClone(batch.image),nextFiles=new Map(files); check(handle);const cut=structuredClone(batch.image),nextFiles=new Map(files);
for(const file of batch.files??[]){assert.equal(nextFiles.has(file.id),false);nextFiles.set(file.id,file.bytes);} for(const 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. // 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}; 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);}, 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:[]}); 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=>{ const transport=async input=>{
input.signal?.throwIfAborted(); input.signal?.throwIfAborted();
// 请求身份查询没命中时返回unknown,不伪造确定未应用回执;本示例记录不跨进程保存。
if(input.method==='GET'){ if(input.method==='GET'){
if(input.path.endsWith('/canvas/model'))return{status:200,body:{model:model()}}; if(input.path.endsWith('/canvas/model'))return{status:200,body:{model:model()}};
const id=input.path.split('/').at(-1),known=requests.get(id); 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); const body=JSON.parse(input.body),known=requests.get(input.requestId);
assert.equal(body.protocol_version,2);assert.equal(body.request_id,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};} if(known){assert.equal(known.body,input.body);return{status:200,body:known.receipt};}
// 示例只含一个canvas版本,先要求版本一致,再应用变更并验证整个文档;每次接受使版本递增一次。
assert.deepEqual(body.expected_versions,{[scope.projectId]:version}); assert.deepEqual(body.expected_versions,{[scope.projectId]:version});
server=applyChanges(server,body.operations.flatMap(operation=>operation.changes));validateDocument(server,registry);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}}; 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}; 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}); 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}); const open=()=>openProjectEngine({scope,registry,document:original,versions:new Map([[scope.projectId,1]]),storage,persistence,createId:randomUUID});
let engine=await open(); let engine=await open();
try{ try{
// 先创建一个counter并settle,后续断言分别验证本地发布及模拟server状态;元素带独立incarnation。
const target={id:randomUUID(),incarnation:1}; 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}}; 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(); assert.equal(engine.editor.dispatch({type:'element.create',element}).status,'applied');await engine.settle();
// 非法事务先移动再写负count,最终必须整体rejected且根对象不变,不能泄露事务中间结果。
const before=engine.getSnapshot(); const before=engine.getSnapshot();
assert.equal(engine.editor.transaction(tx=>{tx.dispatch({type:'element.geometry',target,patch:{x:32}});tx.dispatch({type:'element.data',target,patch:{count:-1}});}).status,'rejected');assert.strictEqual(engine.getSnapshot(),before); assert.equal(engine.editor.transaction(tx=>{tx.dispatch({type:'element.geometry',target,patch:{x:32}});tx.dispatch({type:'element.data',target,patch:{count:-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.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.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); 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; 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); 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 copyId=await engine.imports.duplicate([target],{worldOffset:{x:24,y:24}});await engine.settle();assert.equal(engine.imports.getJobs().get(copyId).status,'completed',JSON.stringify(engine.imports.getJobs().get(copyId)));assert.equal(engine.getSnapshot().document.elements.size,2);assert.equal(server.elements.size,2);
const loaded=await loadFoundryModel(transport,scope.workspaceId,scope.projectId,registry);assert.equal(loaded.status,'ready');assert.equal(loaded.document.elements.size,2); const 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(); 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')); 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 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 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)); 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})); 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();} }finally{await engine.close();storage.close();}
+9
View File
@@ -1,6 +1,12 @@
/**
* Next应用配置。为webpack与Turbopack安装相同ReactFlow编译扩展,并给带内容摘要的图标分片设置长期缓存。
* 此处只配置应用编译/响应头,不改变headless SDK导出或服务端数据协议。
*/
import type { NextConfig } from "next"; import type { NextConfig } from "next";
/** StrictMode显式开启开发检查,poweredByHeader关闭Next标识响应头;其余保持框架默认行为。 */
const config: NextConfig = { const config: NextConfig = {
reactStrictMode: true, poweredByHeader: false, reactStrictMode: true, poweredByHeader: false,
/** 在现有规则后追加匹配ReactFlow ESM入口的loader,再返回原配置对象,保留框架其它规则。 */
webpack(config) { webpack(config) {
config.module.rules.push({ config.module.rules.push({
test: /[\\/]@xyflow[\\/]react[\\/]dist[\\/]esm[\\/]index\.(?:mjs|js)$/, test: /[\\/]@xyflow[\\/]react[\\/]dist[\\/]esm[\\/]index\.(?:mjs|js)$/,
@@ -8,6 +14,7 @@ const config: NextConfig = {
}); });
return config; return config;
}, },
// Turbopack使用条件路径匹配同一个入口;单独配置以免切换构建器时丢失保留节点扩展。
turbopack: { turbopack: {
rules: { rules: {
"*": { "*": {
@@ -16,6 +23,8 @@ const config: NextConfig = {
}, },
}, },
}, },
/** 只给版本目录内带12位十六进制摘要的JSON分片配置一年immutable缓存,文件内容变化应生成新文件名。
* 不对图标目录manifest、页面或全部JSON一概设置immutable。 */
async headers() { async headers() {
return [{ return [{
source: "/canvas-icons/:version/:asset([a-z0-9-]+-[a-f0-9]{12}\\.json)", 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": {} } }; 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 { execFileSync } from 'node:child_process';
import { cp, readdir, readFile, rm, writeFile } from 'node:fs/promises'; import { cp, readdir, readFile, rm, writeFile } from 'node:fs/promises';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
import { join, resolve } from 'node:path'; import { join, resolve } from 'node:path';
// 路径基于脚本URL而非当前终端目录;tsc仍显式以仓库根作为cwd解析项目依赖。
const root=resolve(fileURLToPath(new URL('..',import.meta.url))),output=join(root,'.headless-dist'); const root=resolve(fileURLToPath(new URL('..',import.meta.url))),output=join(root,'.headless-dist');
await rm(output,{recursive:true,force:true}); 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'}); 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){ async function declarations(directory){
for(const entry of await readdir(directory,{withFileTypes:true})){ for(const entry of await readdir(directory,{withFileTypes:true})){
const path=join(directory,entry.name); const path=join(directory,entry.name);
@@ -19,12 +27,15 @@ async function declarations(directory){
} }
} }
} }
// 原生编译器处理运行时import后缀,声明文件仍需此补充;完成后再修URL字面量里的Worker源后缀。
await declarations(join(output,'modules')); await declarations(join(output,'modules'));
// 两个浏览器适配器各应含一个预期Worker URL;要求目标.js文件存在并且源字符串只出现一次才改写。
for(const [module,worker]of [['browser-digests','digest.worker'],['browser-images','image.worker']]){ 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`; 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}`); if(source.split(before).length!==2)throw Error(`Unexpected Worker URL in ${module}`);
await readFile(join(output,'modules/canvas-runtime',`${worker}.js`)); await readFile(join(output,'modules/canvas-runtime',`${worker}.js`));
await writeFile(path,source.replace(before,`./${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)); 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'); 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 = { export const chinese = {
"Star": "星星", "Star": "星星",
"Heart": "爱心", "Heart": "爱心",
@@ -45,4 +49,5 @@ export const chinese = {
"Triangle": "三角形", "Triangle": "三角形",
"Diamond": "菱形" "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: "灯泡 灵感" }; 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 // 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 // tarball: node scripts/generate-canvas-icons.mjs /path/to/tabler/package
import { readFile, writeFile, mkdir } from "node:fs/promises"; import { readFile, writeFile, mkdir } from "node:fs/promises";
@@ -6,13 +10,16 @@ import { resolve } from "node:path";
import dynamicIconImports from "lucide-react/dynamicIconImports"; import dynamicIconImports from "lucide-react/dynamicIconImports";
import { chinese, keywords } from "./canvas-icon-labels.mjs"; import { chinese, keywords } from "./canvas-icon-labels.mjs";
// 命令行参数是已解压Tabler包目录;同时校验两套依赖版本,防止升级后未经审查改变SVG/导出结构。
const tablerDir = process.argv[2]; const tablerDir = process.argv[2];
if (!tablerDir) throw new Error("Pass the extracted @tabler/icons 3.50.0 directory"); 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")); 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"); 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")); 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"); 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; }; 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 = [ const categories = [
["arrows", /arrow|chevron|navigation|corner|undo|redo|move|expand|shrink/], ["arrows", /arrow|chevron|navigation|corner|undo|redo|move|expand|shrink/],
["people", /user|person|baby|smile|frown|angry|laugh|heart|hand|thumb|accessibility/], ["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/], ["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/], ["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"]); 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(); const entries = new Map();
for (const [slug, load] of Object.entries(dynamicIconImports)) { for (const [slug, load] of Object.entries(dynamicIconImports)) {
const name = pascal(slug); const name = pascal(slug);
const { __iconData } = await load(); const { __iconData } = await load();
entries.set(name, { name, slug, source: "lucide", label: chinese[name] || slug.replaceAll("-", " "), node: __iconData.node }); 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 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 = ["外星人", "飞碟", "机器人", "大脑", "电路", "立体球", "立体六边形", "灯塔", "猫", "狗", "鱼", "叶子", "太阳", "月亮", "火箭", "灯泡", "爱心", "星星", "照片", "电影", "音乐", "调色板", "咖啡", "山峰"]; const tablerLabels = ["外星人", "飞碟", "机器人", "大脑", "电路", "立体球", "立体六边形", "灯塔", "猫", "狗", "鱼", "叶子", "太阳", "月亮", "火箭", "灯泡", "爱心", "星星", "照片", "电影", "音乐", "调色板", "咖啡", "山峰"];
for (const [index, slug] of tabler.entries()) { for (const [index, slug] of tabler.entries()) {
const svg = await readFile(resolve(tablerDir, "icons/outline", slug + ".svg"), "utf8"); 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([ 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]), ["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); const name = "Tabler" + pascal(slug);
entries.set(name, { name, slug, source: "tabler", label: tablerLabels[index], node }); 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 records = [...entries.values()].sort((a, b) => a.name.localeCompare(b.name, "en"));
const shards = new Map(); 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; }; 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 catalog = records.map(({ node, ...entry }) => {
const key = entry.source === "tabler" ? "tabler" : `lucide-${bucket(entry.name)}`; const key = entry.source === "tabler" ? "tabler" : `lucide-${bucket(entry.name)}`;
if (!shards.has(key)) shards.set(key, {}); if (!shards.has(key)) shards.set(key, {});
shards.get(key)[entry.name] = node; 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(" ") }; 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"; const assetDir = "public/canvas-icons/lucide-1.69.0-tabler-3.50.0";
await mkdir(assetDir, { recursive: true }); await mkdir(assetDir, { recursive: true });
let totalBytes = 0; let totalBytes = 0;
const assets = {}; const assets = {};
// 每个分片用紧凑JSON计算SHA-256前12位作文件名,写文件额外加换行;totalBytes统计未加换行的内容字节。
for (const [key, nodes] of shards) { for (const [key, nodes] of shards) {
const content = JSON.stringify(nodes); const content = JSON.stringify(nodes);
const hash = createHash("sha256").update(content).digest("hex").slice(0, 12); 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"); await writeFile(`${assetDir}/${filename}`, content + "\n");
totalBytes += Buffer.byteLength(content); 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/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("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}/LUCIDE-LICENSE.txt`, await readFile("node_modules/lucide-react/LICENSE"));
await writeFile(`${assetDir}/TABLER-LICENSE.txt`, await readFile(resolve(tablerDir, "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`); 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 // 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. // transforms compiler input; shared node_modules and the headless SDK are untouched.
// 读取安装包版本,固定12.12.1;库升级必须人工审查目标实现,不能凭相同函数名继续替换。
const version = require('@xyflow/react/package.json').version; const version = require('@xyflow/react/package.json').version;
/** webpack/Turbopack loader接收源码并返回改写后的源码;任何版本或锚点不符立即失败,防止悄悄失去保留能力。 */
module.exports = function retainVisibility(source) { module.exports = function retainVisibility(source) {
if (version !== '12.12.1') throw new Error('Review xyflow retained visibility before upgrading @xyflow/react'); 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 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 edges = 'isEdgeVisible({\n sourceNode,';
const selection = 'const changeSelector = (s) => !!s.onSelectionChangeHandlers;'; const selection = 'const changeSelector = (s) => !!s.onSelectionChangeHandlers;';
const close = 'transform: s.transform,\n })) {'; 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) { 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'); throw new Error('Unsupported xyflow visibility implementation; retained editors must not silently lose virtualization');
} }
// 节点追加未hidden且显式保留的ID并Set去重;边以保留标记或原可见判断二选一,维持原生索引。
// 最后一处改为空数组不订阅完整选择集合,减少无选择监听时的不必要全图selector工作。
return source return source
.replace(nodes, `Array.from(new Set([ .replace(nodes, `Array.from(new Set([
...${nodes}, ...${nodes},
+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 { 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) { --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; } .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), .handMode :global(.react-flow__node) { cursor: grab; }
.handMode :global(.react-flow__pane.dragging) { cursor: grabbing; } .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; } .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; } .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; } .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, .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:focus, .plainText:focus { outline: none; box-shadow: none; border-color: transparent !important; }
.cardText::placeholder, .plainText::placeholder { color: #b2a99e; } .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; } .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[aria-pressed="true"] { background: #f1e6df; color: #78523f; }
.toolbar button:disabled { cursor: not-allowed; } .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; } .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 { 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; } .toolbar .zoomValue:hover { background: #e9e7e3; }
/* 底部主工具栏相对画布居中;帮助、错误等浮层各自定位和设层级。 */
.bottomToolbar { position: absolute; left: 50%; bottom: 12px; transform: translateX(-50%); z-index: 10; } .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 { 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 strong { color: #49372e; font-size: 13px; }
.helpPopup p { margin-top: 9px; } .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; } .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; } .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 { 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 { border: 1px solid #ded7ce; border-radius: 5px; padding: 6px 9px; background: white; cursor: pointer; }
.migrationPanel button:disabled { cursor: default; opacity: .5; } .migrationPanel button:disabled { cursor: default; opacity: .5; }
/* 旧/迁移画布表面铺满宿主,具体渲染内容由桥接组件决定。 */
.migrationSurface { position: absolute; inset: 0; } .migrationSurface { position: absolute; inset: 0; }
/* 窄屏把右上工具条下移避让标题,底部工具条改左对齐并横向滚动,预留右侧帮助空间。 */
@media (max-width: 700px) { @media (max-width: 700px) {
.topRight { top: 62px; right: 12px; } .topRight { top: 62px; right: 12px; }
.bottomToolbar { left: 12px; bottom: 12px; transform: none; max-width: calc(100vw - 92px); overflow-x: auto; overscroll-behavior-x: contain; } .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; } .handMode .node textarea, .handMode .node img { pointer-events: none; }
.topRight .toolbar { max-width: calc(100vw - 24px); overflow-x: auto; } .topRight .toolbar { max-width: calc(100vw - 24px); overflow-x: auto; }
/* 隐藏旧 handle 且禁用命中,新的中心点交互在后续规则控制。 */
.node :global(.react-flow__handle).legacyHandle { opacity: 0; pointer-events: none; } .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; } .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 { padding: 0; border: 1px solid transparent; border-radius: 2px; background: transparent; box-shadow: none; }
.plainFrame.selectedFrame { border-color: #a47764; } .plainFrame.selectedFrame { border-color: #a47764; }
.plainText { padding: 4px; } .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; } .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; } .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:global(.connectionindicator) { pointer-events: auto; }
.node :global(.react-flow__handle).centerHandle:hover { border-color: #a47764; background: #f1e6df; } .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; } .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; } .selectedFrame .cornerResize { opacity: 1; scale: 1; pointer-events: auto; }
.rotatingFrame .cornerResize { opacity: 0; pointer-events: none; } .rotatingFrame .cornerResize { opacity: 0; pointer-events: none; }
/* 旋转时由 frame 接收手势,文本和中心连接点让出指针。 */
.rotatingFrame { cursor: grabbing; touch-action: none; } .rotatingFrame { cursor: grabbing; touch-action: none; }
.rotatingFrame textarea, .node.rotatingFrame :global(.react-flow__handle).centerHandle { pointer-events: 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; } .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 { 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 { 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 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 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; } .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 { 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 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; } .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 { 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 { 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 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 { 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; } .branchMenu button:hover { background: #f1e6df; }
/* 节点图标填满内容区域,SVG 自身不截获指针,事件交给外层节点。 */
.iconContent { width: 100%; height: 100%; padding: 6px; border-radius: inherit; } .iconContent { width: 100%; height: 100%; padding: 6px; border-radius: inherit; }
.iconContent svg { pointer-events: none; } .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 { 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 { 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 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; } .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 { 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 { display: grid; place-items: center; aspect-ratio: 1; border-radius: 6px; color: #6d6054; }
.iconGrid button:hover, .iconPicker header button:hover { background: #f1e6df; } .iconGrid button:hover, .iconPicker header button:hover { background: #f1e6df; }
.iconPicker footer { padding-top: 12px; color: #92877c; font-size: 10px; } .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 { 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; } .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 { 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 { padding: 3px 6px; border-radius: 4px; color: #92877c; font-size: 10px; }
.iconCategories button[aria-pressed="true"] { background: #f1e6df; color: #78523f; } .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 { 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 { 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:hover { background-color: #f1e6df; }
.virtualIconGrid button:focus-visible, .iconCategories button:focus-visible { outline: 2px solid #a47764; outline-offset: -2px; } .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; } .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; } .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; } .rotatingFrame .membershipBadge { display: none; }
.frameMemberCount { font-size: 10px; color: #92877c; white-space: nowrap; } .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; } .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 { 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; } .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; } .frameModeCheck { display: grid; place-items: center; width: 15px; flex: 0 0 15px; }
.frameModeMenu strong { display: block; font-size: 12px; font-weight: 500; } .frameModeMenu strong { display: block; font-size: 12px; font-weight: 500; }
.frameModeMenu small { display: block; margin-top: 2px; color: #92877c; font-size: 10px; } .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.frameDropTarget { border-color: #538366; background: #5383660c; box-shadow: 0 0 0 3px #53836638; }
.frameNode.frameDropExit { border-style: dashed; } .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; } .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 { 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; } .membershipNotice:empty { display: none; }
/* 手形模式统一关闭文本、缩放、中心点和样式工具条的编辑入口。 */
.handMode .plainText { pointer-events: none; } .handMode .plainText { pointer-events: none; }
.handMode .rotationHint, .handMode .centerHandle { visibility: hidden; } .handMode .rotationHint, .handMode .centerHandle { visibility: hidden; }
.handMode .cornerResize { pointer-events: none; opacity: 0.6; } .handMode .cornerResize { pointer-events: none; opacity: 0.6; }
.handMode .styleToolbar { display: none; } .handMode .styleToolbar { display: none; }
/* 第一组减少动态效果规则关闭 frame、角缩放和中心点的过渡;后面还有分段滑块/连线动画的关闭规则。 */
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
.transformFrame, .cornerResize, .node :global(.react-flow__handle).centerHandle { transition: none; } .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 { border: 1px solid #c9b7a5; border-radius: 8px; background: transparent; pointer-events: none; }
.frameNode.selectedFrame { border-color: #a47764; box-shadow: 0 0 0 2px #a477641a; } .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; } .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; } .frameBorderHit { position: absolute; pointer-events: auto; cursor: grab; }
.frameBorder_top { top: -5px; left: 0; right: 0; height: 10px; } .frameBorder_top { top: -5px; left: 0; right: 0; height: 10px; }
.frameBorder_bottom { bottom: -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_left { left: -5px; top: 0; bottom: 0; width: 10px; }
.frameBorder_right { right: -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; } .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; } .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 { 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%; } .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 { 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::backdrop { background: #30251e33; }
.recoveryDialog h2 { font-size: 16px; margin-bottom: 12px; } .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 > div { display: flex; flex-wrap: wrap; gap: 10px; margin-top: 20px; }
.recoveryDialog button { padding: 8px 12px; border: 1px solid #cbb8a8; border-radius: 5px; } .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 { 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; } .textEditor textarea[readonly] { pointer-events: none; }
/* 透明选择层盖住内容区域,选中后给 grab 光标;手形模式取消该层命中。 */
.textSelectLayer { position: absolute; inset: 0; border-radius: inherit; cursor: default; } .textSelectLayer { position: absolute; inset: 0; border-radius: inherit; cursor: default; }
.handMode .textSelectLayer { pointer-events: none; } .handMode .textSelectLayer { pointer-events: none; }
.selectedFrame .textSelectLayer { cursor: grab; } .selectedFrame .textSelectLayer { cursor: grab; }
.textSelectLayer:active { cursor: grabbing; } .textSelectLayer:active { cursor: grabbing; }
/* 纯图片没有卡片 padding,图片仅显示不接受选择/拖动,外层控制变换。 */
.plainImageFrame { padding: 0; } .plainImageFrame { padding: 0; }
.plainImage { width: 100%; height: 100%; display: block; object-fit: contain; pointer-events: none; user-select: none; } .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; } .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; } .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 { 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 { 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; } .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; } .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; } .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; } .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 { 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 label { display: flex; align-items: center; gap: 4px; font-size: 10px; }
.edgeToolbar input[type="number"] { width: 50px; } .edgeToolbar input[type="number"] { width: 50px; }
@@ -156,17 +207,24 @@
.edgeToolbar button:hover { background: #f1e6df; } .edgeToolbar button:hover { background: #f1e6df; }
.edgeToolbar button:disabled { opacity: .4; } .edgeToolbar button:disabled { opacity: .4; }
.edgeToolbar small { width: 100%; color: #92877c; font-size: 10px; } .edgeToolbar small { width: 100%; color: #92877c; font-size: 10px; }
/* 曲线控制点用 pointer-events:all 开放 SVG 命中,触摸默认行为关闭,拖动逻辑由事件处理器负责。 */
.curveControl { cursor: move; touch-action: none; pointer-events: all; } .curveControl { cursor: move; touch-action: none; pointer-events: all; }
/* 连线效果分别用 dashoffset 流动和 opacity 脉冲;是否附加动画类由执行状态/配置决定。 */
.edgeDashAnimation { animation: edgeDash 1.6s linear infinite; } .edgeDashAnimation { animation: edgeDash 1.6s linear infinite; }
.edgePulseAnimation { animation: edgePulse 1.6s ease-in-out infinite; } .edgePulseAnimation { animation: edgePulse 1.6s ease-in-out infinite; }
/* 虚线一个周期移动 40 单位;脉冲从低透明到不透明再返回。 */
@keyframes edgeDash { to { stroke-dashoffset: -40; } } @keyframes edgeDash { to { stroke-dashoffset: -40; } }
@keyframes edgePulse { 0%, 100% { opacity: .1; } 50% { opacity: 1; } } @keyframes edgePulse { 0%, 100% { opacity: .1; } 50% { opacity: 1; } }
/* 第二组减少动态效果规则关闭滑块过渡和两种连线动画。 */
@media (prefers-reduced-motion: reduce) { .frameLabelSlider { transition: none; } .edgeDashAnimation, .edgePulseAnimation { animation: none; } } @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; } .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 { display: flex; align-items: center; gap: 2px; font-size: 11px; }
.subgraphScale input { width: 45px; } .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 { 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; } .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 { 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 { display: flex; align-items: center; gap: 6px; font-size: 12px; margin-bottom: 9px; }
.aiPanel header span { margin-left: auto; color: #92877c; max-width: 160px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 10px; } .aiPanel 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 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 { width: 32px; height: 32px; flex: 0 0 32px; display: grid; place-items: center; border-radius: 8px; background: #78523f; color: white; }
.aiPanel footer button:disabled { opacity: .35; } .aiPanel footer button:disabled { opacity: .35; }
/* 普通提示与 role=alert 错误采用不同颜色;冲突候选区域独立滚动并保留预格式文本换行。 */
.aiPanel p { margin: 7px 0 0; color: #92877c; font-size: 10px; } .aiPanel p { margin: 7px 0 0; color: #92877c; font-size: 10px; }
.aiPanel p[role="alert"] { color: #a25439; } .aiPanel p[role="alert"] { color: #a25439; }
.aiConflict { max-height: 180px; overflow: auto; padding: 8px; border-radius: 7px; background: #f4eee7; font-size: 11px; } .aiConflict { max-height: 180px; overflow: auto; padding: 8px; border-radius: 7px; background: #f4eee7; font-size: 11px; }
.aiConflict pre { white-space: pre-wrap; } .aiConflict pre { white-space: pre-wrap; }
.aiConflict img { max-height: 100px; } .aiConflict img { max-height: 100px; }
.aiConflict button { padding: 6px; color: #78523f; } .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; } .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; } .selectedFrame :global(.react-flow__handle).centerHandle { opacity: 1; }
.transformFrame :global(.react-flow__handle).centerHandle:global(.connectionindicator) { pointer-events: auto; } .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 { width: 8px; height: 8px; border: 1px solid #fffefb; background: #a47764; pointer-events: auto; }
.framePort { opacity: 0; pointer-events: none; } .framePort { opacity: 0; pointer-events: none; }
.selectedFrame .framePort { opacity: 1; } .selectedFrame .framePort { opacity: 1; }
.framePort:global(.connectionindicator) { pointer-events: auto; } .framePort:global(.connectionindicator) { pointer-events: auto; }
.rotatingFrame :global(.react-flow__handle).centerHandle { pointer-events: none; } .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; } .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; } .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 { 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; } .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 { 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 summary { cursor: pointer; padding: 5px 0; }
.importPanel ul { padding-left: 16px; overflow-wrap: anywhere; } .importPanel ul { padding-left: 16px; overflow-wrap: anywhere; }
.importPanel p { margin: 5px 0; } .importPanel p { margin: 5px 0; }
.importPanel small { color: #92877c; } .importPanel small { color: #92877c; }
.importJob { border-top: 1px solid #eee7de; padding-top: 5px; margin-top: 5px; } .importJob { border-top: 1px solid #eee7de; padding-top: 5px; margin-top: 5px; }
/* 导入动作横排,其余任务按钮使用文字色与局部留白,任务状态由 runtime/组件决定。 */
.importActions { display: flex; align-items: center; gap: 8px; } .importActions { display: flex; align-items: center; gap: 8px; }
.importActions > button:not(:first-child), .importJob button { color: #78523f; padding: 4px 0; } .importActions > button:not(:first-child), .importJob button { color: #78523f; padding: 4px 0; }
+84
View File
@@ -1,3 +1,9 @@
/**
* 旧版主画布的界面装配与交互控制。useCanvasTree拥有旧快照、writer、历史和子图事务,组件把其投影交给ReactFlow。
* 本地state管理工具、模式、菜单与提示,refs保存拖动/连线起点及文件选择上下文;真实上传与演示Data URL分别处理。
* 节点/连线组件通过Context调用适配动作,不直接修改投影;子图归属、端口连线和解散优先使用服务端能力与事务。
* 迁移门闩暂停旧编辑和创作入口,给迁移协调组件提供检查、保存排空与备份;本文件不自行切换到新版engine。
*/
"use client"; "use client";
import { useImageUploads } from "./use-image-uploads"; import { useImageUploads } from "./use-image-uploads";
import {LegacyCanvasMigration} from "./legacy-canvas-migration"; import {LegacyCanvasMigration} from "./legacy-canvas-migration";
@@ -41,15 +47,21 @@ import type { NodeTransform } from "@/lib/canvas-geometry";
import { MaterialNode, NodeEditing } from "./material-node"; import { MaterialNode, NodeEditing } from "./material-node";
import styles from "./canvas.module.css"; import styles from "./canvas.module.css";
/** 旧边统一使用可编辑贝塞尔组件,节点按旧type名称分派;类型映射本身不持有元素实例数据。 */
const edgeTypes = { default: CanvasEdge }; const edgeTypes = { default: CanvasEdge };
/** 图标面板延迟在客户端加载,避免首屏加载目录面板及其交互代码;加载中提供可访问状态。 */
const IconPicker = dynamic(() => import("./icon-picker").then((module) => module.IconPicker), { ssr: false, loading: () => <div role="status" className={styles.iconLoading}>正在加载图标面板…</div> }); const 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 }; 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 }) { function CanvasEditor({ board, snapshot, migration }: { board: Board; snapshot?: CanvasSnapshot; migration?:LegacyMigrationContext }) {
const flow = useReactFlow<CanvasNode>(); const flow = useReactFlow<CanvasNode>();
const { zoom } = useViewport(); const { zoom } = useViewport();
const reduceMotion = useReducedMotion(); const reduceMotion = useReducedMotion();
const storageKey = canvasStorageKey(board.workspace_id, board.id); const storageKey = canvasStorageKey(board.workspace_id, board.id);
// 服务端快照优先,另有不同本地内容则等待恢复选择;无快照可直接用草稿。
// 视口独立读本地键并限制范围,损坏视口不阻断文档;草稿解码失败回退快照/空文档并提示。
const [initial] = useState(() => { const [initial] = useState(() => {
try { try {
const local = readLocalRecovery(localStorage, storageKey, snapshot); 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: "无法读取此浏览器的图谱草稿,可通过导入恢复备份。" }; } } catch { return { document: snapshot?.document || emptyCanvas(), recovery: null, error: "无法读取此浏览器的图谱草稿,可通过导入恢复备份。" }; }
}); });
// 工具与变换模式是界面状态,不放入文档;draggedIds仅驱动成员预览,membershipNotice短暂展示结果。
const [tool, setTool] = useState<"select" | "hand">("select"); const [tool, setTool] = useState<"select" | "hand">("select");
const [error, setError] = useState(initial.error); const [error, setError] = useState(initial.error);
const feedback = useContext(FeedbackContext); const feedback = useContext(FeedbackContext);
@@ -72,26 +85,35 @@ function CanvasEditor({ board, snapshot, migration }: { board: Board; snapshot?:
const [rotating, setRotating] = useState<string | null>(null); const [rotating, setRotating] = useState<string | null>(null);
const [draggedIds, setDraggedIds] = useState<string[]>([]); const [draggedIds, setDraggedIds] = useState<string[]>([]);
const [membershipNotice, setMembershipNotice] = useState(""); const [membershipNotice, setMembershipNotice] = useState("");
// 防节点拖动与多选拖动回调重复进入;起点按显示ID保存,连线屏幕起点用于区分点击与拉出新分支。
const dragSession = useRef<"idle" | "active" | "finishing">("idle"); const dragSession = useRef<"idle" | "active" | "finishing">("idle");
const dragOrigins = useRef(new Map<string, XYPosition>()); const dragOrigins = useRef(new Map<string, XYPosition>());
const draggingFrame = useRef(false); const draggingFrame = useRef(false);
const connectionStart = useRef<{ id: string; direction: NodeDirection; point: XYPosition } | null>(null); const connectionStart = useRef<{ id: string; direction: NodeDirection; point: XYPosition } | null>(null);
// 图片分支先保留源节点/方向/位置,等文件选择返回;cancel事件要清它,不能沿用上次创建意图。
const pendingImage = useRef<BranchRequest | null>(null); const pendingImage = useRef<BranchRequest | null>(null);
/** 稳定关闭回调供Portal菜单与画布事件共用,避免菜单因每次渲染重新安装监听。 */
const closeBranch = useCallback(() => setBranchRequest(null), []); const closeBranch = useCallback(() => setBranchRequest(null), []);
const imageKind = useRef<"image" | "plain-image">("image"); const imageKind = useRef<"image" | "plain-image">("image");
const imageInput = useRef<HTMLInputElement>(null); const imageInput = useRef<HTMLInputElement>(null);
const projectInput = useRef<HTMLInputElement>(null); const projectInput = useRef<HTMLInputElement>(null);
const [recovery, setRecovery] = useState(initial.recovery); 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 tree = useCanvasTree(board.id, board.workspace_id, initial.document, snapshot, Boolean(recovery), board.parent_board_id, migration);
const childRecovery = [...tree.recoveries][0]; 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 downloadDraft = (document: import("@/lib/canvas").CanvasDocument) => { const url = URL.createObjectURL(new Blob([JSON.stringify(document, null, 2)], { type: "application/json" })); const link = window.document.createElement("a"); link.href = url; link.download = "画布本地草稿.json"; link.click(); setTimeout(() => URL.revokeObjectURL(url), 1000); };
const sync = tree; const sync = tree;
// snapshot存在才执行真实AI;state.present组合根元数据与整树显示节点/边,不能把它当单board持久快照。
const ai = useCanvasAI(board.workspace_id, board.id, !!snapshot, tree); const ai = useCanvasAI(board.workspace_id, board.id, !!snapshot, tree);
const state = { present: { ...tree.rootDocument, nodes: tree.views.nodes, edges: tree.views.edges } }; const state = { present: { ...tree.rootDocument, nodes: tree.views.nodes, edges: tree.views.edges } };
const treeRef = useRef(tree); treeRef.current = tree; 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 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 imageUploading = demoImageUploading;
// 文件导入等待期间的迁移检查标记;这不是通用导入队列,也不提供并发文件事务锁。
const ioActive=useRef(false); const ioActive=useRef(false);
// 上传任务与文档都可能变化:按pendingUpload重新找owner,补资源或纯图片默认尺寸,避免移动/撤销后遗漏占位。
useEffect(() => { useEffect(() => {
const current = treeRef.current; const current = treeRef.current;
for (const job of uploads.jobs) { 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!}); 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]); }, [uploads.jobs,tree.documents]);
/** undo/redo走异步树历史接口并显示失败,其余动作走根document reducer;不把服务端事务撤销当普通reducer动作。 */
function dispatch(action: CanvasAction) { function dispatch(action: CanvasAction) {
if (action.type === "undo" || action.type === "redo") { void treeRef.current[action.type]().catch((error) => setError(error.message)); return; } if (action.type === "undo" || action.type === "redo") { void treeRef.current[action.type]().catch((error) => setError(error.message)); return; }
treeRef.current.dispatch(action); treeRef.current.dispatch(action);
} }
// 浏览器文件选择取消不触发普通onChange,单独监听cancel清理待建图片分支;卸载拆监听。
useEffect(() => { useEffect(() => {
const input = imageInput.current; const input = imageInput.current;
/** 取消本次选择意图,不删除已经存在的图片节点或中止已启动上传。 */
const cancel = () => { pendingImage.current = null; }; const cancel = () => { pendingImage.current = null; };
input?.addEventListener("cancel", cancel); input?.addEventListener("cancel", cancel);
return () => input?.removeEventListener("cancel", cancel); return () => input?.removeEventListener("cancel", cancel);
}, []); }, []);
const { nodes: displayNodes, edges } = state.present; 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; }); 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); 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); } }; 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 frameFeedback = useMemo(() => {
const names = new Map(nodes.filter((node) => node.data.kind === "frame").map((node) => [node.id, node.data.title])); const names = new Map(nodes.filter((node) => node.data.kind === "frame").map((node) => [node.id, node.data.title]));
const counts = new Map<string, number>(); 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); 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) }; return { names, counts, drops: frameDropFeedback(nodes, draggedIds) };
}, [nodes, draggedIds]); }, [nodes, draggedIds]);
// 成员提示四秒后清理;重新提示重置计时器,卸载取消。
useEffect(() => { useEffect(() => {
if (!membershipNotice) return; if (!membershipNotice) return;
const timer = setTimeout(() => setMembershipNotice(""), 4000); const timer = setTimeout(() => setMembershipNotice(""), 4000);
return () => clearTimeout(timer); return () => clearTimeout(timer);
}, [membershipNotice]); }, [membershipNotice]);
// 全局反馈可用时把本地错误转成稳定ID通知并清本地error;无Provider时保留底部内联提示。
useEffect(() => { useEffect(() => {
if (!feedback || !error) return; if (!feedback || !error) return;
const id = `canvas:${board.id}:error`; const id = `canvas:${board.id}:error`;
notify({ id, level: "error", title: error }); notify({ id, level: "error", title: error });
setError(""); setError("");
}, [feedback, error, board.id]); }, [feedback, error, board.id]);
// 成员通知使用board级稳定ID,内容变化替换旧通知,清理时显式dismiss。
useEffect(() => { useEffect(() => {
if (!feedback || !membershipNotice) return; if (!feedback || !membershipNotice) return;
const id = `canvas:${board.id}:membership`; const id = `canvas:${board.id}:membership`;
@@ -141,29 +173,40 @@ function CanvasEditor({ board, snapshot, migration }: { board: Board; snapshot?:
return () => dismissNotification(id); return () => dismissNotification(id);
}, [feedback, membershipNotice, board.id]); }, [feedback, membershipNotice, board.id]);
const duration = reduceMotion ? 0 : 180; const duration = reduceMotion ? 0 : 180;
/** 视口派发给根并独立写本地键;存储失败不打断浏览,内容writer自行判断是否需要服务端保存。 */
const rememberViewport = (viewport: import("@xyflow/react").Viewport) => { const rememberViewport = (viewport: import("@xyflow/react").Viewport) => {
treeRef.current.dispatch({ type: "viewport", viewport }); treeRef.current.dispatch({ type: "viewport", viewport });
try { localStorage.setItem(storageKey + ":viewport", JSON.stringify(viewport)); } catch { /* View changes do not require server writes. */ } try { localStorage.setItem(storageKey + ":viewport", JSON.stringify(viewport)); } catch { /* View changes do not require server writes. */ }
}; };
/** 工具栏视口动画完成后保存实际视口;错误转界面提示,不提前记录尚未完成的目标值。 */
const changeView = (operation: () => Promise<boolean>) => { const changeView = (operation: () => Promise<boolean>) => {
void operation().then(() => rememberViewport(flow.getViewport())).catch((error) => setError(error.message)); void operation().then(() => rememberViewport(flow.getViewport())).catch((error) => setError(error.message));
}; };
// latest保留当前显示几何,拖动结束基于最新节点计算成员反馈,避免只使用开始时的闭包。
const latest = useRef(state.present); const latest = useRef(state.present);
latest.current = state.present; latest.current = state.present;
/** 显示ID的选择交tree解析owner;稳定回调通过treeRef避免每次文档更新重建编辑上下文。 */
const select = useCallback((id: string) => treeRef.current.select(id), []); 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]); 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 })), []); 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); }, []); 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 transform = useCallback((id: string, transform: NodeTransform) => {
const ref = treeRef.current.views.nodeRefs.get(id); if (!ref) return; const ref = treeRef.current.views.nodeRefs.get(id); if (!ref) return;
treeRef.current.nodeAction(id, (id) => ({ type: "transform", id, transform: { ...transform, 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.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 } : {}) } })); ...(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 })), []); 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 })), []); 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 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 current = treeRef.current, ref = current.views.nodeRefs.get(id); if (!ref) return;
const document = current.documents.get(ref.boardId)!; 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 } }; return { ...node, ...(size ? { ...size, style: { ...node.style, ...size } } : {}), data: { ...node.data, ...patch } };
}) } }, ref.boardId); }) } }, 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]); 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) { function createBranch(request: BranchRequest, kind: "text" | "image" | "script", src?: string) {
try { 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); 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); setBranchRequest(null);
} catch (error) { setError(error instanceof Error ? error.message : "无法创建节点。"); } } catch (error) { setError(error instanceof Error ? error.message : "无法创建节点。"); }
} }
/** 图片分支需要文件选择才能创建,先保存request并关菜单;文本/脚本立即创建。 */
function chooseBranch(kind: "text" | "image" | "script") { function chooseBranch(kind: "text" | "image" | "script") {
if (!branchRequest) return; if (!branchRequest) return;
if (kind === "image") { pendingImage.current = branchRequest; imageKind.current = "image"; setBranchRequest(null); imageInput.current?.click(); } if (kind === "image") { pendingImage.current = branchRequest; imageKind.current = "image"; setBranchRequest(null); imageInput.current?.click(); }
else createBranch(branchRequest, kind); else createBranch(branchRequest, kind);
} }
/** 无有效目标的连接拖动若移动至少5px并落到空白pane,且源仍选中,则在落点打开分支菜单。 */
const connectionEnd: OnConnectEnd = (event, connection) => { const connectionEnd: OnConnectEnd = (event, connection) => {
const start = connectionStart.current; const start = connectionStart.current;
connectionStart.current = null; 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 }); setBranchRequest({ id: start.id, direction: start.direction, point: { x: point.clientX, y: point.clientY }, at });
}; };
/** 按窗口中心转换为场景点并加六档偏移,减少连续添加完全重叠;不是按编辑器容器中心计算。 */
const center = () => { const center = () => {
const point = flow.screenToFlowPosition({ x: window.innerWidth / 2, y: window.innerHeight / 2 }); const point = flow.screenToFlowPosition({ x: window.innerWidth / 2, y: window.innerHeight / 2 });
const offset = (nodes.length % 6) * 18; const offset = (nodes.length % 6) * 18;
return { x: point.x - 130 + offset, y: point.y - 80 + offset }; 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) { function addNode(kind: CanvasNode["data"]["kind"], src?: string, size?: { width: number; height: number }, title?: string) {
if (tree.rootDocument.nodes.length >= 500) { setError("当前图谱已达到 500 个节点,请新建图谱继续创作。"); return; } if (tree.rootDocument.nodes.length >= 500) { setError("当前图谱已达到 500 个节点,请新建图谱继续创作。"); return; }
const node = makeCanvasNode(kind, center(), src); 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 }] }); dispatch({ type: "nodes", changes: [{ type: "add", item: node }] });
setTool("select"); setTool("select");
} }
/** 一次replace插入三张预填卡片和两条连线,预留三个节点容量并切回选择工具;不触发AI生成。 */
function addTemplate() { function addTemplate() {
if (tree.rootDocument.nodes.length > 497) { setError("当前图谱没有足够空间插入模板。"); return; } if (tree.rootDocument.nodes.length > 497) { setError("当前图谱没有足够空间插入模板。"); return; }
const position = center(); const position = center();
@@ -233,6 +283,7 @@ function CanvasEditor({ board, snapshot, migration }: { board: Board; snapshot?:
] } }); ] } });
setTool("select"); setTool("select");
} }
/** idle才建立checkpoint与拖动起点;有frame参与则不显示普通拖入预览,防止移动分区误收纳覆盖内容。 */
function beginDrag(node: CanvasNode, dragged: CanvasNode[]) { function beginDrag(node: CanvasNode, dragged: CanvasNode[]) {
if (dragSession.current !== "idle") return; dragSession.current = "active"; tree.setDragging(true); if (dragSession.current !== "idle") return; dragSession.current = "active"; tree.setDragging(true);
beginEdit(node.id); closeBranch(); setRotating(null); setResizing(null); 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()]); setDraggedIds(draggingFrame.current ? [] : [...dragOrigins.current.keys()]);
setMembershipNotice(""); setMembershipNotice("");
} }
/** 只结束active拖动,先清拖动/保存暂停标记,再按真实位移计算归属;真实子图与旧frameId归属分路处理。
* 异步移动失败保留错误提示,不宣称所有跨分区步骤一起回滚。 */
async function finishDrag(node: CanvasNode, dragged: CanvasNode[]) { async function finishDrag(node: CanvasNode, dragged: CanvasNode[]) {
if (dragSession.current !== "active") return; if (dragSession.current !== "active") return;
const origins = new Map(dragOrigins.current); const origins = new Map(dragOrigins.current);
@@ -252,19 +305,23 @@ function CanvasEditor({ board, snapshot, migration }: { board: Board; snapshot?:
if (ids.length) { if (ids.length) {
const positions = new Map(items.map((item) => [item.id, item.position])); const positions = new Map(items.map((item) => [item.id, item.position]));
const dropped = latest.current.nodes.map((item) => positions.has(item.id) ? { ...item, position: positions.get(item.id)! } : item); const 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 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 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(";")}` : ""; const notice = messages.length ? `${changed.length > 1 ? `${changed.length} 项内容:` : ""}${[...new Set(messages)].join(";")}` : "";
if (snapshot && tree.rootDocument.schema_version === 2) { if (snapshot && tree.rootDocument.schema_version === 2) {
try { try {
// 按source/target分组,同组节点一起迁移;候选分区按面积升序,优先最小包围分区。
const byOwner = new Map<string, { ids: string[]; target: string }>(); const byOwner = new Map<string, { ids: string[]; target: string }>();
const parents = subgraphParents(tree.current.current); const parents = subgraphParents(tree.current.current);
for (const item of items.filter((item) => ids.includes(item.id))) { for (const item of items.filter((item) => ids.includes(item.id))) {
const ref = tree.views.nodeRefs.get(item.id); if (!ref) continue; 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 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 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 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; 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) { 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; 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); 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]) => { await Promise.all([...byOwner].map(async ([key, group]) => {
let source = key.split("/")[0]; let source = key.split("/")[0];
for (const hop of subgraphMovePath(tree.current.current, source, group.target)) { 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) { async function connect(connection: Connection) {
const source = tree.views.nodeRefs.get(connection.source), target = tree.views.nodeRefs.get(connection.target); const source = tree.views.nodeRefs.get(connection.source), target = tree.views.nodeRefs.get(connection.target);
if (!source || !target) return; if (!source || !target) return;
try { try {
/** 按深度计数0—8解析子图入口,每层必须找到明确port映射;仍未到实际节点或缺端口则拒绝。 */
const resolve = async (ref: typeof source, handle: string | null | undefined): Promise<{ boardId: string; id: string; handle: string }> => { 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" }; let endpoint = { boardId: ref!.boardId, id: ref!.id, handle: handle || "right" };
for (let depth = 0; depth <= 8; depth++) { for (let depth = 0; depth <= 8; depth++) {
@@ -303,6 +363,7 @@ function CanvasEditor({ board, snapshot, migration }: { board: Board; snapshot?:
throw new Error("subgraph_cycle"); throw new Error("subgraph_cycle");
}; };
const realSource = await resolve(source, connection.sourceHandle), realTarget = await resolve(target, connection.targetHandle || "left"); const realSource = await resolve(source, connection.sourceHandle), realTarget = await resolve(target, connection.targetHandle || "left");
// 同一canvas快照可直接加边;跨canvas需要共同上层/边界端口关系,不能把显示端点直接写进任一子快照。
if (realSource.boardId === realTarget.boardId) { if (realSource.boardId === realTarget.boardId) {
const document = tree.current.current.get(realSource.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); 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; return;
} }
} }
// 兼容路径由exposedConnection提供可寻址端点与owner,失败提示跨画布连接暂不可用。
const exposed = exposedConnection(tree.current.current, realSource, realTarget); const exposed = exposedConnection(tree.current.current, realSource, realTarget);
const document = tree.current.current.get(exposed.boardId)!; 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); 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)); } } catch (error) { setError(error instanceof Error && error.message === "boundary_port_required" ? "跨画布连接暂不可用,请稍后重试。" : errorText(error)); }
} }
/** 校验MIME与大小并清文件input;迁移期间拒绝。真实画布通过上传占位,演示以Data URL直接加入。 */
async function readImage(event: ChangeEvent<HTMLInputElement>) { async function readImage(event: ChangeEvent<HTMLInputElement>) {
const file = event.target.files?.[0]; const file = event.target.files?.[0];
event.target.value = ""; event.target.value = "";
@@ -331,6 +394,7 @@ function CanvasEditor({ board, snapshot, migration }: { board: Board; snapshot?:
} }
const request = pendingImage.current; pendingImage.current = null; const request = pendingImage.current; pendingImage.current = null;
const kind = imageKind.current; const kind = imageKind.current;
// 真实图片上传先取得本地Blob预览并构造draft,随后把pendingUpload节点及可选分支边发布到owner。
if (snapshot) { if (snapshot) {
try { const job = uploads.start(file, (src) => { try { const job = uploads.start(file, (src) => {
const current = treeRef.current; 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); const node=makeCanvasNode(kind,center(),src);if(kind==='plain-image')node.data.title=file.name.slice(0,300);
return {owner:board.id,node}; return {owner:board.id,node};
}); });
// pendingUpload以node.id标记,后续资源完成/跨区移动/历史恢复都按这个身份找到占位。
const current=treeRef.current,doc=current.current.current.get(job.owner)!; const current=treeRef.current,doc=current.current.current.get(job.owner)!;
const node={...job.node,pendingUpload:job.node.id}; 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); 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));} setTool("select"); } catch(error) {setError(errorText(error));}
return; return;
} }
// 演示不上传服务端,FileReader读取全部字节为Data URL;纯图片另解码初始尺寸,finally释放读取中UI状态。
setImageUploading(true); setImageUploading(true);
try { try {
const src = await new Promise<string>((resolve, reject) => { 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)); } } catch (error) { pendingImage.current = null; setError(errorText(error)); }
finally { setImageUploading(false); } finally { setImageUploading(false); }
} }
/** 旧版导入最多5MB并全量读取解码,替换当前根快照;任一方含真实子图时拒绝,避免破坏跨board关系。
* ioActive用于阻止迁移交接;这条路径不是新版渐进导入,也不会自动重建bundle子图。 */
async function importProject(event: ChangeEvent<HTMLInputElement>) { async function importProject(event: ChangeEvent<HTMLInputElement>) {
const file = event.target.files?.[0]; const file = event.target.files?.[0];
event.target.value = ""; event.target.value = "";
@@ -387,6 +455,8 @@ function CanvasEditor({ board, snapshot, migration }: { board: Board; snapshot?:
} catch (error) { setError(error instanceof Error ? error.message : "导入失败。"); } } catch (error) { setError(error instanceof Error ? error.message : "导入失败。"); }
finally{ioActive.current=false;} finally{ioActive.current=false;}
} }
/** 有上传占位则先等待用户完成;树导出完整可达文档,多board写bundle,单board写当前视口。
* 下载名清理文件名非法字符,Blob URL延迟释放;不额外打包File/Blob,已有演示Data URL仍随src导出。 */
async function exportProject() { async function exportProject() {
let documents: Map<string, import("@/lib/canvas").CanvasDocument>; 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; } 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); setTimeout(() => URL.revokeObjectURL(url), 1000);
} }
// 旧版全局快捷键:跳过输入框、文本编辑、对话框和打开的popover,支持撤销/重做与V/H;卸载移除。
useEffect(() => { useEffect(() => {
/** 按DOM目标过滤后处理键;此旧路径没有新版routeCanvasKey的组合输入/认领优先级模型。 */
const keydown = (event: KeyboardEvent) => { const keydown = (event: KeyboardEvent) => {
const target = event.target; const target = event.target;
if (!(target instanceof HTMLElement)) return; if (!(target instanceof HTMLElement)) return;
@@ -417,6 +489,11 @@ function CanvasEditor({ board, snapshot, migration }: { board: Board; snapshot?:
return () => window.removeEventListener("keydown", keydown); return () => window.removeEventListener("keydown", keydown);
}, []); }, []);
// Provider把图片预览、节点动作、分区反馈及活动连线接到renderer;迁移时surface与底部创作栏设inert。
// ReactFlow删除由onBeforeDelete拦截真实子图/暴露端口节点,避免默认删除破坏结构;普通元素沿tree变化入口。
// 选择工具允许拖动/连接,抓手只平移;Space临时平移、Shift部分框选,滚轮默认平移、修饰键缩放。
// 工具栏创建/模板/导入走上述入口,顶部视口操作保存本地viewport;AI只给选中且支持的节点显示。
// 恢复弹窗先根再子,逐项核查canRestore;迁移组件检查草稿、文件IO和未完成上传再排空writer。
return ( return (
<div className={`${styles.editor} ${tool === "hand" ? styles.handMode : ""}`}> <div className={`${styles.editor} ${tool === "hand" ? styles.handMode : ""}`}>
<ImagePreviews.Provider value={imagePreviews}> <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} <ReactFlow<CanvasNode> nodes={nodes} edges={edges} nodeTypes={nodeTypes} edgeTypes={edgeTypes}
onBeforeDelete={async ({ nodes: removing }) => { onBeforeDelete={async ({ nodes: removing }) => {
if (tree.busy || tree.transactionBusy) return false; if (tree.busy || tree.transactionBusy) return false;
// 选中分区入口要先dissolve保留内容;内部节点若被父port引用,先移回父board再删,清理边界引用。
const linked = removing.filter((node) => node.data.subgraph); const linked = removing.filter((node) => node.data.subgraph);
const parents = subgraphParents(tree.current.current); const parents = subgraphParents(tree.current.current);
const referenced = removing.filter((node) => { const referenced = removing.filter((node) => {
@@ -436,6 +514,7 @@ function CanvasEditor({ board, snapshot, migration }: { board: Board; snapshot?:
}); });
if (!linked.length && !referenced.length) return true; if (!linked.length && !referenced.length) return true;
try { try {
// 分区逐个事务解散,参与者包含父、子、孙及需要调整引用的祖先;并非一批选择全体原子操作。
for (const node of linked) { for (const node of linked) {
const ref = tree.views.nodeRefs.get(node.id)!; const ref = tree.views.nodeRefs.get(node.id)!;
await tree.ensure(node.data.subgraph!.boardId); 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); 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]); await tree.command(ref.boardId, `subgraphs/${ref.id}/dissolve`, {}, [...participants]);
} }
// 解散后owner可能变化,按原ID重新查唯一归属;找不到唯一目标就跳过,不误删同ID的其它节点。
for (const node of removing.filter((node) => !node.data.subgraph)) { for (const node of removing.filter((node) => !node.data.subgraph)) {
const ref = tree.views.nodeRefs.get(node.id); if (!ref) continue; const ref = tree.views.nodeRefs.get(node.id); if (!ref) continue;
let owner = ref.boardId; let owner = ref.boardId;
@@ -461,6 +541,7 @@ function CanvasEditor({ board, snapshot, migration }: { board: Board; snapshot?:
} }
if (linked.length) setMembershipNotice("已解散选框,子画布内容已保留"); if (linked.length) setMembershipNotice("已解散选框,子画布内容已保留");
} catch (error) { setError(error instanceof Error ? error.message : "删除未完成"); } } catch (error) { setError(error instanceof Error ? error.message : "删除未完成"); }
// 结构分支已自行执行删除/解散,阻止ReactFlow再按旧投影默认删一遍。
return false; return false;
}} }}
onNodesChange={tree.nodesChanged} onNodesChange={tree.nodesChanged}
@@ -486,6 +567,7 @@ function CanvasEditor({ board, snapshot, migration }: { board: Board; snapshot?:
defaultEdgeOptions={{ style: { stroke: "#aa968b", strokeWidth: 1.5 } }}> defaultEdgeOptions={{ style: { stroke: "#aa968b", strokeWidth: 1.5 } }}>
<Background variant={BackgroundVariant.Dots} gap={22} size={1} color="#c9bfb5" /> <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) => { {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 ref = tree.views.nodeRefs.get(node.id)!;
const owned = tree.documents.get(ref.boardId)?.nodes.find((item) => item.id === ref.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 } }))} />; 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> <button onClick={exportProject}>导出当前内容</button><button onClick={() => window.location.reload()}>重新读取</button>
</div>} </div>}
{tree.memberJobs.map((job, index) => job.ids.map((id) => { {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)); 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}> 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]}> <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 }) { export default function GraphCanvas({ board, snapshot, migration }: { board: Board; snapshot?: CanvasSnapshot; migration?:LegacyMigrationContext }) {
return <ReactFlowProvider><CanvasEditor board={board} snapshot={snapshot} migration={migration} /></ReactFlowProvider>; 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"; "use client";
import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore, type ContextType } from "react"; 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 { ImagePreviews } from "./canvas-image";
import styles from "./canvas.module.css"; import styles from "./canvas.module.css";
/** 把适配层样式等可序列化值规范化成JSON Data,去掉undefined等JSON不保留的属性;不处理循环或任意非JSON对象。 */
const json = (value: unknown): Data => JSON.parse(JSON.stringify(value)); 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 }) { 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]); const sceneCache=useMemo(()=>createSceneProjection(),[engine,viewId]);
/** 用户字段修改/替换删除及undo/redo立即发布,保证受控输入及时回显;批量创建和后台修改允许合并到下一帧。 */
const subscribe = useCallback((notify: (immediate?:boolean) => void) => { const subscribe = useCallback((notify: (immediate?:boolean) => void) => {
let before=engine.getSnapshot().document; let before=engine.getSnapshot().document;
return engine.editor.subscribe((commit,next)=>{sceneCache.advance(before,next.document,commit.changes);before=next.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); notify(immediate);
}); });
}, [engine,sceneCache]); }, [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 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 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 sessionFrames=useMemo(()=>createSessionFrameSource(engine.sessions,{request:callback=>requestAnimationFrame(callback),cancel:handle=>cancelAnimationFrame(handle as number)}),[engine]);
const sessions = useSyncExternalStore(sessionFrames.subscribe, sessionFrames.getSnapshot, sessionFrames.getSnapshot); const sessions = useSyncExternalStore(sessionFrames.subscribe, sessionFrames.getSnapshot, sessionFrames.getSnapshot);
// 视图被detach后保留最近view供本轮Hook计算,最终返回“画布已关闭”;不以lastView恢复已关闭会话。
const liveView = sessions.views.get(viewId), lastView = useRef(liveView); const liveView = sessions.views.get(viewId), lastView = useRef(liveView);
const loading=root.runtime.loading,needsLoadingFit=useRef(!!loading&&fitLoading); const loading=root.runtime.loading,needsLoadingFit=useRef(!!loading&&fitLoading);
if (liveView) lastView.current = liveView; if (liveView) lastView.current = liveView;
const view = liveView ?? lastView.current!, flow = useReactFlow<ProjectionNode>(); 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); const canCreate=!loading||loading.version>=4&&engine.editor.getQueries(root.document).coverage.completeCanvasIds.includes(view.canvasId);
// 加载预览首次适配只取当前canvas直接节点,避免展开后代把视口拉得过远;有交接视口时不再自动fit。
useEffect(()=>{ useEffect(()=>{
if(!needsLoadingFit.current||!flow.viewportInitialized||!root.document.canvases.has(view.canvasId))return; if(!needsLoadingFit.current||!flow.viewportInitialized||!root.document.canvases.has(view.canvasId))return;
// Expanded descendants can extend far beyond their partition entry. Initial // 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; if(!nodes.length)return;
needsLoadingFit.current=false;void flow.fitView({nodes,padding:.2,maxZoom:1}); needsLoadingFit.current=false;void flow.fitView({nodes,padding:.2,maxZoom:1});
},[engine,flow,root.document,view.canvasId]); },[engine,flow,root.document,view.canvasId]);
// 复制准备任务来自runtime状态;copying防本地重复点击,两者共同禁用复制入口。
const preparingCopy = [...(root.runtime.imports?.values() ?? [])].some(job => job.status === 'preparing' && job.source?.targetCanvasId === view.canvasId); 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); const [focusedEditor, setFocusedEditor] = useState<HTMLElement | null>(null), focusEpoch = useRef(0);
useEffect(() => { focusEpoch.current++; setFocusedEditor(null); return () => { focusEpoch.current++; }; }, [engine,viewId]); useEffect(() => { focusEpoch.current++; setFocusedEditor(null); return () => { focusEpoch.current++; }; }, [engine,viewId]);
// Removing an active renderer need not dispatch a native blur. Retire only // 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);}); useEffect(()=>{if(focusedEditor&&!focusedEditor.isConnected)setFocusedEditor(current=>current===focusedEditor?null:current);});
const [copying, setCopying] = useState(false); const [copying, setCopying] = useState(false);
const [resourcePicker,setResourcePicker]=useState<{canvasId:Id;position:{x:number;y:number}}|null>(null); 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 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 [showAI, setShowAI] = useState(false), [showRecovery, setShowRecovery] = useState(false);
const [error, setError] = useState(""); 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 [displayState, setDisplayState] = useState<{engine:ProjectEngine|null;viewId:Id|null;sources:Map<Id,ResourceViewSource>;failures:number}>({engine:null,viewId:null,sources:new Map(),failures:0});
const resourceView=useRef<ReturnType<typeof createResourceView>|null>(null); const 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); 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); const projectionCache = useRef<ReturnType<typeof createProjectionCache> | null>(null);
projectionCache.current ??= createProjectionCache(); projectionCache.current ??= createProjectionCache();
const measurements = useRef(new Map<Id, { width: number; height: number; measured: { width: number; height: number } }>()); 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}>()); 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]); 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 catalog=useMemo(()=>loading?createLoadingRendererCatalog(engine.editor.registry,renderers.catalog):renderers.catalog,[engine,renderers,!!loading]);
const scene=sceneCache.project(engine,root.document,view,sessions,engine.editor.registry,catalog,engine.editor.getQueries(root.document)); const scene=sceneCache.project(engine,root.document,view,sessions,engine.editor.registry,catalog,engine.editor.getQueries(root.document));
// 仅在尺寸一致且投影缓存确认身份/变换允许时复用measured;离开场景的节点清测量条目。
const projection = useMemo(() => { const projection = useMemo(() => {
const result = scene; const result = scene;
// Preserve renderer measurements across metadata/preview updates. Dropping them // 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; } 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); return projectionCache.current!.reuse(engine,root.document,view.canvasId,result);
}, [engine, root.document, scene,view.canvasId]); }, [engine, root.document, scene,view.canvasId]);
// 每个view独立OverlayIndex,Portal控件通过索引贡献边界与焦点;不会把浮层几何存入项目文档。
const surface=useRef<HTMLDivElement>(null),overlays=useMemo(()=>createOverlayIndex(viewId),[engine,viewId]); const surface=useRef<HTMLDivElement>(null),overlays=useMemo(()=>createOverlayIndex(viewId),[engine,viewId]);
const overlayScope=useMemo(()=>({index:overlays,engine}),[overlays,engine]); const overlayScope=useMemo(()=>({index:overlays,engine}),[overlays,engine]);
const overlaySnapshot=useSyncExternalStore(overlays.subscribe,overlays.getSnapshot,overlays.getSnapshot); const overlaySnapshot=useSyncExternalStore(overlays.subscribe,overlays.getSnapshot,overlays.getSnapshot);
const surfaceBounds=surface.current?.getBoundingClientRect(); const surfaceBounds=surface.current?.getBoundingClientRect();
// 绘制范围索引用于可见性筛选;sceneViewport把ReactFlow平移/缩放反算为场景矩形。
const paintCache=useMemo(()=>createProjectedPaintIndex(),[engine,view.canvasId]); const paintCache=useMemo(()=>createProjectedPaintIndex(),[engine,view.canvasId]);
const paint=useMemo(()=>paintCache.sync(projection,engine.editor.registry),[paintCache,projection,engine]); 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 viewport=useViewport(),surfaceWidth=useStore(state=>state.width),surfaceHeight=useStore(state=>state.height);
const sceneViewport={x:-viewport.x/viewport.zoom,y:-viewport.y/viewport.zoom,width:surfaceWidth/viewport.zoom,height:surfaceHeight/viewport.zoom}; const 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)):[]; 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 paintedNodes=[...paint.unknown,...paint.query(sceneViewport)];
const nativeNodes=useStore(state=>state.nodes); const nativeNodes=useStore(state=>state.nodes);
const connectionPaintCache=useMemo(()=>createConnectionPaintIndex(),[engine,view.canvasId]); 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]); 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 // Exhausted geometry work cannot prove any edge invisible. Retain the edge
// layer and its endpoints, not unrelated nodes or a synthetic sentinel owner. // layer and its endpoints, not unrelated nodes or a synthetic sentinel owner.
const paintedEdges=connectionPaint.unknown.includes('paint_budget')?projection.edges.map(edge=>edge.id):[...connectionPaint.unknown,...connectionPaint.query(sceneViewport)]; const 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]); 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 }; 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 }) => { 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))); 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 }; }; 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 })); 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 }[]) { function selectChanges(changes: readonly { id: Id; selected: boolean }[]) {
const current = engine.sessions.getSnapshot().views.get(viewId)!; const current = engine.sessions.getSnapshot().views.get(viewId)!;
const ids = new Set(current.selection); const ids = new Set(current.selection);
for (const change of changes) if (change.selected) ids.add(change.id); else ids.delete(change.id); 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]); 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}) { function selectAtPane(event: {clientX:number;clientY:number}) {
const captured=current.current; const captured=current.current;
if(captured.view.tool!=='select'){engine.sessions.select(viewId,[]);return;} 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]; const hit=result.hits[0];
engine.sessions.select(viewId,hit?.certainty==='confirmed'?[hit.target.id]:[]); engine.sessions.select(viewId,hit?.certainty==='confirmed'?[hit.target.id]:[]);
} }
/** session.begin成功后捕获gesture token,以guardEditingLease约束后续preview/commit/cancel只作用于该交互。 */
function beginTransform(id: Id) { function beginTransform(id: Id) {
const result = engine.sessions.begin(viewId, [target(id)]); report(result); if (!result.enabled) return false; const result = engine.sessions.begin(viewId, [target(id)]); report(result); if (!result.enabled) return false;
const token = engine.sessions.getSnapshot().gesture!.token; const token = engine.sessions.getSnapshot().gesture!.token;
/** 每次租约动作实时核对token,旧pointerup不能结束后来新建的手势。 */
const current = () => engine.sessions.getSnapshot().gesture?.token === token; const current = () => engine.sessions.getSnapshot().gesture?.token === token;
return guardEditingLease(current, { preview: (change: Parameters<typeof transform>[1]) => transform(id, change), commit: () => report(engine.sessions.commit(viewId)), cancel: () => engine.sessions.cancel('pointer_cancel') }); 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 }) { 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); const { root, projection } = current.current, element = root.document.elements.get(id);
if (element?.structure !== 'node') return; 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 } : {}) }; 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)); 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 }) { 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 id = crypto.randomUUID(), canvasId = current.current.view.canvasId;
const rect = window.document.querySelector('[data-headless-canvas]')?.getBoundingClientRect(); const rect = window.document.querySelector('[data-headless-canvas]')?.getBoundingClientRect();
const position = at ?? flow.screenToFlowPosition({ x: (rect?.left ?? 0) + (rect?.width ?? window.innerWidth) / 2, y: (rect?.top ?? 0) + (rect?.height ?? window.innerHeight) / 2 }); const 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 size = kind === 'frame' ? { width: 640, height: 400 } : kind === 'plain-text' ? { width: 240, height: 72 } : { width: 260, height: 200 };
const element: ElementRecord = { id, incarnation: 1, structure: 'node', componentId: kind, dataVersion: 1, ownerCanvasId: canvasId, geometry: { ...position, ...size, rotation: 0 }, data: { title: kind === 'frame' ? '未命名分区' : kind === 'image' ? '视觉参考' : '灵感笔记', text: '', ...(ref ? { assetId: ref.id, src: ref.src } : {}) } }; const 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 } } }); }; 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' })); if (tx) create(tx); else report(engine.editor.transaction(create, { label: 'Add element' }));
return id; return id;
} }
/** 选择工具才可建边;拒绝unsupported端点,将owner设为两端所属canvas的共同上级,端口ID来自连接事件。 */
function connect(connection: Connection) { function connect(connection: Connection) {
if(engine.sessions.getSnapshot().views.get(viewId)?.tool!=='select')return; 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); 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;} 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 } })); 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])); 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(() => { useEffect(() => {
if (!liveView) return; 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));}}); 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; resourceView.current=view;
return ()=>{if(resourceView.current===view)resourceView.current=null;view.dispose();}; return ()=>{if(resourceView.current===view)resourceView.current=null;view.dispose();};
}, [engine,viewId,Boolean(liveView)]); }, [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)]); 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]); 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' })); } 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 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; 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(() => { 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; }; 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. // Bubble after the target/React handlers: an input owner can claim Escape first.
/** 冒泡阶段让输入控件优先认领Escape,再按统一路由决定是否取消手势。 */
const escape = (event: KeyboardEvent) => { const escape = (event: KeyboardEvent) => {
if(event.key!=='Escape')return; 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;}}); 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; const modes = view.modes;
// Retain explicit owners in the native renderer, leaving the full graph in its // 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. // lookup for edges/queries. No synthetic dragging or altered geometry is used.
// 保留模式目标、可见绘制对象、浮层目标和本视图手势;完整图仍留ReactFlow查询表,不以伪造拖动避裁剪。
const retained=new Set<Id>([...modes.keys(),...paintedNodes,...paintedEdges]); const retained=new Set<Id>([...modes.keys(),...paintedNodes,...paintedEdges]);
for(const entry of visibleOverlays)retained.add(entry.target.id); 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.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); if(sessions.connectionGesture?.viewId===viewId)retained.add(sessions.connectionGesture.target.id);
// 活动输入可来自节点、连线、Portal浮层或独立UI;无法证明其归属时保守关闭可见性裁剪。
const activeEditor=focusedEditor?.isConnected?focusedEditor:null; const activeEditor=focusedEditor?.isConnected?focusedEditor:null;
const focusedOverlay=activeEditor?.closest<HTMLElement>('[data-canvas-overlay-target]'); const focusedOverlay=activeEditor?.closest<HTMLElement>('[data-canvas-overlay-target]');
const focusedUI=activeEditor?.closest<HTMLElement>('[data-canvas-ui-view]'); 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); if(focusedId)retained.add(focusedId);
// Retained connections also keep their measured endpoint owners alive. // 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);}} 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 scoped=!(activeEditor&&!focusedId&&!independentFocus)&&[...retained].every(id=>root.document.elements.has(id));
const cullVisible=!!surfaceWidth&&!!surfaceHeight&&scoped; const cullVisible=!!surfaceWidth&&!!surfaceHeight&&scoped;
const retainedProjection=useMemo(()=>createRetainedProjection(),[engine,viewId]); const retainedProjection=useMemo(()=>createRetainedProjection(),[engine,viewId]);
const rendered=retainedProjection(projection.nodes,projection.edges,retained); 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 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 selectedResource=selected?.structure==='node'&&engine.editor.registry.resolve(selected)==='known'&&engine.editor.registry.supports(selected,'geometry.resize')&&typeof selected.data.assetId==='string'&&engine.editor.registry.components.get(selected.componentId)?.resourceFields?.includes('assetId')?root.runtime.resources.get(selected.data.assetId):undefined;
const canFitImage=!!selectedResource&&(selectedResource.image?.version===1&&selectedResource.image.status==='ready'||!!selectedResource.stored)&&!sessions.gesture; const 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. // Actions use current.current / engine snapshots instead of captured document projections.
const rotating = [...modes].find(([, mode]) => mode === 'rotate')?.[0] ?? null; const rotating = [...modes].find(([, mode]) => mode === 'rotate')?.[0] ?? null;
const resizing = [...modes].find(([, mode]) => mode === 'resize')?.[0] ?? null; const resizing = [...modes].find(([, mode]) => mode === 'resize')?.[0] ?? null;
// 新版资源URL寿命由resourceView管理,CanvasImage的loaded回调这里无需删除旧版上传任务。
const imagePreviews = useMemo<ContextType<typeof ImagePreviews>>(() => ({sources,loaded:()=>{}}),[sources]); 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, 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'); } }, 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); }, 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 }), 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'); }, 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)), 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'); }, 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'); }, 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) => { branch: (id, direction) => {
const node = projectionCache.current!.getNode(id); if (!node) return; 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; 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]); }),[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 => { 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; 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 token = engine.sessions.getSnapshot().connectionGesture!.token, element = engine.getSnapshot().document.elements.get(id)!;
const transform = current.current.projection.transforms.get(element.ownerCanvasId)!; const transform = current.current.projection.transforms.get(element.ownerCanvasId)!;
/** token护栏绑定该条连接手势,预览路径先从场景空间变回owner局部空间。 */
const ownsGesture = () => engine.sessions.getSnapshot().connectionGesture?.token === token; 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') }); 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'); }, 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) => { patch: (id, value, transient) => {
const element = engine.getSnapshot().document.elements.get(id); if (element?.structure !== 'connection') return; const element = engine.getSnapshot().document.elements.get(id); if (element?.structure !== 'connection') return;
const t = current.current.projection.transforms.get(element.ownerCanvasId)!; const t = current.current.projection.transforms.get(element.ownerCanvasId)!;
const path = transformPath((value.path ?? null) as Json, t, true); const path = transformPath((value.path ?? null) as Json, t, true);
const displayedPath = current.current.projection.edges.find(edge => edge.id === id)?.data?.path; 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); const changedPath = Object.hasOwn(value, 'path') && !equal(value.path, displayedPath);
if (transient) { report(engine.sessions.updateConnection(viewId, path)); return; } if (transient) { report(engine.sessions.updateConnection(viewId, path)); return; }
const { path: ignored, ...rest } = value; void ignored; const { path: ignored, ...rest } = value; void ignored;
@@ -269,8 +336,15 @@ function CanvasView({ engine, board, viewId, renderers,fitLoading=true }: { engi
}, },
}),[engine,viewId,report]); }),[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]); 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>; 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} 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); } }} 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); }); }} 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)} onMoveEnd={(_, viewport) => engine.sessions.viewport(viewId, viewport)}
onNodeDragStart={(event, node, nodes) => { onNodeDragStart={(event, node, nodes) => {
const ids = new Set((nodes.length ? nodes : [node]).map(node => node.id)); 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 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]; 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])) }; 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'))} 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 }); }} 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 => { onNodeDragStop={event => {
// 仅结束匹配本视图和token的拖动,迟到dragStop不提交当前其它手势。
const saved = drag.current; drag.current = null; pointerStart.current = null; 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; 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 }); 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="图谱视图控制"> <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={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={() => { <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})); 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(""); 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); }); 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>} {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>; </div>;
} }
/** 挂载期生成稳定viewId并attach,借用initialViewport只初始化一次;detach只释放视图,不关闭engine。
* 交接视口存在时禁用加载初次fit,避免从只读预览切入后可视位置突然跳变。 */
export default function HeadlessGraphCanvas({ engine, board, renderers=defaultCanvasRenderers,initialViewport }: { engine: ProjectEngine; board: Board; renderers?:ReactRenderers;initialViewport?:Viewport }) { export default function HeadlessGraphCanvas({ engine, board, renderers=defaultCanvasRenderers,initialViewport }: { engine: ProjectEngine; board: Board; renderers?:ReactRenderers;initialViewport?:Viewport }) {
const [handoffViewport]=useState(initialViewport); const [handoffViewport]=useState(initialViewport);
const [viewId] = useState(() => crypto.randomUUID()), [attached, setAttached] = useState(false); 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]); 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>; 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>;
} }
+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 { --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, .page button { -webkit-tap-highlight-color: transparent; }
.page a:focus-visible, .page button:focus-visible { outline: 2px solid #6879c2; outline-offset: 4px; } .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; } .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) { color: var(--ink); gap: 8px; font-size: 19px; letter-spacing: -0.6px; }
.page :global(.brand img) { width: 30px; height: 30px; } .page :global(.brand img) { width: 30px; height: 30px; }
.page :global(.brand small) { color: #92958c; font-size: 11px; font-weight: 400; letter-spacing: 0; } .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 { display: flex; gap: 28px; font-size: 13px; color: #72766c; }
.nav nav a:hover { color: var(--ink); } .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; } .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 { display: inline-flex; align-items: center; gap: 12px; padding: 10px 15px; border: 1px solid #cfd2c8; border-radius: 5px; }
.smallCta:hover { background: #f5f6f0; } .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; } .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; } .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; } .heroCopy .eyebrow { display: flex; align-items: center; gap: 8px; white-space: nowrap; }
.statusDot { width: 5px; height: 5px; background: #83966c; border-radius: 50%; } .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 { 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 { 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; } .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; } .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; } .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 { 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; } .primary:hover { background: #41453b; translate: 0 -1px; }
.textLink { display: inline-flex; align-items: center; gap: 8px; font-size: 12px; color: #6e7565; } .textLink { display: inline-flex; align-items: center; gap: 8px; font-size: 12px; color: #6e7565; }
.textLink:hover { color: #21221f; } .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; } .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; } .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; } .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 { 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:first-child { display: flex; align-items: center; gap: 7px; color: #69745e; }
.previewHeading > span:last-child { font: 8px ui-monospace, monospace; letter-spacing: 1px; } .previewHeading > span:last-child { font: 8px ui-monospace, monospace; letter-spacing: 1px; }
.tinyLogo { font-size: 20px; color: #4c5841; } .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; } .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 { position: absolute; inset: 0; width: 100%; height: 100%; stroke: #a3ad90; stroke-width: 1; }
.wires circle { fill: #a3ad90; } .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 { 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 > button:hover { translate: 0 -3px; }
.scene .activeCard { border-color: #9aa77d; box-shadow: 0 0 0 2px #9aa77d15, 0 4px 12px #32471508; } .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; } .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 { 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; } .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; } .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; } .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; } .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; } .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; } .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; } .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; } .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; } .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; } .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 { 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; } .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 { 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 > div { display: flex; gap: 5px; }
.previewFooter button { width: 5px; height: 5px; border-radius: 50%; background: #d8dfcc; } .previewFooter button { width: 5px; height: 5px; border-radius: 50%; background: #d8dfcc; }
.previewFooter button[aria-pressed="true"] { background: #879567; } .previewFooter button[aria-pressed="true"] { background: #879567; }
.previewFooter > span:last-child { margin-left: auto; font: 9px ui-monospace, monospace; } .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 { 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 > 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 { font-size: 13px; color: #646c58; letter-spacing: 0.3px; }
.manifestoStrip p span { color: #9ba28e; } .manifestoStrip p span { color: #9ba28e; }
/* 功能区域限制最大宽度;标题与说明左右对齐,功能卡使用三列网格。 */
.space { max-width: 1320px; margin: auto; padding: 105px 48px; } .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 { 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 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; } .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 { display: grid; grid-template-columns: repeat(3, 1fr); gap: 32px; }
.featureGrid article { min-width: 0; } .featureGrid article { min-width: 0; }
/* 每个功能插画使用固定高度裁剪容器,内部收集/连线/创作布局分别绘制。 */
.featureArt { position: relative; height: 185px; border: 1px solid #eaede4; border-radius: 6px; background: #f9faf6; overflow: hidden; margin-bottom: 25px; } .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 { 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 { 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(2) { rotate: 2deg; translate: 8px 0; }
.collectArt span:nth-child(3) { rotate: -1deg; translate: -5px 0; } .collectArt span:nth-child(3) { rotate: -1deg; translate: -5px 0; }
.collectArt svg { width: 14px; } .collectArt svg { width: 14px; }
/* 连线示例 SVG 铺满插画,两个端点卡片和装饰字形单独定位。 */
.connectArt svg { width: 100%; height: 100%; stroke: #b1b99e; } .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 { 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 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; } .connectArt i { position: absolute; top: 38px; right: 25px; color: #a0ad86; font: 32px Georgia, serif; }
/* 创作示例居中纸张和标题,编辑徽标独立固定在插画右下附近。 */
.createArt { display: grid; place-items: center; } .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 { 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 > 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; } .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; } .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; } .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 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; } .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)); } .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; } .quote { position: relative; }
.quoteMark { font: 80px/1 Georgia, serif; position: absolute; top: -30px; left: -12px; color: #d9dfca; } .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 h2 { position: relative; font: 31px/1.7 "Songti SC", Georgia, serif; color: #586449; }
.quote em { display: block; font-style: normal; } .quote em { display: block; font-style: normal; }
.quote p { font-size: 12px; color: #747a6d; line-height: 2; margin-top: 18px; } .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; } .signature { display: block; font: italic 13px Georgia, serif; color: #a3ad94; margin-top: 30px; }
/* 步骤纵向居中,每项含编号、文字和右侧不收缩的图标。 */
.workflowList { display: flex; flex-direction: column; justify-content: center; } .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 { display: flex; gap: 18px; padding: 24px 0; border-bottom: 1px solid #dfe5d4; }
.workflowList > div:first-child { padding-top: 0; } .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 h3 { font-size: 15px; font-weight: 500; color: #5e6e4e; margin-bottom: 8px; }
.workflowList p { font-size: 11px; line-height: 1.8; color: #747a6d; } .workflowList p { font-size: 11px; line-height: 1.8; color: #747a6d; }
.workflowList svg { flex-shrink: 0; color: #a5b38e; margin-left: auto; } .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; } .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 { 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 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 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; } .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 { 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 h2 { font-size: 38px; letter-spacing: -1.3px; font-weight: 500; margin: 18px 0; }
.closing p { font-size: 13px; color: #74796d; margin-bottom: 28px; } .closing p { font-size: 13px; color: #74796d; margin-bottom: 28px; }
@@ -110,8 +138,11 @@
.footer > p { font-size: 11px; color: #a1a992; } .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 > 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; } .footer > span { color: #abb39d; font: 8px ui-monospace, monospace; }
/* 断行标记默认隐藏,本文件没有另行显示它的媒体查询。 */
.mobileBreak { display: none; } .mobileBreak { display: none; }
/* 大屏仅增大首屏上下留白。 */
@media (min-width: 1400px) { .hero { padding-top: 105px; padding-bottom: 110px; } } @media (min-width: 1400px) { .hero { padding-top: 105px; padding-bottom: 110px; } }
/* 中屏压缩页面边距、标题、按钮及区块间距,仍保留主要双栏/三栏结构。 */
@media (max-width: 1050px) { @media (max-width: 1050px) {
.nav, .hero, .space, .faq, .footer { padding-left: 30px; padding-right: 30px; } .nav, .hero, .space, .faq, .footer { padding-left: 30px; padding-right: 30px; }
.hero { gap: 26px; } .hero { gap: 26px; }
@@ -124,6 +155,7 @@
.featureGrid { gap: 20px; } .featureGrid { gap: 20px; }
.faq { gap: 45px; } .faq { gap: 45px; }
} }
/* 窄屏隐藏导航链接,将首屏、功能、工作流和 FAQ 转为单列,并缩小装饰及页脚内容。 */
@media (max-width: 760px) { @media (max-width: 760px) {
.nav { height: 74px; gap: 15px; padding-left: 22px; padding-right: 22px; } .nav { height: 74px; gap: 15px; padding-left: 22px; padding-right: 22px; }
.nav nav { display: none; } .nav nav { display: none; }
@@ -159,6 +191,7 @@
.footer > p { display: none; } .footer > p { display: none; }
.footer > span { flex-basis: 100%; } .footer > span { flex-basis: 100%; }
} }
/* 极窄屏进一步压缩边距和预览卡片文字,隐藏品牌副标,避免最小视口拥挤。 */
@media (max-width: 380px) { @media (max-width: 380px) {
.page :global(.brand small) { display: none; } .page :global(.brand small) { display: none; }
.hero { padding-left: 20px; padding-right: 20px; } .hero { padding-left: 20px; padding-right: 20px; }
@@ -170,4 +203,5 @@
.ideaCard strong { margin-top: 8px; margin-bottom: 8px; } .ideaCard strong { margin-top: 8px; margin-bottom: 8px; }
.noteTag { font-size: 7px; padding: 2px; } .noteTag { font-size: 7px; padding: 2px; }
} }
/* 按减少动态效果偏好关闭链接、按钮和 FAQ 图标的过渡。 */
@media (prefers-reduced-motion: reduce) { .page a, .page button, .faqItem svg { transition: none; } } @media (prefers-reduced-motion: reduce) { .page a, .page button, .faqItem svg { transition: none; } }
+5
View File
@@ -1,3 +1,5 @@
/* IconButton 的共享尺寸、交互态和 tooltip 外观;尺寸从所在 Toolbar 的自定义属性继承,缺省为 32px 控件/20px 图标。 */
/* 固定 flex 尺寸避免拥挤时压缩;inline-flex 居中图标,透明背景显露宿主表面,文字颜色继承宿主。 */
.button { .button {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
@@ -13,10 +15,13 @@
color: inherit; color: inherit;
cursor: pointer; 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 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:hover { background: #e9e7e3; }
.button:focus-visible { outline: 2px solid #9d7d66; outline-offset: 2px; } .button:focus-visible { outline: 2px solid #9d7d66; outline-offset: 2px; }
.button:disabled { opacity: 0.45; cursor: not-allowed; } .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; } .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; } .arrow { fill: #49372e; }
+4
View File
@@ -1,3 +1,5 @@
/* Toolbar 共享表面:统一控件尺寸变量、排列间距、边框和背景;不负责自身在画布中的定位。 */
/* 提供后代按钮消费的 CSS 变量;半透明背景与 backdrop blur 处理工具栏背后的内容。 */
.surface { .surface {
--toolbar-icon-size: 20px; --toolbar-icon-size: 20px;
--toolbar-control-width: 32px; --toolbar-control-width: 32px;
@@ -18,5 +20,7 @@
} }
/* Text in the view controls already creates wider visual spacing. */ /* Text in the view controls already creates wider visual spacing. */
/* 含缩放文字的视图工具栏增加横向留白,不改变控件本身的尺寸。 */
.relaxed { column-gap: 6px; padding-inline: 6px; } .relaxed { column-gap: 6px; padding-inline: 6px; }
/* 分隔线保持 1px flex 基准,避免被相邻控件挤宽或挤没。 */
.divider { flex: 0 0 1px; width: 1px; height: 16px; margin: 0 2px; background: #ece6df; } .divider { flex: 0 0 1px; width: 1px; height: 16px; margin: 0 2px; background: #ece6df; }
@@ -1,9 +1,15 @@
/* EditableTitle 的只读标题/输入框切换样式;壳体作为错误提示的定位参照,网络和提交状态由组件控制。 */
/* 相对定位保留标题所在行,绝对定位错误框不会把工具栏向下撑开。 */
.shell { position: relative; display: flex; align-items: center; } .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; } .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 { overflow: hidden; white-space: nowrap; text-overflow: ellipsis; text-align: left; }
.title:hover:not(:disabled) { background: #f0ece6; } .title:hover:not(:disabled) { background: #f0ece6; }
.title[data-selected="true"] { background: #ede5dc; border-color: #d3c0b2; } .title[data-selected="true"] { background: #ede5dc; border-color: #d3c0b2; }
.title:disabled { cursor: default; } .title:disabled { cursor: default; }
/* 编辑态给定目标宽度但受上方 max-width 限制,readonly 时降低透明度。 */
.input { width: 220px; border-color: #a47764; background: white; outline: none; } .input { width: 220px; border-color: #a47764; background: white; outline: none; }
.input[readonly] { opacity: 0.65; } .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; } .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; }
@@ -1,3 +1,5 @@
/* 工作区壳体、目录、账户菜单与画布浮动标题;CSS Module 负责显示,账号/画布生命周期和侧栏开关由组件管理。 */
/* 侧栏宽度变量供侧栏本体和浮动标题共同消费,根容器至少占满动态视口高度。 */
.shell { .shell {
--sidebar-width: 270px; --sidebar-width: 270px;
display: flex; display: flex;
@@ -5,6 +7,7 @@
background: #fffefb; background: #fffefb;
color: #37352f; color: #37352f;
} }
/* 侧栏插槽 sticky 固定在滚动视口顶部;裁剪展开过渡期间的内容,层级高于画布。 */
.sidebarSlot { .sidebarSlot {
position: sticky; position: sticky;
top: 0; top: 0;
@@ -14,6 +17,7 @@
overflow: hidden; overflow: hidden;
z-index: 30; z-index: 30;
} }
/* 侧栏采用纵向 flex,导航区可滚动、账户区留在底部;最大宽度给主区域保留至少 40px。 */
.sidebar { .sidebar {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -29,30 +33,38 @@
letter-spacing: -0.15px; letter-spacing: -0.15px;
color: #37352f; color: #37352f;
} }
/* 顶行按钮分居两端;搜索框 focus-within 让输入聚焦时整个框有反馈。 */
.topRow { display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px; color: #85817b; } .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 { 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: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 { min-width: 0; width: 100%; padding: 0; background: transparent; border: 0; outline: none; font: inherit; color: #37352f; }
.search input::placeholder { color: #9a9690; } .search input::placeholder { color: #9a9690; }
/* flex:1 配合 min-height:0 才允许长目录在侧栏内部滚动。 */
.navigation { flex: 1; min-height: 0; overflow-y: auto; padding-top: 10px; } .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, .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; } .home { width: fit-content; font-weight: 600; background: #ecebe8; }
.pageLink { margin: 1px 0; } .pageLink { margin: 1px 0; }
.pageLink svg { flex-shrink: 0; color: #87837c; } .pageLink svg { flex-shrink: 0; color: #87837c; }
.pageLink span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .pageLink span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.pageLink:hover, .pageLink[aria-current="page"] { background: #eae7e2; } .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; } .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; } .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; } .error { margin: 6px 4px; font-size: 12px; color: #b14b3c; overflow-wrap: anywhere; }
/* 账户区域向侧栏底部靠齐,按钮占满宽度以便整行点击。 */
.accountArea { margin-top: auto; padding-top: 6px; } .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 { 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; } .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 { 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; } .avatar img { display: block; width: 100%; height: 100%; object-fit: cover; }
.avatarLarge { flex-basis: 36px; width: 36px; height: 36px; font-size: 16px; } .avatarLarge { flex-basis: 36px; width: 36px; height: 36px; font-size: 16px; }
/* 账户文字允许收缩和省略,副文字在此布局隐藏。 */
.accountText { display: flex; flex: 1; flex-direction: column; min-width: 0; } .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 strong { font-size: 13px; font-weight: 550; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
.accountText small { display: none; } .accountText small { display: none; }
/* 原生 popover 的面板位置/宽度约束;自身可滚动且从左下角进入,不用 CSS 切换 open 状态。 */
.accountPopup { .accountPopup {
position: fixed; position: fixed;
inset: auto auto 50px 8px; inset: auto auto 50px 8px;
@@ -73,31 +85,40 @@
letter-spacing: -0.15px; letter-spacing: -0.15px;
transform-origin: bottom left; transform-origin: bottom left;
} }
/* 仅 popover 打开时播放进入动画;backdrop 保持透明,但是否可交互仍由 popover 行为决定。 */
.accountPopup:popover-open { animation: account-enter 130ms ease-out; } .accountPopup:popover-open { animation: account-enter 130ms ease-out; }
.accountPopup::backdrop { background: transparent; } .accountPopup::backdrop { background: transparent; }
/* 菜单头、工作区字母头像及身份行共用纵向文字布局,长名称省略。 */
.accountHeading { display: flex; align-items: center; gap: 10px; padding: 8px 7px 10px; } .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; } .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 > 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 strong { font-size: 14px; font-weight: 600; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
.accountHeading small { color: #8b8780; font-size: 12px; } .accountHeading small { color: #8b8780; font-size: 12px; }
/* 菜单分组和身份区域提供内间距,邮箱允许断行;工作区列表限制高度后独立滚动。 */
.menuGroup { padding: 1px 0; } .menuGroup { padding: 1px 0; }
.popupIdentity { display: flex; align-items: center; gap: 8px; padding: 8px 7px 7px; } .popupIdentity { display: flex; align-items: center; gap: 8px; padding: 8px 7px 7px; }
.popupIdentity strong { font-size: 12px; font-weight: 500; } .popupIdentity strong { font-size: 12px; font-weight: 500; }
.popupIdentity small { color: #8b8780; font-size: 11px; overflow-wrap: anywhere; letter-spacing: 0; } .popupIdentity small { color: #8b8780; font-size: 11px; overflow-wrap: anywhere; letter-spacing: 0; }
.workspaceList { max-height: min(220px, 30dvh); overflow-y: auto; } .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 { 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 > span:not(.workspaceMini) { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.menuItem > svg { flex-shrink: 0; } .menuItem > svg { flex-shrink: 0; }
.menuItem:hover { background: #f1f0ed; } .menuItem:hover { background: #f1f0ed; }
.menuItem:disabled { opacity: 0.45; cursor: not-allowed; } .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; } .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; } .newWorkspace { color: #2383e2; }
.logout { color: #78746d; font-size: 12px; } .logout { color: #78746d; font-size: 12px; }
.separator { height: 1px; margin: 6px 7px; background: #eeece8; } .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); } .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); } .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; } .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; } .settingsPanel { color: #37352f; border-color: #e6e2dc; border-radius: 12px; }
/* 设置表单纵向排布身份、字段及操作;提交按钮区分主动作,禁用时保留位置。 */
.settingsForm { display: flex; flex-direction: column; gap: 16px; padding-top: 4px; } .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; } .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; } .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 { 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[type="submit"] { background: #37352f; color: white; border-color: #37352f; }
.settingsActions button:disabled { opacity: 0.5; cursor: not-allowed; } .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; } .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; } .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; } .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); } } @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 (max-width: 600px) { .shell { --sidebar-width: 240px; } }
/* 减少动态效果偏好下关闭菜单进入动画;浮动条过渡在文件后部单独关闭。 */
@media (prefers-reduced-motion: reduce) { .accountPopup:popover-open { animation: none; } } @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 { 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 img { display: block; width: var(--toolbar-icon-size, 20px); height: var(--toolbar-icon-size, 20px); }
.logoLink:hover { background: #e9e7e3; } .logoLink:hover { background: #e9e7e3; }
.logoLink:focus-visible { outline: 2px solid #9d7d66; outline-offset: 2px; } .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 { 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; } .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; } .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; } .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; } .floatingTitle { flex: 1 1 auto; min-width: 0; max-width: 240px; margin: 0; color: #37352f; font-size: 14px; line-height: var(--toolbar-control-height, 32px); font-weight: 500; overflow: visible; }
/* 中等窄屏降低标题最大宽度,不改变标题内容或编辑状态。 */
@media (max-width: 700px) { .floatingTitle { max-width: 180px; } } @media (max-width: 700px) { .floatingTitle { max-width: 180px; } }
/* 极窄且侧栏仍展开时隐藏 logo/标题/分隔,保留浮动条其余控件。 */
@media (max-width: 420px) { @media (max-width: 420px) {
.floating[data-sidebar-collapsed="false"] .logoLink, .floating[data-sidebar-collapsed="false"] .logoLink,
.floating[data-sidebar-collapsed="false"] .floatingTitle, .floating[data-sidebar-collapsed="false"] .floatingTitle,
.floating[data-sidebar-collapsed="false"] > span[aria-hidden="true"] { display: none; } .floating[data-sidebar-collapsed="false"] > span[aria-hidden="true"] { display: none; }
} }
/* 按用户偏好关闭浮动条位移/宽度过渡。 */
@media (prefers-reduced-motion: reduce) { .floating { transition: none; } } @media (prefers-reduced-motion: reduce) { .floating { transition: none; } }
/* 递归目录清除列表默认缩进,层级缩进由组件布局提供;行本身允许标题收缩。 */
.directoryList { list-style: none; padding: 0; margin: 0; } .directoryList { list-style: none; padding: 0; margin: 0; }
.directoryRow { display: flex; align-items: center; min-width: 0; } .directoryRow { display: flex; align-items: center; min-width: 0; }
.directoryRow .pageLink { flex: 1; min-width: 0; padding-left: 3px; } .directoryRow .pageLink { flex: 1; min-width: 0; padding-left: 3px; }
/* 可折叠项按钮与叶项占位共享 20px 列宽,保持各行标题对齐;焦点内描边不扩尺寸。 */
.directoryToggle, .directorySpacer { flex: 0 0 20px; width: 20px; height: 26px; } .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 { display: grid; place-items: center; border: 0; border-radius: 4px; background: transparent; color: #87837c; cursor: pointer; }
.directoryToggle:hover { background: #eae7e2; } .directoryToggle:hover { background: #eae7e2; }
+8 -3
View File
@@ -2,6 +2,7 @@
* 嵌套 canvas 的坐标换算:Transform 把一个 canvas 的局部空间映射到项目世界空间。 * 嵌套 canvas 的坐标换算:Transform 把一个 canvas 的局部空间映射到项目世界空间。
* 统一采用平移、等比缩放和旋转,不处理 skew/非等比父变换。owner 改变时通过世界空间中转, * 统一采用平移、等比缩放和旋转,不处理 skew/非等比父变换。owner 改变时通过世界空间中转,
* 保持节点外观和连线曲线位置;表现单位的 data 修正由结构/编辑规划器另行生成。 * 保持节点外观和连线曲线位置;表现单位的 data 修正由结构/编辑规划器另行生成。
* 数学辅助函数依赖已校验的有限几何/正比例;它们不逐项代替 schema、registry 或可见性校验。
*/ */
import type { Document, Geometry, Id, Json, Point } from './types.ts'; import type { Document, Geometry, Id, Json, Point } from './types.ts';
import { ancestors } from './document.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); } 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 对象视为字段表,空值/原始值/数组按空表处理,不修改源数据。 */ /** 将可识别的 JSON 对象视为字段表,空值/原始值/数组按空表处理,不修改源数据。 */
const object = (value: Json | undefined) => value && typeof value === 'object' && !Array.isArray(value) ? value : {}; const object = (value: Json | undefined) => value && typeof value === 'object' && !Array.isArray(value) ? value : {};
/** 对已识别的 bezier-chain 换空间;未知路径原样返回,严格写入边界由 convertPath 把关。 /** type/knots 可识别时逐点换空间;其它路径原样返回,不在本函数完整验证曲线形状。
* knot.position 是位置,in/out 与首尾 control 是偏移向量,向量不能叠加平移。 */ * knot.position 是位置,in/out 与首尾 control 是偏移向量,向量不能叠加平移。 */
export function transformPath(path: Json, transform: Transform, inverse = false): Json { export function transformPath(path: Json, transform: Transform, inverse = false): Json {
// 直接调用可对部分字段做转换;实际需要换空间时,convertPath 会先检查支持的路径格式。
const raw = object(path); const raw = object(path);
if (raw.type !== 'bezier-chain' || !Array.isArray(raw.knots)) return path; if (raw.type !== 'bezier-chain' || !Array.isArray(raw.knots)) return path;
/** 保留点的扩展字段;vector 模式把变换平移归零,仅施加旋转/比例。 */ /** 保留点的扩展字段;vector 模式把变换平移归零,仅施加旋转/比例。 */
@@ -42,13 +44,15 @@ export function transformPath(path: Json, transform: Transform, inverse = false)
/** 合成父变换与分区入口几何,得到子 canvas 内容区原点/比例/旋转。 /** 合成父变换与分区入口几何,得到子 canvas 内容区原点/比例/旋转。
* 24/36 是格式 1 的内容内边距,要乘 presentation.unitScale,和 renderer 的投影保持一致。 */ * 24/36 是格式 1 的内容内边距,要乘 presentation.unitScale,和 renderer 的投影保持一致。 */
export function childCanvasTransform(parent:Transform,geometry:Geometry,presentation:Json|undefined):Transform { 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 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 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}); 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}; 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 { export function canvasTransform(document:Document,canvasId:Id):Transform {
let result:Transform={...origin}; let result:Transform={...origin};
for(const id of ancestors(document,canvasId).reverse()){ 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; 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}; 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 { export function convertPath(path:Json,from:Transform,to:Transform):Json {
if(path===null||sameTransform(from,to))return path; if(path===null||sameTransform(from,to))return path;
if(!connectionPathValid(path))throw fault('unsupported_coordinate_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 type { Json } from './types.ts';
import { fault } from './values.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]; const {value, depth} = work.pop()!, key = path[depth];
if (key === '*') { if (key === '*') {
if (!Array.isArray(value)) throw fault('invalid_copy_reference'); 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)}; } 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 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)) { } 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> { export function* copyReferenceIds(data: Json, ref: {path: readonly string[]; many?: boolean}): Generator<string> {
for (const {parent, key} of copySlots(data, ref.path)) { for (const {parent, key} of copySlots(data, ref.path)) {
const value = parent[key]; if (value === null) continue; const value = parent[key]; if (value === null) continue;
+11 -5
View File
@@ -1,6 +1,7 @@
/** /**
* 同步事务内的大批创建优化。追加表用固定成员的 base+added 视图避免每新增一项都复制整表, * 同步事务内的大批创建优化。追加表用固定成员的 base+added 视图避免每新增一项都复制整表,
* 累计 64 项后折叠为新 base。只用于尚未发布的 draft,提交前统一物化为标准文档表。 * 已有追加层达到 64 项时,下一次追加先折叠为新 base。只用于未发布 draft,提交前统一物化文档表。
* 输入表须已具固定成员;视图借用 base 和未变记录,不是任意可变 Map 的隔离器。
*/ */
import {detachElementData} from './element-data.ts'; import {detachElementData} from './element-data.ts';
import type {CanvasRecord,Change,Document,ElementRecord,Id} from './types.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>{ function append<T>(input:ReadonlyMap<Id,T>,id:Id,value:T):ReadonlyMap<Id,T>{
if(input.has(id))throw fault('duplicate_identity',[id]); if(input.has(id))throw fault('duplicate_identity',[id]);
let previous=overlays.get(input) as {base:ReadonlyMap<Id,T>;added:ReadonlyMap<Id,T>}|undefined; 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; 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); const base=previous?.base??(overlays.has(input)?readMap(input):input),added=new Map(previous?.added);
added.set(id,value); added.set(id,value);
/** 迭代先 base 后 added,保持插入顺序;视图的 get/has 优先查 appended 成员。 */ /** 迭代先 base 后 added,保持插入顺序;视图的 get/has 优先查 appended 成员。 */
const entries=function*():Generator<[Id,T],undefined,unknown>{yield* base;yield* added;return undefined;}; 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。 // 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, 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, *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. */ /** Internal editor path: input tables already have fixed membership. */
/** 仅接收 before=null 且 after 存在的创建变化,校验 JSON/ID 后捕获记录并追加相应表。 /** 仅接收 before=null 且 after 存在的创建变化,校验 JSON/ID 后捕获记录并追加相应表。
* 整图依赖可在后续命令补齐,因此此处不提前做完整树/端点校验。 */ * 整图依赖可在后续命令补齐,因此此处不提前做完整树/端点校验。
* 本层只检查创建形状、JSON、ID 一致及活表重复;registry 支持与墓碑禁复用由 editor/最终校验负责。 */
export function stageCreation(document:Document,change:Extract<Change,{kind:'canvas'|'element'}>):Document{ export function stageCreation(document:Document,change:Extract<Change,{kind:'canvas'|'element'}>):Document{
if(change.before!==null||!change.after)throw fault('invalid_creation_draft'); 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(!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]); if(change.after.id!==change.id)throw fault('identity_mismatch',[change.id]);
const value=change.kind==='element'?detachElementData(change.after as ElementRecord):freeze(clone(change.after)); const 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; 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. */ /** Materialize transaction-local append tables before publication. */
/** 只有已标记的 draft 需要通过空 changes 重新捕获表;普通文档原样复用。 */ /** 只有已标记 draft 通过空 changes 物化成工厂 envelope/readMap;其它文档原样返回。
* 不在这里校验 schema/树,也不把任何外来 Document 自动视为已校验或深度不可变。 */
export const materializeCreations=(document:Document)=>drafts.has(document)?applyChanges(document,[]):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. * Already-issued operation bodies are deliberately outside this function.
*/ */
/** 仅原创建仍在 undo、目标仍活且未被用户缩放时,准备默认尺寸与历史修正。 /** 仅原创建仍在 undo、目标仍活且未被用户缩放时,准备默认尺寸与历史修正。
* 返回候选 document/history/changes;原输入始终不变,失败不留下半份历史。 */ * 返回候选 document/history/changes,不发布状态;输入须来自已捕获的不可变文档与历史。
* 这里按 geometry.resize 能力判定,不限定 componentId 必须是 image;图片授权条件由外层 grant 决定。 */
export function planCreationSize(document: Document, history: HistoryState, registry: Registry, export function planCreationSize(document: Document, history: HistoryState, registry: Registry,
entryId: Id, target: Target, size: { readonly width: number; readonly height: number }) { entryId: Id, target: Target, size: { readonly width: number; readonly height: number }) {
/** 统一将失败绑定到目标 ID,便于调用方解释为何不能再自动调整。 */ /** 统一将失败绑定到目标 ID,便于调用方解释为何不能再自动调整。 */
const fail = (code: string): never => { throw fault(code, [target.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'); 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 index = history.undo.indexOf(entryId), entry = history.entries.get(entryId);
const current = document.elements.get(target.id); const current = document.elements.get(target.id);
if (index < 0 || !entry || current?.incarnation !== target.incarnation || current.structure !== 'node') fail('creation_size_target_changed'); 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 units = unitScale(created.data.presentation);
const baseline = { width: created.geometry.width / units, height: created.geometry.height / units }; const baseline = { width: created.geometry.width / units, height: created.geometry.height / units };
const ratio = { width: size.width / baseline.width, height: size.height / baseline.height }; const 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'; const matches = (element: ElementRecord | undefined) => element?.incarnation === target.incarnation && element.structure === 'node';
/** 比较宽高除 unitScale 后的视觉值,移动/换 owner 不等同于用户主动缩放。 */ /** 比较宽高除 unitScale 后的视觉值,移动/换 owner 不等同于用户主动缩放。 */
const unchanged = (element: ElementRecord | undefined) => matches(element) && element!.structure === 'node' 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 // Rewind on private roots. applyChanges intentionally preserves historical
// incarnations here; this is inspection, not a user-visible delete/restore. // incarnations here; this is inspection, not a user-visible delete/restore.
let original = document; let original = document;
// 从创建项起回退已应用历史,得到创建前私有基线;不产生用户可见的删除/恢复代次。 // 从创建项起回退全部已应用项,得到创建前私有基线;此处用 applyChanges 而非 planHistoryChanges,
// 因而允许检查途中存在历史代次/墓碑组合,不把它们作为最终文档发布。
const applied = history.undo.slice(index); const applied = history.undo.slice(index);
for (const id of [...applied].reverse()) original = applyChanges(original, invert(history.entries.get(id)!.changes)); for (const id of [...applied].reverse()) original = applyChanges(original, invert(history.entries.get(id)!.changes));
// original 追踪旧时间线,amended 追踪修正后的时间线;inheriting 只在匹配创建之后开启。
let amended = original, inheriting = false; let amended = original, inheriting = false;
const entries = new Map(history.entries); const entries = new Map(history.entries);
// 先按 undo 的正向顺序重建已应用项,再倒序遍历 redo 栈还原未来重做时间线。 // 先按 undo 的正向顺序重建已应用项,再倒序遍历 redo 栈还原未来重做时间线。
@@ -63,11 +67,12 @@ export function planCreationSize(document: Document, history: HistoryState, regi
const id = timeline[cursor], source = entries.get(id)!; const id = timeline[cursor], source = entries.get(id)!;
const end = applyChanges(original, source.changes), isApplied = cursor < applied.length; const end = applyChanges(original, source.changes), isApplied = cursor < applied.length;
if (isApplied && !unchanged(end.elements.get(target.id))) fail('creation_size_user_resized'); 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. // A future resize preserves each dimension explicitly changed by the user.
// Only its before value inherits the asynchronous default correction. // 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)); const futureResize = !isApplied && inheriting && matches(end.elements.get(target.id)) && !unchanged(end.elements.get(target.id));
let resizeConsumed = false; let resizeConsumed = false;
/** redo 中用户显式改过的维度停止继承默认比例;仅修正其 before,不改用户指定的 after。 */ /** redo 中按宽/高分别判断:被用户改过的维度将 ratio 置 1,保留其 after;
* before 重取修正时间线,未被用户改变的另一维仍可继承默认比例。每项最多消费一次。 */
const preserveFutureResize = () => { const preserveFutureResize = () => {
if (!futureResize || resizeConsumed) return; if (!futureResize || resizeConsumed) return;
const value = end.elements.get(target.id) as ElementRecord & { structure: 'node' }; 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; resizeConsumed = true;
}; };
const changes: Change[] = []; const changes: Change[] = [];
// 每条 next 都基于 amended 的实际 before 重建,保证修正后的连续历史仍条件匹配。 // 目标整记录/geometry 变化从 amended 重取 before;其它变化保留原条件,继续用 applyChanges 校验。
for (const change of source.changes) { for (const change of source.changes) {
let next = change; let next = change;
// 整记录变化也要重取真实 before;其它对象/字段沿用原变化,保证整条时间线仍按原次序检查。
if (change.kind === 'element' && change.id === target.id) { if (change.kind === 'element' && change.id === target.id) {
const before = amended.elements.get(target.id) ?? null; const before = amended.elements.get(target.id) ?? null;
const birth = id === entryId && change === creations[0]; const birth = id === entryId && change === creations[0];
@@ -98,6 +104,7 @@ export function planCreationSize(document: Document, history: HistoryState, regi
amended = applyChanges(amended, [next]); amended = applyChanges(amended, [next]);
changes.push(next); changes.push(next);
} }
// 整项完成后再推进旧时间线;只有变化内容确实不同才替换 entries 中这一项,operationIds/标签保持。
original = end; original = end;
if (!equal(changes, source.changes)) entries.set(id, freeze({ ...source, changes })); 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); const nextDocument = applyChanges(document, changes);
validateDocument(nextDocument, registry); 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 handler = registry.handlers.get(current!.componentId)?.get('geometry.resize');
const proposed = handler!({ element: current!, input: freeze({ patch: { width: after.width, height: after.height } }) }); 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'); 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'); 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) }) }); 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 {Point} from './types.ts';
import type {Cubic} from './geometry.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。 * maxSteps 限总工作,maxDepth 限细分深度,error 控制可接受平坦度;stack 用显式 DFS。
* 每轮:包围盒排除→near±flat 判定→足够平/深度已满则记 possible→否则入栈左右子曲线。 * 每轮:包围盒排除→near±flat 判定→足够平/深度已满则记 possible→否则入栈左右子曲线。
* near 是到端点弦线段距离,flat 包括两控制点到该弦的偏差及 roundoff。 * 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}){ 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; 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; 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}; 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}); 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}; 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. // Private shape provenance: values may still be mutable and require capture.
// Always copy; external Object.freeze is never accepted as ownership evidence. // Always copy; external Object.freeze is never accepted as ownership evidence.
// WeakSet 以新容器身份为键,不按内容相等判断;容器不再被外部引用时,标记不会单独阻止回收。
const owned = new WeakSet<object>(); const owned = new WeakSet<object>();
/** 消费 iterable 得到新数组并浅冻结;不会冻结数组中的业务值。 */ /** 消费 iterable 得到新数组并浅冻结;不会冻结数组中的业务值。 */
export function dataArray<T>(values: Iterable<T>): readonly T[] { export function dataArray<T>(values: Iterable<T>): readonly T[] {
const result = Object.freeze(Array.from(values)); owned.add(result); return result; const result = Object.freeze(Array.from(values)); owned.add(result); return result;
} }
/** 浅复制对象的可枚举自有属性再冻结外壳;不是任意对象的深复制器。 */ /** 浅复制对象的可枚举自有属性再冻结外壳;不是任意对象的深复制器。
* 对象展开会读取 getter 并保留嵌套值引用,也不保留原对象原型或属性描述符。 */
export function dataRecord<T extends object>(value: T): Readonly<T> { export function dataRecord<T extends object>(value: T): Readonly<T> {
const result = Object.freeze({...value}); owned.add(result); return result; 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'; import {clone,equal,fault,freeze} from './values.ts';
/** Remove an owning canvas, retaining its entry and promoting only direct members. */ /** 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[]}{ 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]); const canvas=document.canvases.get(canvasId);if(!canvas)throw fault('canvas_not_loaded',[canvasId]);
if(!canvas.parent)throw fault('cannot_dissolve_root',[canvasId]); if(!canvas.parent)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'){ 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']); 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); 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}); commands.push({type:'element.move',target,canvasId:parent});
changes.push({kind:'field',target,field:'ownerCanvasId',before:canvasId,after:parent}); changes.push({kind:'field',target,field:'ownerCanvasId',before:canvasId,after:parent});
if(!equal(geometry,element.geometry))changes.push({kind:'field',target,field:'geometry',before:element.geometry,after:geometry}); if(!equal(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 空间一并转换。 // 在私有候选文档上计算边的新公共上级,并把曲线从旧 owner 空间一并转换。
const promoted=applyChanges(document,changes);changes.push(...repairConnectionOwners(document,promoted)); const promoted=applyChanges(document,changes);changes.push(...repairConnectionOwners(document,promoted));
// Only declared canvas references have meaning here. Opaque payloads are retained. // 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()){ for(const element of promoted.elements.values()){
if(registry.resolve(element)!=='known')continue; if(registry.resolve(element)!=='known')continue;
const references=registry.components.get(element.componentId)?.copy?.references.filter(reference=>reference.target==='canvas')??[]; 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;}; 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); 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])){ 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']); 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]}); 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]}}); commands.push({type:'element.data',target:{id:element.id,incarnation:element.incarnation},patch:{[key]:data[key]}});
} }
} }
// 保留原入口元素与边 ID;只移除 canvas 记录,不用 removalChanges 的子树删除语义。
return freeze({changes,commands}); 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); export const isFactoryDocument=(document:Document)=>factoryDocuments.has(document);
/** 捕获输入记录、拒绝同一表内重复 ID,并脱离调用者的可变数据。 /** 捕获输入记录、拒绝同一表内重复 ID,并脱离调用者的可变数据。
* 不在此处验证整棵树或所有跨记录引用;完整发布前仍需 validateDocument。 */ * 不在此处验证整棵树、组件 schema 或所有跨记录引用;完整发布前仍需 validateDocument。
* canvas 总是复制冻结,element 可复用元素捕获模块已持有的记录;墓碑复制表成员,元素固有证明只省去重复形状遍历。 */
export function createDocument(projectId: Id, canvases: readonly CanvasRecord[], elements: readonly ElementRecord[] = [], tombstones: ReadonlyMap<Id, number> = new Map()): Document { export function createDocument(projectId: Id, canvases: readonly CanvasRecord[], elements: readonly ElementRecord[] = [], tombstones: ReadonlyMap<Id, number> = new Map()): Document {
for (const element of elements) { const id = element.id; if (!staticElementProofs.has(element)&&!validJson(element)) throw fault('invalid_element', [id]); } for (const 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'); for (const canvas of canvases) if (!validJson(canvas)) throw fault('invalid_canvas');
@@ -61,14 +62,19 @@ const staticElementProofs=new WeakSet<object>();
/** 校验单条记录的基础形状、墓碑代次和已知组件数据。 /** 校验单条记录的基础形状、墓碑代次和已知组件数据。
* staticElementProofs 仅缓存已捕获记录的固有形状;墓碑和 registry 相关检查每次仍执行。 */ * staticElementProofs 仅缓存已捕获记录的固有形状;墓碑和 registry 相关检查每次仍执行。 */
export function validateElementRecord(element: ElementRecord, registry: Registry, tombstones: ReadonlyMap<Id,number> = new Map(), componentData = true): void { export function validateElementRecord(element: ElementRecord, registry: Registry, tombstones: ReadonlyMap<Id,number> = new Map(), componentData = true): void {
// 静态证明仅随不可变记录身份复用,不能因浅冻结或一个文档 envelope 标记就跳过校验。
const proven=!!element&&staticElementProofs.has(element); 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]); if (!proven&&(!element || !validJson(element) || typeof element.id !== 'string' || !element.id || typeof element.ownerCanvasId !== 'string' || !element.ownerCanvasId || typeof element.componentId !== 'string' || !element.componentId || !['node','connection'].includes(element.structure) || !Number.isSafeInteger(element.incarnation) || element.incarnation < 1 || !Number.isSafeInteger(element.dataVersion) || element.dataVersion < 1 || !element.data || typeof element.data !== 'object' || Array.isArray(element.data) || !validJson(element.data))) throw fault('invalid_element', [element?.id]);
// 墓碑属于当前文档上下文,即使同一记录以前通过过形状校验,也要重新核对已死亡代次。
const dead=tombstones.get(element.id);if(dead!==undefined&&element.incarnation<=dead)throw fault('stale_incarnation',[element.id]); const 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); 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]); 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(proven)return;
if(element.structure==='node'){if(!geometryValid(element.geometry))throw fault('invalid_geometry',[element.id]);} 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]); 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); 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>{ function* validateDocumentChecks(document:Document,registry:Registry,componentData:boolean):Generator<void,void>{
if(!document.canvases.size)throw fault('missing_root'); if(!document.canvases.size)throw fault('missing_root');
for(const[id,incarnation]of document.tombstones){yield;validateTombstone(id,incarnation);} for(const[id,incarnation]of document.tombstones){yield;validateTombstone(id,incarnation);}
// 一次遍历累计入口占用和根数;必须继续到最后,才能得到根数及所有元素的完整结论。
const entries=new Set<Id>();let roots=0; const entries=new Set<Id>();let roots=0;
for(const canvas of document.canvases.values()){yield;roots+=validateCanvasInDocument(document,canvas,entries);} for(const canvas of document.canvases.values()){yield;roots+=validateCanvasInDocument(document,canvas,entries);}
if(roots!==1)throw fault('invalid_roots'); 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);} export function* validateDocumentTopologySteps(document:Document,registry:Registry):Generator<void,void>{yield* validateDocumentChecks(document,registry,false);}
/** 含组件 payload 的完整分片校验;必须迭代到结束才证明整份文档通过。 */ /** 含组件 payload 的完整分片校验;必须迭代到结束才证明整份文档通过。 */
export function* validateDocumentSteps(document:Document,registry:Registry):Generator<void,void>{yield* validateDocumentChecks(document,registry,true);} 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{ export function validateDocument(document:Document,registry:Registry):void{
if(!document.canvases.size)throw fault('missing_root'); if(!document.canvases.size)throw fault('missing_root');
for(const[id,incarnation]of document.tombstones)validateTombstone(id,incarnation); 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'); if(roots!==1)throw fault('invalid_roots');
for(const element of document.elements.values())validateElementInDocument(document,element,registry,true); 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[]}; 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 { export function applyChanges(document: Document, changes: readonly Change[], conditional = true): Document {
return applyChangeBatches(document,[{changes}],conditional,false); return applyChangeBatches(document,[{changes}],conditional,false);
} }
/** Ordered conditional replay against one private draft; no intermediate document escapes. */ /** 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 { export function replayChanges(document:Document,batches:Iterable<ReplayChangeBatch>):Document {
return applyChangeBatches(document,batches,true,true); return applyChangeBatches(document,batches,true,true);
} }
/** 三类变化共用的执行器:field 改字段、element 增删元素、canvas 改树记录。 /** 三类变化共用的执行器:field 改字段、element 增删元素、canvas 改树记录。
* 仅在全部循环成功后返回冻结 envelope,不泄漏半成品;此处不做整图合法性校验。 */ * 仅在全部循环成功后返回冻结 envelope,不泄漏半成品;此处不做整图合法性校验。 */
function applyChangeBatches(document:Document,batches:Iterable<ReplayChangeBatch>,conditional:boolean,replay:boolean):Document { 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); 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) { for (const batch of batches) for (const change of batch.changes) {
if(replay){ 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); if (change.afterPresent === false) delete data[dataKey]; else data[dataKey] = clone(change.after);
elements.set(element.id, freeze({ ...element, data })); elements.set(element.id, freeze({ ...element, data }));
} else elements.set(element.id, freeze({ ...element, [change.field]: clone(change.after) }) as ElementRecord); } 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') { } else if (change.kind === 'element') {
if (conditional && !equal(elements.get(change.id) || null, change.before)) throw fault('element_conflict', [change.id]); if (conditional && !equal(elements.get(change.id) || null, change.before)) throw fault('element_conflict', [change.id]);
if (change.after) { if (change.after) {
@@ -184,6 +196,7 @@ function applyChangeBatches(document:Document,batches:Iterable<ReplayChangeBatch
} }
/** 检测回放目标效果是否已经存在;字段目标必须仍是同一个 incarnation 才可视为满足。 */ /** 检测回放目标效果是否已经存在;字段目标必须仍是同一个 incarnation 才可视为满足。 */
function changeSatisfied(elements:ReadonlyMap<Id,ElementRecord>,canvases:ReadonlyMap<Id,CanvasRecord>,change:Change):boolean { 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==='canvas')return equal(canvases.get(change.id)??null,change.after);
if(change.kind==='element')return equal(elements.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); const element=elements.get(change.target.id);
+13 -6
View File
@@ -2,6 +2,7 @@
* 可暂停/恢复的深复制准备状态机。检查点保存 ID、阶段、游标、DFS stack 和稳定映射, * 可暂停/恢复的深复制准备状态机。检查点保存 ID、阶段、游标、DFS stack 和稳定映射,
* 不保存元素 payload 的第二份副本;输出记录是由源与映射确定性重建的缓存。 * 不保存元素 payload 的第二份副本;输出记录是由源与映射确定性重建的缓存。
* 每次 step 只推进有限语义工作,但持久源校验、分片调度、checkpoint 保存和渐进加入由 runtime 负责。 * 每次 step 只推进有限语义工作,但持久源校验、分片调度、checkpoint 保存和渐进加入由 runtime 负责。
* 构造器仍同步展开源表并校验保存状态,step 也没有按字节/毫秒强制切断单记录转换。
*/ */
import {detachJsonData} from './json-data.ts'; import {detachJsonData} from './json-data.ts';
import type { CanvasRecord, Change, Document, ElementRecord, Id, Point, Registry, Target } from './types.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>; export type DuplicateCheckpoint=Immutable<MutableCheckpoint>;
/** 形状辅助检查:数组成员必须是非空字符串;是否存在/重复由后续检查负责。 */ /** 形状辅助检查:数组成员必须是非空字符串;是否存在/重复由后续检查负责。 */
const ids=(value:unknown):value is Id[]=>Array.isArray(value)&&value.every(id=>typeof id==='string'&&!!id); 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{ export function validateDuplicateCheckpoint(value:DuplicateCheckpoint):void{
if(!value||!validJson(value)||value.version!==1)throw fault('copy_checkpoint_interpreter_unavailable'); 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'); 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'); 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/选区/偏移/预留映射,任一不符拒绝续跑。 /** 创建或恢复解释器;绑定 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}){ export function createDuplicatePlanner(document:Document,registry:Registry,selection:readonly Target[],options:DuplicateOptions,binding:{sourceToken:string;schemaToken:string;checkpoint?:DuplicateCheckpoint;topologyOnly?:boolean}){
const offset=options.worldOffset??{x:32,y:32},idFactory=options.createId,suppliedMapping=options.mapping?freeze(clone(options.mapping)):undefined; const offset=options.worldOffset??{x:32,y:32},idFactory=options.createId,suppliedMapping=options.mapping?freeze(clone(options.mapping)):undefined;
// 从检查点复制出私有可变 state,旧 checkpoint 保持不变;新任务从 selection 开始。 // 从检查点复制出私有可变 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}))]:[]; 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 内部可读其增长,不作为第二份发布文档。 // 这些 Map 是派生输出缓存,overlay 内部可读其增长,不作为第二份发布文档。
const outputElements=new Map<Id,ElementRecord>(),outputCanvases=new Map<Id,CanvasRecord>(),output=duplicateOverlay(document,outputElements,outputCanvases),changes:Change[]=[]; 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; let treeCache=0,edgeCache=0,sourceChecks:Generator<void>|undefined=(binding.topologyOnly?validateDocumentTopologySteps:validateDocumentSteps)(document,registry),outputChecks:Generator<void>|undefined,validated=false;
/** 切换解释阶段并重置/指定该阶段 cursor,不改稳定 ID 或输出前缀。 */ /** 切换解释阶段并重置/指定该阶段 cursor,不改稳定 ID 或输出前缀。 */
const phase=(next:DuplicateCheckpoint['phase'],cursor=0)=>{state.phase=next;state.cursor=cursor;}; 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 同时覆盖源、墓碑与全部新值。 */ /** 只为尚未分配的源项取新 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;} 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({ return Object.freeze({
/** 源已校验且刚到 emit,范围/映射固定,可以交给运行时逐记录转换,不要求完整计划已生成。 */ /** 源检查迭代已结束、阶段刚到 emit 且 emitted=0 时可接管范围/映射;不是完整计划已生成或已加入画布。
* topologyOnly 下“源检查结束”仍只证明拓扑路径通过,不证明所有组件 payload schema。 */
ready():boolean{return !sourceChecks&&state.phase==='emit'&&state.emitted===0;}, ready():boolean{return !sourceChecks&&state.phase==='emit'&&state.emitted===0;},
/** 返回阶段、语义 steps 和已分配数;不冒称解析/加入/网络已完成。 */ /** 返回阶段、语义 steps 和已分配数;不冒称解析/加入/网络已完成。 */
progress(){return Object.freeze({phase:state.phase,steps:state.steps,allocated:state.elementIds.length+state.canvasIds.length});}, 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};} 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; 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 '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-elements':if(state.cursor<state.copied.length){const id=state.copied[state.cursor++],value=allocate(id,'elements');elementIds.set(id,value);state.elementIds.push([id,value]);}else phase('allocate-canvases');break;
case 'allocate-canvases':if(state.cursor<state.canvases.length){const id=state.canvases[state.cursor++],value=allocate(id,'canvases');canvasIds.set(id,value);state.canvasIds.push([id,value]);}else phase('mapping');break; case '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 时机由宿主软预算决定。 // 每次状态机工作/阶段切换单调计数;step 的 wall time 和 yield 时机由宿主软预算决定。
state.steps++;return false; 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]});}, 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,最后生成边。 * 完整快照上的深复制纯规划:先确定范围和稳定 ID 映射,再生成节点/canvas,最后生成边。
* 同时选分区入口和其内部节点时只复制一次;原上级 C→F 保留,新增边可连接原 C 与新 F′。 * 同时选分区入口和其内部节点时只复制一次;原上级 C→F 保留,新增边可连接原 C 与新 F′。
* 这里只返回计划,不改 editor、不上传图片、不启动生成;runtime 负责调度、持久源和逐批加入。 * 这里只返回计划,不改 editor、不上传图片、不启动生成;runtime 负责调度、持久源和逐批加入。
* 源须在规划期间保持不可变;资源 ID 沿 data/copy 声明处理,不在这里复制文件字节或创建资源绑定。
*/ */
import type { CanvasRecord, Change, Data, Document, ElementRecord, Id, Json, Point, Registry, Target } from './types.ts'; import type { CanvasRecord, Change, Data, Document, ElementRecord, Id, Json, Point, Registry, Target } from './types.ts';
import { copySlots } from './copy-paths.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 { 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; const definition = registry.components.get(element.componentId), schema = definition?.copy;
if (registry.resolve(element) !== 'known' || !schema) throw fault('copy_schema_unavailable', [element.id]); 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>; const data = clone(element.data) as Record<string, Json>;
for (const ref of schema.references) { for (const ref of schema.references) {
for (const slot of copySlots(data, ref.path)) { 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); 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]; } 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; return data;
} }
/** Pure, complete-snapshot planning. Runtime owns time slicing, durable source/mapping and partial progress. */ /** 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> { export function* planDuplicateSteps(document: Document, registry: Registry, selection: readonly Target[], options: DuplicateOptions): Generator<void, DuplicatePlan> {
yield* validateDocumentSteps(document, registry); yield* validateDocumentSteps(document, registry);
const offset = options.worldOffset ?? { x: 32, y: 32 }; const offset = options.worldOffset ?? { x: 32, y: 32 };
@@ -78,6 +82,7 @@ export function* planDuplicateSteps(document: Document, registry: Registry, sele
} }
} }
// 边另外扫描:显式选中、属于复制 canvas、或指向复制节点的入边均纳入,源端可仍指向原上级节点。 // 边另外扫描:显式选中、属于复制 canvas、或指向复制节点的入边均纳入,源端可仍指向原上级节点。
// 仅 source 被复制、target 在外部的出边,不因 source 在范围内就自动纳入;显式选中等其它条件仍可纳入。
const edges: ElementRecord[] = []; 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); } } 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 互撞。 // ID 分配前先保留源元素/canvas/墓碑全部 ID;同一 used 集合还防止两种新 ID 互撞。
@@ -124,6 +129,7 @@ export function* planDuplicateSteps(document: Document, registry: Registry, sele
/** 共享确定性转换器:canvas 改 parent/entry 映射,节点改身份/owner/data 与根级偏移。 /** 共享确定性转换器:canvas 改 parent/entry 映射,节点改身份/owner/data 与根级偏移。
* 同步及 checkpoint planner 都使用这里,避免两套复制产品规则。 */ * 同步及 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'}>{ 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') { if (kind === 'canvas') {
const canvas=record as CanvasRecord,parent=canvas.parent!; 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)! } }; 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 }; 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'}>{ export function duplicateEdgeChange(document:Document,registry:Registry,edge:ElementRecord,elementIds:ReadonlyMap<Id,Id>,canvasIds:ReadonlyMap<Id,Id>,nodes:Document):Extract<Change,{kind:'element'}>{
if (edge.structure !== 'connection') throw fault('invalid_copy_endpoint'); if (edge.structure !== 'connection') throw fault('invalid_copy_endpoint');
// 与 convertPath 的同变换早返回不同,这里即使 owner 空间不变,也先要求复制支持该曲线格式。
if (!connectionPathValid(edge.path)) throw fault('unsupported_copy_path', [edge.id]); 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 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); 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 }); 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 }; 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; 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 }) }; }) }; 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. */ /** Internal overlay avoids an O(document size) clone between planning phases. Additions own unique IDs. */
/** 只读接口覆盖层:新增 ID 保证唯一,先查 additions,迭代则 base 后 additions。 /** 只读接口覆盖层:新增 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> { function overlay<K,V>(base: ReadonlyMap<K,V>, additions: ReadonlyMap<K,V>): ReadonlyMap<K,V> {
const view: ReadonlyMap<K,V> = { const view: ReadonlyMap<K,V> = {
// get/has 与所有迭代器读取同一组当前表;forEach 的第三参数仍是这个只读接口。 // get/has 与所有迭代器读取同一组当前表;forEach 的第三参数仍是这个只读接口。
+39 -15
View File
@@ -1,7 +1,8 @@
/** /**
* 纯编辑器与单 Root 发布协调器:命令先在私有 draft 上规划,再统一发布 document/history/operations/runtime。 * 纯编辑器与单 Root 发布协调器:命令先在私有 draft 上规划,再统一发布 document/history/operations/runtime。
* 不在这里执行网络或 IndexedDB;engine 通过同步准备钩子装配 ledger 等事实,调度器在发布后异步处理。 * 不在这里执行网络或 IndexedDB;engine 通过同步准备钩子装配 ledger 等事实,调度器在发布后异步处理。
* 修改入口共用 entered 防重入和 disposed 生命周期检查;协调提案另用 rootRevision 拒绝过期计划。 * 命令/协调发布入口共用 entered 防重入和 disposed 检查;协调提案另用 rootRevision 拒绝过期计划。
* dispose/subscribe 和只读查询有各自生命周期规则,entered 是同步重入闸门,不是跨线程或网络锁。
*/ */
import {dataArray,dataRecord} from './data-containers.ts'; import {dataArray,dataRecord} from './data-containers.ts';
import {stageCreation,materializeCreations} from './creation-draft.ts'; import {stageCreation,materializeCreations} from './creation-draft.ts';
@@ -29,9 +30,13 @@ export type EditorOptions<Runtime = undefined> = {
policy?: (command: Command, document: Document) => Availability; policy?: (command: Command, document: Document) => Availability;
/** 撤销/重做独立准入钩子,runtime 可据依赖、未知请求或权限阻止栈移动。 */ /** 撤销/重做独立准入钩子,runtime 可据依赖、未知请求或权限阻止栈移动。 */
traversalPolicy?: (entry: HistoryEntry, direction: 'undo' | 'redo') => Availability; traversalPolicy?: (entry: HistoryEntry, direction: 'undo' | 'redo') => Availability;
/** listener 抛错后的诊断回调;诊断本身失败也不能回滚已经发布的状态。 */
onListenerError?: (error: unknown) => void; onListenerError?: (error: unknown) => void;
/** 明确开放 changes.apply 给可信宿主;仍执行 policy 和发布前文档检查。 */
allowRawChanges?: boolean; allowRawChanges?: boolean;
/** 恢复层预先校验的核心快照;初始化直接沿用其中 history/operations,不在此完整解码恢复 DTO。 */
recovery?: CoreSnapshot & { readonly rootRevision: number }; recovery?: CoreSnapshot & { readonly rootRevision: number };
/** 与初始核心快照配套的宿主 runtime;其内部不可变性由宿主负责。 */
runtime?: Runtime; runtime?: Runtime;
/** engine 安装的同步 reducer,将核心提交变成下一份 runtime;抛错拒绝整次发布。 */ /** engine 安装的同步 reducer,将核心提交变成下一份 runtime;抛错拒绝整次发布。 */
reduceRuntime?: (previous: Runtime, next: CoreSnapshot, commit: Commit) => Runtime; reduceRuntime?: (previous: Runtime, next: CoreSnapshot, commit: Commit) => Runtime;
@@ -58,17 +63,20 @@ export function createEditor<Runtime = undefined>(options: EditorOptions<Runtime
// 文档索引与 runtime 覆盖范围同次绑定;同一个 doc 对象的加载事实也可能更新。 // 文档索引与 runtime 覆盖范围同次绑定;同一个 doc 对象的加载事实也可能更新。
const bindCoverage=(snapshot:Snapshot<Runtime>,queries:ReturnType<typeof createDocumentQueries>)=>options.queryCoverage?withQueryCoverage(queries,options.queryCoverage(snapshot)):queries; 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)); 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]]); const queriesByDocument=new WeakMap([[document,queries]]);
// entered 覆盖准备、发布和通知,防止 handler/reducer/observer 同步重入造成半份状态。 // entered 覆盖准备、发布和通知,防止 handler/reducer/observer 同步重入造成半份状态。
let entered = false, disposed = false, sequence = 0; let entered = false, disposed = false, sequence = 0;
for (const intent of state.operations.values()) sequence = Math.max(sequence, intent.sequence); for (const intent of state.operations.values()) sequence = Math.max(sequence, intent.sequence);
const listeners = new Set<(commit: Commit, snapshot: Snapshot<Runtime>) => void>(); const listeners = new Set<(commit: Commit, snapshot: Snapshot<Runtime>) => void>();
// createId 可能在最终发布前被调用;失败的候选不会回收已经向宿主申请过的 ID。
const createId = options.createId || (() => globalThis.crypto.randomUUID()); const createId = options.createId || (() => globalThis.crypto.randomUUID());
const limit = options.historyLimit ?? 100; const limit = options.historyLimit ?? 100;
if (!Number.isSafeInteger(limit) || limit < 1) throw fault('invalid_history_limit'); 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[] } { function plan(document: Document, command: Command): { document: Document; changes: readonly Change[] } {
const permission = options.policy?.(command, document); const permission = options.policy?.(command, document);
if (permission && !permission.enabled) throw fault(permission.code, permission.targets); 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]); 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) } }) }); 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]); 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'){ } else if(command.type==='connection.endpoints'){
if(element.structure!=='connection'||!options.registry.supports(element,'connection.edit'))throw fault('unsupported_capability',[element.id]); if(element.structure!=='connection'||!options.registry.supports(element,'connection.edit'))throw fault('unsupported_capability',[element.id]);
changes=[]; 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)}]; 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))]; if(changes.length)changes=[...changes,...repairConnectionOwners(document,applyChanges(document,changes))];
// 删除使用结构规划器收集子树/附边,不通过单一内容或几何 handler。
} else if (command.type === 'element.remove') { } else if (command.type === 'element.remove') {
changes = removalChanges(document,command.target,queriesByDocument.get(document)); changes = removalChanges(document,command.target,queriesByDocument.get(document));
// 跨 owner 移动需要同时处理归属、几何、视觉单位、入口子 canvas 与相关边。 // 跨 owner 移动需要同时处理归属、几何、视觉单位、入口子 canvas 与相关边。
@@ -142,7 +152,7 @@ export function createEditor<Runtime = undefined>(options: EditorOptions<Runtime
if (command.type === 'element.data') run('content.edit', command.patch); if (command.type === 'element.data') run('content.edit', command.patch);
else { 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]); 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>][] = [ const parts: [Capability, Partial<Geometry>][] = [
['geometry.move', Object.fromEntries(Object.entries(command.patch).filter(([key]) => key === 'x' || key === 'y'))], ['geometry.move', Object.fromEntries(Object.entries(command.patch).filter(([key]) => key === 'x' || key === 'y'))],
['geometry.resize', Object.fromEntries(Object.entries(command.patch).filter(([key]) => key === 'width' || key === 'height'))], ['geometry.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; 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); 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)) }; 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'); if(isThenable(runtime))throw fault('async_transaction');
return runtime===next.runtime?next:Object.freeze({...next,runtime}); return runtime===next.runtime?next:Object.freeze({...next,runtime});
} }
/** 发布后通知 listener 快照;逐个隔离异常,观察者失败不能回滚已提交的 Root。 */ /** 发布后通知 listener 快照;逐个隔离异常,观察者失败不能回滚已提交的 Root。
* 本轮遍历 listener 集合的数组副本,通知期间退订不撤回副本中尚未调用的 listener。 */
function emit(commit: Commit) { 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. */ } } 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) }); history = Object.freeze({ ...history, entries: readMap(entries) });
} }
if (outbound) { if (outbound) {
// 收集旧/新 owner、父 canvas 和端点 owner,形成跨图版本/claim 的完整参与者范围。 // 按变化种类收集参与者:canvas 自身及父项、元素旧/新 owner、新建边或端点变化涉及的端点 owner。
// 这是出站意图的 canvasIds 来源,不是一次对所有可能插件引用的全图扫描。
const canvasIds = new Set<Id>(); const canvasIds = new Set<Id>();
for (const change of changes) { for (const change of changes) {
if (change.kind === 'canvas') { 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 next: CoreSnapshot = Object.freeze({ revision: state.revision + 1, document, history, operations: readMap(operations) });
const commit = freeze({ revision: next.revision, origin, changes, operationIds }); const commit = freeze({ revision: next.revision, origin, changes, operationIds });
// reducer/prepareRuntime/查询构建都在 state 赋值前完成;拒绝只丢弃候选,不能撤回宿主钩子的外部副作用。
const runtime = options.reduceRuntime ? options.reduceRuntime(state.runtime, next, commit) : state.runtime; const runtime = options.reduceRuntime ? options.reduceRuntime(state.runtime, next, commit) : state.runtime;
if (isThenable(runtime)) throw fault('async_transaction'); 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 } : {}) }); 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 { function transaction(callback: (transaction: Transaction) => unknown, settings: { label?: string; groupId?: Id; history?: boolean; origin?: Origin } = {}): Result {
try { assertOpen(); } catch (error) { return reject(error); } try { assertOpen(); } catch (error) { return reject(error); }
entered = true; entered = true;
let active = true, document = state.document, failed: unknown; let active = true, document = state.document, failed: unknown;
const changes: Change[] = []; const changes: Change[] = [];
const tx: Transaction = Object.freeze({ const tx: Transaction = Object.freeze({
/** 私有 dispatch 记住第一次失败,后续命令不再继续推进 draft。 */ /** 私有 dispatch 记录规划抛错;常规 Error 被 failed 真值分支记住,后续命令停止推进。 */
dispatch(command: Command): Availability { dispatch(command: Command): Availability {
if (!active) throw fault('transaction_closed'); if (!active) throw fault('transaction_closed');
if (failed) return { enabled: false, ...reject(failed) }; if (failed) return { enabled: false, ...reject(failed) };
@@ -277,6 +291,7 @@ export function createEditor<Runtime = undefined>(options: EditorOptions<Runtime
// 全部命令规划结束才物化创建记录并做整图校验,允许同批先创建节点再接边。 // 全部命令规划结束才物化创建记录并做整图校验,允许同批先创建节点再接边。
document=materializeCreations(document); document=materializeCreations(document);
validateDocument(document, options.registry); validateDocument(document, options.registry);
// 无变化或表内容/迭代顺序与原文档相同,直接报告 applied;不分配历史/操作 ID,也不通知订阅者。
if (!changes.length || documentsEqual(document, state.document)) return Object.freeze({ status: 'applied', revision: state.revision, operationIds: Object.freeze([]) }); 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; let history = state.history, historyEntryId: Id | undefined;
if (settings.history !== false && (!settings.origin || settings.origin === 'user')) { 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 } : {}) }))); 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); undo.push(historyEntryId);
} }
// 容量淘汰移除最旧 entry;资源 pin 的后续扫描以更新后的 history 为准。 // 仅从当前 history 淘汰最旧 entry;未解决操作的 historyTransition 等证据仍可能 pin 住旧资源。
while (undo.length > limit) entries.delete(undo.shift()!); while (undo.length > limit) entries.delete(undo.shift()!);
history = Object.freeze({ undo: Object.freeze(undo), redo: Object.freeze([]), entries: readMap(entries) }); 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); return publish(document, freeze([...changes]), settings.origin || 'user', history, historyEntryId);
} catch (error) { active = false; return reject(error); } } catch (error) { active = false; return reject(error); }
finally { entered = false; } 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 { function dispatch(command: Command, settings?: Parameters<typeof transaction>[1]): Result {
return transaction(tx => { tx.dispatch(command); }, settings); return transaction(tx => { tx.dispatch(command); }, settings);
} }
/** 只规划和校验可用性,不分配操作 ID、不发布;提交时还要重新校验当前状态。 */ /** 只规划和校验可用性,不分配操作 ID、不发布;提交时还要重新校验当前状态。
* can 只检查 disposed,不占 entered;宿主提供的 policy/handler 可能仍被调用,不应有外部写入。 */
function can(command: Command): Availability { 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 }; } 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 }; } 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 entry = state.history.entries.get(id)!;
const allowed = options.traversalPolicy?.(entry, direction); const allowed = options.traversalPolicy?.(entry, direction);
if (allowed && !allowed.enabled) throw fault(allowed.code, allowed.targets); if (allowed && !allowed.enabled) throw fault(allowed.code, allowed.targets);
// undo 反转变化顺序和值,redo 使用正向变化;私有执行先通过最终文档校验,才构造新的两栈。
const planned=direction==='undo'?invert(entry.changes):entry.changes; const planned=direction==='undo'?invert(entry.changes):entry.changes;
const {document,changes,remap,restored}=planHistoryChanges(state.document,planned,options.registry); const {document,changes,remap,restored}=planHistoryChanges(state.document,planned,options.registry);
const entries=new Map(state.history.entries); 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. // 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)))}))); 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))}))); 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) }); 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); return publish(document, changes, direction, history, id);
@@ -335,7 +354,8 @@ export function createEditor<Runtime = undefined>(options: EditorOptions<Runtime
// 公开只读快照与绑定查询;指定未知 document 查查询会拒绝,不隐式混用当前索引。 // 公开只读快照与绑定查询;指定未知 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, 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. */ /** 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 { resolveCreationSize(effect: CreationSizeEffect, expectedRootRevision: number): Result {
try { assertOpen(); } catch (error) { return reject(error); } try { assertOpen(); } catch (error) { return reject(error); }
entered = true; entered = true;
@@ -363,6 +383,7 @@ export function createEditor<Runtime = undefined>(options: EditorOptions<Runtime
if (expectedRootRevision !== state.rootRevision) throw fault('stale_plan'); if (expectedRootRevision !== state.rootRevision) throw fault('stale_plan');
const runtime = reduce(state.runtime); const runtime = reduce(state.runtime);
if (isThenable(runtime)) throw fault('async_transaction'); if (isThenable(runtime)) throw fault('async_transaction');
// 相同 runtime 引用视为 no-op,prepareRuntime 不执行;调用方须返回新对象而非原地改旧状态。
if (runtime !== state.runtime) { if (runtime !== state.runtime) {
const nextState=prepareState(Object.freeze({ ...state, rootRevision: state.rootRevision + 1, runtime })),nextQueries=bindCoverage(nextState,queries); const nextState=prepareState(Object.freeze({ ...state, rootRevision: state.rootRevision + 1, runtime })),nextQueries=bindCoverage(nextState,queries);
state=nextState;queries=nextQueries;queriesByDocument.set(state.document,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; } finally { entered = false; }
}, },
/** Engine-only canonical proposal: validation and runtime state publish as one root. */ /** Engine-only canonical proposal: validation and runtime state publish as one root. */
/** 将已校验的 ACK/恢复提案与 runtime 一起发布;旧 rootRevision 提案明确拒绝。 /** 接收 ACK/恢复提案并重新捕获校验文档,与 runtime 一起发布;旧 rootRevision 提案明确拒绝。
* 可替换 history/operations,但不允许改写原有操作身份;文档实际变化才推进 revision。 */ * 可替换 history/operations,但不允许改写原有操作身份;文档实际变化才推进 revision。 */
reconcile(document: Document, runtime: Runtime, expectedRootRevision: number, replacement?: { readonly history: HistoryState; readonly operations: ReadonlyMap<Id,OperationIntent> }): Result { reconcile(document: Document, runtime: Runtime, expectedRootRevision: number, replacement?: { readonly history: HistoryState; readonly operations: ReadonlyMap<Id,OperationIntent> }): Result {
try { assertOpen(); } catch (error) { return reject(error); } 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); const next = createDocument(document.projectId, [...document.canvases.values()], [...document.elements.values()], document.tombstones);
validateDocument(next, options.registry); validateDocument(next, options.registry);
let nextSequence=sequence; let nextSequence=sequence;
// 恢复替换必须让两栈精确覆盖 entry,entry 的 operation ID 必须存在,序列保持单调。 // 替换时要求两栈精确覆盖 entries、历史关联的 operation ID 存在,已有操作内容完全相等。
// 新操作的 sequence 在此仅检查正安全整数,再取最大值供后续分配;完整恢复校验由宿主边界完成。
if(replacement){ if(replacement){
const {history,operations}=replacement,ids=[...history.undo,...history.redo]; 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'); 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,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'); 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 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); 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); 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(); }, dispose() { disposed = true; listeners.clear(); },
}); });
} }
/** 比较规范化表,先做身份/大小快判;无净变化的事务不增加 revision 或历史。 */ /** 比较表的迭代顺序与内容,先做身份/大小快判;不是忽略插入顺序的 Map 集合等价。
* transaction 用它省去重复发布,reconcile 用它决定是否推进文档 revision。 */
function documentsEqual(a: Document, b: Document): boolean { function documentsEqual(a: Document, b: Document): boolean {
if(a===b)return true; 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; 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 // Provenance comes from detaching JSON data, never from an external freeze or
// document envelope. This does not assert record validity or registry support. // document envelope. This does not assert record validity or registry support.
const owned=new WeakSet<object>(); const owned=new WeakSet<object>();
/** 已由本模块捕获的记录直接复用;外部记录重新复制冻结,再标记新记录身份。 */ /** 已由本模块捕获的记录直接复用;外部记录重新按 JSON 语义复制冻结,再标记新记录身份。
* 此处不调用组件 schema;捕获成功与记录合法是两项独立事实。 */
export function detachElementData(record:ElementRecord):ElementRecord{ export function detachElementData(record:ElementRecord):ElementRecord{
if(record&&typeof record==='object'&&owned.has(record))return record; if(record&&typeof record==='object'&&owned.has(record))return record;
const value=detachJsonData(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 // Framework-owned validators depend only on detached immutable data. Custom
// validators remain live, including their method receiver and external context. // validators remain live, including their method receiver and external context.
const builtinValidators=new WeakSet<Function>(),dataProofs=new WeakMap<object,ComponentDefinition['validate']>(); 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;} export function builtinDataValidator<T extends ComponentDefinition['validate']>(validate:T):T{builtinValidators.add(validate);return validate;}
/** 检查组件 data,只有“owned 记录 + 内置 validator”组合才允许复用成功结果。 /** 检查组件 data,只有“owned 记录 + 内置 validator”组合才允许复用成功结果。
* 失败不缓存;更换 validator 后缓存也不命中;Reflect.apply 保留定义对象作为 this。 */ * 失败不缓存;更换 validator 后缓存也不命中;Reflect.apply 保留定义对象作为 this。 */
@@ -29,6 +31,7 @@ export function componentDataValid(record:ElementRecord,definition:ComponentDefi
// record 身份和具体 validator 函数同时匹配,才能跳过一次已成功的 schema 检查。 // record 身份和具体 validator 函数同时匹配,才能跳过一次已成功的 schema 检查。
const validate=definition.validate,reusable=hasOwnedElementData(record)&&builtinValidators.has(validate); const validate=definition.validate,reusable=hasOwnedElementData(record)&&builtinValidators.has(validate);
if(reusable&&dataProofs.get(record)===validate)return true; if(reusable&&dataProofs.get(record)===validate)return true;
// 校验器抛错会直接传播给上层;这里只缓存正常返回且通过的结果,不吞错或缓存失败。
const valid=Reflect.apply(validate,definition,[record.data]); const valid=Reflect.apply(validate,definition,[record.data]);
if(valid&&reusable)dataProofs.set(record,validate); if(valid&&reusable)dataProofs.set(record,validate);
return valid; return valid;
+20 -6
View File
@@ -1,6 +1,7 @@
/** /**
* 内置 feature 与 component 装配。Handler 是纯规划器:读取目标与 patch,只返回字段变化。 * 内置 feature 与 component 装配。Handler 是纯规划器:读取目标与 patch,只返回字段变化。
* 权限、事务与发布仍由 editor 负责;UI 不能绕过 editor 直接调用 handler 来写文档。 * 权限、事务与发布仍由 editor 负责;UI 不能绕过 editor 直接调用 handler 来写文档。
* capability 表示安装了哪类行为,schema 与各 handler 的实际检查仍须分别阅读。
*/ */
import {builtinDataValidator} from './element-data.ts'; import {builtinDataValidator} from './element-data.ts';
import {rectangleGeometry,bezierChainSegments,type BezierChain} from './geometry.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]); if (element.structure !== 'node') throw fault('unsupported_capability', [element.id]);
const patch = input.patch as Partial<Geometry>; 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]); 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 }; 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 }]; return equal(element.geometry, after) ? [] : [{ kind: 'field', target: { id: element.id, incarnation: element.incarnation }, field: 'geometry', before: element.geometry, after }];
}; };
/** 移动仅允许 x/y,不能顺带修改大小、旋转或 owner。 */ /** 移动仅允许 x/y,不能顺带修改大小、旋转或 owner。 */
export const movable = () => defineFeature('geometry.move', { 'geometry.move': geometryHandler(['x', 'y']) }); 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 } = {}) => { export const resizable = (inputOptions: { min?: number; max?: number; aspect?: boolean } = {}) => {
const options = Object.freeze({ ...inputOptions }); 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'); 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 => { return geometryHandler(['width', 'height'], geometry => {
// owner 变换后 unitScale 也会改变,因此 min/max 要乘它才能维持同一视觉尺寸约束。 // owner 变换后 unitScale 也会改变,因此 min/max 要乘它才能维持同一视觉尺寸约束。
const units=unitScale(element.data.presentation),min=(options.min??1)*units,max=(options.max??100000)*units; 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)); let width = Math.min(max, Math.max(min, geometry.width)), height = Math.min(max, Math.max(min, geometry.height));
// 宽度优先;只给高度时反算宽度。保持原比例后越界则拒绝,不分别截断而破坏比例。 // 宽度优先;只给高度时反算宽度。保持原比例后越界则拒绝,不分别截断而破坏比例。
if (options.aspect) { 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']) }); export const rotatable = () => defineFeature('geometry.rotate', { 'geometry.rotate': geometryHandler(['rotation']) });
/** 内容编辑按顶层 data 键生成独立条件变化,保存存在性,区分缺失字段与 null。 */ /** 内容编辑按顶层 data 键生成独立条件变化,保存存在性,区分缺失字段与 null。 */
export const editable = () => defineFeature('content.edit', { export const editable = () => defineFeature('content.edit', {
'content.edit': ({ element, input }) => { 'content.edit': ({ element, input }) => {
const patch = input.patch as Data; const patch = input.patch as Data;
if (!patch || !validJson(patch) || Array.isArray(patch)) throw fault('invalid_data_patch', [element.id]); if (!patch || !validJson(patch) || Array.isArray(patch)) throw fault('invalid_data_patch', [element.id]);
// 签名要求 Data 对象;上面的运行时 guard 检查 JSON 值域并排除数组,不是任意未知输入的完整对象 schema。
// patch 中出现的键都表示写入,null 是显式值;此 handler 没有以 undefined 表示删除的约定。
// 每个字段携带 before,ACK/重放时才能检查冲突,而不是用整份节点覆盖后来的编辑。 // 每个字段携带 before,ACK/重放时才能检查冲突,而不是用整份节点覆盖后来的编辑。
return Object.entries(patch).flatMap(([key, after]): FieldChange[] => { return Object.entries(patch).flatMap(([key, after]): FieldChange[] => {
const before = element.data[key] ?? null; 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'); const textData = builtinDataValidator((data: Data) => presentationUnitsValid(data.presentation) && validJson(data) && typeof data.title === 'string' && typeof data.text === 'string');
/** 安装文本、图片、脚本、图标、frame 和 connection 的默认定义。 /** 安装文本、图片、脚本、图标、frame 和 connection 的默认定义。
* 节点共用基础行为;frame 不安装旋转行为。复制声明与资源字段也是定义的一部分。 */ * 节点共用基础行为;frame 不安装旋转行为。复制声明与资源字段也是定义的一部分。 */
@@ -67,18 +75,23 @@ export function builtinComponents(): readonly ComponentDefinition[] {
id, dataVersion: 1, structure: 'node', validate: textData, id, dataVersion: 1, structure: 'node', validate: textData,
features: [movable(), resizable(), ...(id === 'frame' ? [] : [rotatable()]), editable()], features: [movable(), resizable(), ...(id === 'frame' ? [] : [rotatable()]), editable()],
resourceFields: ['image', 'plain-image'].includes(id) ? ['assetId','asset'] : [], 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']] }, 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: [] } })]); })).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 { export function connectionPathValid(path: Json): boolean {
if (path === null) return true; 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; if (typeof path !== 'object' || Array.isArray(path) || path.type !== 'bezier-chain' || !Array.isArray(path.knots) || path.knots.length > 64) return false;
// 控制点只能是有限 x/y;缺少坐标、数组或非对象值均不接受。 // 控制点只能是有限 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); 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; 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>(); const ids = new Set<string>();
for (const knot of path.knots) { 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; 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; 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 }) => { export const connectionEditable = () => defineFeature('connection.path', { 'connection.edit': ({ element, input }) => {
const path = (input.patch as { path?: Json }).path; const path = (input.patch as { path?: Json }).path;
if (element.structure !== 'connection' || !connectionPathValid(element.path)) throw fault('unsupported_connection_path', [element.id]); 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 绘制共享。 * bounds 负责粗筛,hit 负责矩形精判,曲线 segments 由模型命中和 SVG 绘制共享。
* 函数不主动校验输入几何/容差/控制点,也不换算 viewport 或 presentation.unitScale,调用方先统一空间。
*/ */
import type {Geometry,Point} from './types.ts'; 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。 */ /** 绕节点中心旋转后的 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};} 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);} 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 等数据变化重建。 */ /** 默认矩形节点协议;空 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)}); 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. */ /** Shared by the built-in model protocol and the SVG renderer; all arguments are in one display space. */
/** 由锚点链生成每段三次曲线:上一 knot.out 和下一 knot.in 分别作为控制偏移。 /** 由锚点链生成每段三次曲线:上一 knot.out 和下一 knot.in 分别作为控制偏移。
* 无显式首尾控制柄时,按端点距离的 0.4 倍选择长度并限制在 40–240。 * 无显式首尾控制柄时,reach 按两端直线距离的 0.4 倍限制在 40–240,再乘相应法向量。
* add 把相对偏移变成绝对控制点;map 对每对相邻 anchors 输出一段。 */ * 本函数不归一 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]]);} 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. /** 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. */ * Cache by owner only during this admission/publication, so multi-selection shares ancestry work. */
/** 给定不可变文档/view/registry,返回短生命周期 owner→入口链读取器。 /** 给定不可变文档/view/registry,返回短生命周期 owner→入口链读取器。
* Map 只在一次准入或发布比较中复用,多选共享祖先扫描,不能跨文档更新继续使用。 */ * Map 只在一次准入或发布比较中复用,多选共享祖先扫描,不能跨文档更新继续使用。
* 返回数组/步骤没有运行时 freeze,geometry 也沿用记录引用;readonly 是调用约定,不应修改缓存结果。 */
export function createGesturePlacementReader(document:Document,viewCanvasId:Id,registry:Registry){ export function createGesturePlacementReader(document:Document,viewCanvasId:Id,registry:Registry){
// 链按 owner→view 方向排列,不包含 view 自身在更上级的入口;读同一 owner 会返回同一个数组。
const values=new Map<Id,readonly PlacementStep[]>(); const values=new Map<Id,readonly PlacementStep[]>();
/** 向 view 回溯目标拥有链,缺失/循环/隐藏入口明确拒绝;成功后才缓存完整链。 */ /** 向 view 回溯目标拥有链,缺失/循环/隐藏入口明确拒绝;成功后才缓存完整链。 */
return(ownerCanvasId:Id):readonly PlacementStep[]=>{ return(ownerCanvasId:Id):readonly PlacementStep[]=>{
@@ -24,6 +26,7 @@ export function createGesturePlacementReader(document:Document,viewCanvasId:Id,r
while(id!==viewCanvasId){ while(id!==viewCanvasId){
if(seen.has(id))throw fault('canvas_cycle',[id]);seen.add(id); if(seen.has(id))throw fault('canvas_cycle',[id]);seen.add(id);
const parent=document.canvases.get(id)?.parent; const parent=document.canvases.get(id)?.parent;
// owner 缺失或已回溯到无父根,统一视为不在此 view;仅 view 本身缺失在上方报 canvas_missing。
if(!parent)throw fault('target_outside_view',[ownerCanvasId]); if(!parent)throw fault('target_outside_view',[ownerCanvasId]);
const entry=document.elements.get(parent.entryElementId); const entry=document.elements.get(parent.entryElementId);
// A future/unknown entry is a collapsed placeholder; never interpret its payload. // 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:{}; const raw=entry.data.presentation,presentation=raw&&typeof raw==='object'&&!Array.isArray(raw)?raw:{};
if(presentation.collapsed===true)throw fault('target_hidden',[ownerCanvasId]); 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; 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)}); 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; id=parent.canvasId;
+35 -17
View File
@@ -1,7 +1,9 @@
/** /**
* 每个 editor 的临时交互会话:多个 view 各自保存选择/视口/工具/mode,共用一份已提交文档。 * 一个 manager 的临时交互会话:多个 view 各自保存选择/视口/工具/mode,共用 editor 已提交文档。
* 同时至多存在一个节点或连线手势;baseline/preview 均是短生命周期状态,不写 history/ledger。 * 单个 manager 内至多一个节点或连线手势;同一 editor 若另建 manager,并不共享这项互斥。
* pointerup 时才通过 editor 提交净修改;取消仅清预览,不回写旧整节点,因此不会撤掉晚到资源 ACK。 * baseline/preview 均是短生命周期状态,不写 history/ledger。
* 输入绑定通常在 pointerup 调用 commit;本模块不接 DOM 事件,只有显式 commit 才发 editor 命令。
* 取消仅清预览,不回写旧整节点,因此不会撤掉晚到资源 ACK;session listener 仍由本模块同步通知。
*/ */
import type { Editor } from './editor.ts'; import type { Editor } from './editor.ts';
import type { Availability, Capability, ElementRecord, Geometry, Id, Json, Result, Target } from './types.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 }); let snapshot: SessionSnapshot = Object.freeze({ views: readMap(new Map<Id, ViewState>()), gesture: null, connectionGesture: null, lastCancelReason: null });
// 开始手势时捕获原记录,用它计算净几何差;提交时仍以 editor 当前数据为准,不恢复整条记录。 // 开始手势时捕获原记录,用它计算净几何差;提交时仍以 editor 当前数据为准,不恢复整条记录。
let baseline = new Map<Id, ElementRecord>(), connectionBaseline: ElementRecord | undefined, disposed = false, nextGestureToken = 0; 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; let publishedDocument=editor.getSnapshot().document;
const listeners = new Set<() => void>(); const listeners = new Set<() => void>();
/** 对监听列表做副本迭代并隔离异常,renderer 出错不能影响已提交模型。 */ /** 对监听列表做副本迭代并隔离异常,renderer 出错不能影响已提交模型。
* 会话本身没有 entered 重入闸门;监听器不应同步递归制造新会话操作。 */
const notify = () => { for (const listener of [...listeners]) try { listener(); } catch { /* A renderer cannot invalidate model commits. */ } }; const notify = () => { for (const listener of [...listeners]) try { listener(); } catch { /* A renderer cannot invalidate model commits. */ } };
/** 一次发布 views;若被中断的是手势拥有者,同时清 baseline 和预览,只通知一次。 */ /** 一次发布 views;若被中断的是手势拥有者,同时清 baseline 和预览,只通知一次。 */
function publishViews(views:Map<Id,ViewState>,cancelOwnedView?:Id,reason='view_interrupted'){ function publishViews(views:Map<Id,ViewState>,cancelOwnedView?:Id,reason='view_interrupted'){
@@ -37,7 +40,8 @@ export function createSessions(editor: Editor) {
} }
/** 复制视图表并替换一项,可在同一发布中带上切工具/切 canvas 等取消原因。 */ /** 复制视图表并替换一项,可在同一发布中带上切工具/切 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); } 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') { function cancel(reason = 'canceled') {
if (!snapshot.gesture && !snapshot.connectionGesture) return; if (!snapshot.gesture && !snapshot.connectionGesture) return;
baseline.clear(); connectionBaseline = undefined; snapshot = Object.freeze({ ...snapshot, gesture: null, connectionGesture: null, lastCancelReason: reason }); notify(); 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) { for (const [id, view] of views) {
// 当前 canvas 被删除时回到根;失效 selection 清理,模式还需检查相邻发布的元素身份。 // 当前 canvas 被删除时回到根;失效 selection 清理,模式还需检查相邻发布的元素身份。
const canvasMissing = !document.canvases.has(view.canvasId); 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 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); 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; 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 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;}}; 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') { if (!cancelReason&&snapshot.connectionGesture?.phase === 'active') {
const current = document.elements.get(snapshot.connectionGesture.target.id), before = connectionBaseline; 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'; 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.resize', Object.fromEntries(Object.entries(patch).filter(([key]) => key === 'width' || key === 'height'))],
['geometry.rotate', Object.fromEntries(Object.entries(patch).filter(([key]) => key === 'rotation'))], ['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) { for (const [capability, value] of parts) if (Object.keys(value).length) {
if (!editor.registry.supports(element, capability)) throw fault('unsupported_capability', [target.id]); 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 }) }); 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 或出站请求。 // 更新 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); 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(); snapshot = Object.freeze({ ...snapshot, gesture: freeze({ ...snapshot.gesture, preview: readMap(preview) }) }); notify();
return { enabled: true }; return { enabled: true };
@@ -120,36 +128,42 @@ export function createSessions(editor: Editor) {
return Object.freeze({ return Object.freeze({
/** 外部订阅读取当前只读会话,不能直接修改内部 Map/Set。 */ /** 外部订阅读取当前只读会话,不能直接修改内部 Map/Set。 */
getSnapshot: () => snapshot, 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) { 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 (disposed || snapshot.views.has(id)) throw fault('duplicate_view', [id]);
if (!editor.getSnapshot().document.canvases.has(canvasId)) throw fault('canvas_missing', [canvasId]); 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>()) })); 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'); }; 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) { canvas(id: Id, canvasId: Id) {
const current = view(id); const current = view(id);
if (!editor.getSnapshot().document.canvases.has(canvasId)) throw fault('canvas_missing', [canvasId]); 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'); 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[]) { select(id: Id, ids: readonly Id[]) {
const current = view(id), document = editor.getSnapshot().document; const current = view(id), document = editor.getSnapshot().document;
for (const target of ids) if (!document.elements.has(target)) throw fault('element_missing', [target]); for (const target of ids) if (!document.elements.has(target)) throw fault('element_missing', [target]);
setView(freeze({ ...current, selection: readSet(ids) })); setView(freeze({ ...current, selection: readSet(ids) }));
}, },
/** 保存有限 pan 和正 zoom;改变观察视口不修改元素几何或 history。 */ /** 保存有限 pan 和正 zoom;不修改元素几何/history,也不自动取消当前手势。
* 屏幕输入如何随 pan/zoom 换算由输入绑定负责,此处不保存 DOM 指针坐标。 */
viewport(id: Id, viewport: ViewState['viewport']) { viewport(id: Id, viewport: ViewState['viewport']) {
if (![viewport.x, viewport.y, viewport.zoom].every(Number.isFinite) || viewport.zoom <= 0) throw fault('invalid_viewport'); if (![viewport.x, viewport.y, viewport.zoom].every(Number.isFinite) || viewport.zoom <= 0) throw fault('invalid_viewport');
setView(freeze({ ...view(id), viewport: { ...viewport } })); setView(freeze({ ...view(id), viewport: { ...viewport } }));
}, },
/** select/pan 切换会取消本 view 的手势;重复设置相同工具不发通知。 */ /** 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'); }, 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) })); }, 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 全部通过。 /** 节点手势准入:select 工具、无其它手势、目标去重/版本支持、可见拥有链及 policy 全部通过。
* 先把整组选区检查完再替换 baseline,拒绝不会留下半组手势。 */ * 先把整组目标检查完再替换 baseline,拒绝不会留下半组手势;targets 不必等于 view.selection。
* 空 geometry patch 的 editor.can 不调用 move/resize/rotate handler,实际能力仍在 update/commit 检查。 */
begin(viewId: Id, targets: readonly Target[]): Availability { begin(viewId: Id, targets: readonly Target[]): Availability {
try { try {
if(view(viewId).tool!=='select')throw fault('tool_not_select'); if(view(viewId).tool!=='select')throw fault('tool_not_select');
@@ -188,7 +202,8 @@ export function createSessions(editor: Editor) {
return { enabled: true }; return { enabled: true };
} catch (error) { const result = reject(error); return { enabled: false, code: result.code, targets: result.targets }; } } 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 { commitConnection(viewId: Id): Result {
try { try {
view(viewId); view(viewId);
@@ -200,7 +215,9 @@ export function createSessions(editor: Editor) {
} catch (error) { cancel('commit_failed'); return reject(error); } } catch (error) { cancel('commit_failed'); return reject(error); }
}, },
update, update,
/** 节点手势提交一个同步事务,把各目标相对 baseline 的净字段变化合成一个 Transform 历史项。 */ /** 节点手势提交一个同步事务,仅遍历 preview 中的目标,合并相对 baseline 的净字段变化。
* 有实际文档变化时通常形成一个 Transform 历史项;空预览/无净变化遵守 editor 的 no-op 语义。
* 与连线提交相同,异常路径会取消本 manager 当前手势,而不只清理传入的 view。 */
commit(viewId: Id): Result { commit(viewId: Id): Result {
try { try {
view(viewId); view(viewId);
@@ -221,7 +238,8 @@ export function createSessions(editor: Editor) {
} catch (error) { cancel('commit_failed'); return reject(error); } } catch (error) { cancel('commit_failed'); return reject(error); }
}, },
cancel, cancel,
/** 渲染时优先读取预览,否则读取最新 committed 几何;取消后自然显露最新模型。 */ /** 按元素 ID 优先读取本 manager 预览,否则读取最新 committed 几何;取消后自然显露最新模型。
* 无 viewId 参数,不过滤调用者视图;投影/renderer 负责决定这个元素是否应在该 view 显示。 */
effectiveGeometry(id: Id): Geometry | undefined { effectiveGeometry(id: Id): Geometry | undefined {
const element = editor.getSnapshot().document.elements.get(id); const element = editor.getSnapshot().document.elements.get(id);
return snapshot.gesture?.preview.get(id) || (element?.structure === 'node' ? element.geometry : undefined); 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. // only its flattened changes, never registry-dependent references or callbacks.
const transitionChanges=new WeakMap<HistoryTransition,readonly Change[]>(); const transitionChanges=new WeakMap<HistoryTransition,readonly Change[]>();
const immutableData=new WeakSet<object>(); const immutableData=new WeakSet<object>();
/** 检查可安全跨持久切点复用的深不可变普通数据,最多 64 层。 /** 检查缓存候选的冻结数据描述符与普通原型,最多递归 64 层;不是单独的 JSON/schema 校验器。
* 冻结的 getter 仍可能返回不同值,稀疏数组也不是完整区间,因此都不允许缓存。 */ * 冻结的 getter 仍可能返回不同值,稀疏数组也不是完整区间,因此都不允许缓存。 */
function immutablePlainData(value:unknown,depth=0):boolean{ function immutablePlainData(value:unknown,depth=0):boolean{
if(depth>64)return false; if(depth>64)return false;
if(value===null||typeof value!=='object')return typeof value!=='function'; if(value===null||typeof value!=='object')return typeof value!=='function';
// 只记住已遍历成功的对象身份;祖先链若太深仍会先被 depth 条件拒绝。
if(immutableData.has(value))return true; if(immutableData.has(value))return true;
if(!Object.isFrozen(value)||Object.getPrototypeOf(value)!==(Array.isArray(value)?Array.prototype:Object.prototype))return false; 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; 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. */ /** Store changed ranges only. Clearing redo/eviction still retains the removed entry records needed to reverse that transition. */
/** 对比两份 history,记录新增、删除和修改的 entry 及两栈顺序。 /** 对比两份 history,记录新增、删除和修改的 entry 及两栈顺序。
* 默认无变化不造记录;recordNoop 用于调用方显式需要记录发布边界的情形。 */ * 默认无变化不造记录;recordNoop 用于调用方显式需要记录发布边界的情形。
* 来源应是不可变 history:两栈和区间内的 Change 沿用引用,最终 freeze 不等于任意外部输入的深复制。 */
export function describeHistoryTransition(before:HistoryState,after:HistoryState,rootRevision:number,entryId?:Id,recordNoop=false):HistoryTransition|undefined{ export function describeHistoryTransition(before:HistoryState,after:HistoryState,rootRevision:number,entryId?:Id,recordNoop=false):HistoryTransition|undefined{
const entries:EntryPatch[]=[]; 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??[])});} 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}); return freeze({version:1,rootRevision,...(entryId?{entryId}:{}),before:{undo:before.undo,redo:before.redo},after:{undo:after.undo,redo:after.redo},entries});
} }
/** 验证外部/恢复的增量 DTO,未知 version 明确要求其它解释器。 /** 验证外部/恢复的增量 DTO,未知 version 明确要求其它解释器。
* checkChanges 可由调用方注入更严格的 Change 校验,本函数本身不依赖 registry。 */ * checkChanges 可由调用方注入更严格的 Change 校验;未传时只检查 DTO/区间形状,不解释每条 Change 的领域语义。
* rootRevision 只在此检查非负整数,不在此证明 transition 属于哪次实际发布。 */
export function validateHistoryTransition(value:unknown,checkChanges?:(value:unknown)=>unknown):asserts value is HistoryTransition{ 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 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 integer=(v:unknown)=>{if(typeof v!=='number'||!Number.isSafeInteger(v)||v<0)throw fault('invalid_history_transition');};
const id=(v:unknown)=>{if(typeof v!=='string'||!v)throw fault('invalid_history_transition');}; const 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. */ /** Conditional metadata-only replay. A mismatch leaves the supplied history untouched. */
/** 先检查栈和每个区间仍匹配预期,再在私有副本上正向/反向回放元数据。 /** 先检查两栈,再逐 entry 检查元数据/数组区间并在私有副本上正向或反向回放。
* 任一冲突抛出而不修改输入 history;不能拿旧 journal 覆盖新的历史顺序。 */ * 不改写输入两栈/entries 表;不能拿旧 journal 覆盖新的历史顺序。
* history/transition 应已捕获:补丁内 Change 会沿用并冻结,不应把此接口当任意外部值的深隔离入口。 */
export function applyHistoryTransition(history:HistoryState,transition:HistoryTransition,direction:'forward'|'reverse'):HistoryState{ 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; 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'); 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[]{ export function historyTransitionChanges(transition:HistoryTransition):readonly Change[]{
const cached=transitionChanges.get(transition);if(cached)return cached; const cached=transitionChanges.get(transition);if(cached)return cached;
validateHistoryTransition(transition); validateHistoryTransition(transition);
// 仅收集被记录的变化区间两侧,未重复保存的共同前后缀不在本结果中;它不是完整 history 的展开。
const changes=transition.entries.flatMap(patch=>[...patch.changes.before,...patch.changes.after]); const changes=transition.entries.flatMap(patch=>[...patch.changes.before,...patch.changes.after]);
// 属性来自原型或 getter 时不可把一次结果当永久事实,避免错误释放资源 pin。 // 属性来自原型或 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')); const ownRanges=Object.hasOwn(transition,'entries')&&transition.entries.every(patch=>Object.hasOwn(patch,'changes')&&Object.hasOwn(patch.changes,'before')&&Object.hasOwn(patch.changes,'after'));
+5 -2
View File
@@ -9,15 +9,17 @@ import { freeze } from './values.ts';
/** Pure history execution, also used to validate recovery proposals before publication. */ /** Pure history execution, also used to validate recovery proposals before publication. */
/** 顺序回放一组历史修改,完成整份文档校验后返回候选文档和实际 changes。 /** 顺序回放一组历史修改,完成整份文档校验后返回候选文档和实际 changes。
* remap 供调用者把其它历史目标同步映射到恢复后的 incarnation;失败时原文档不变。 */ * remap 供调用者把其它历史目标同步映射到恢复后的 incarnation;函数不发布任何候选。
* initial/planned 应来自已捕获的不可变状态,底层 applyChanges/freeze 不是任意外部对象的深隔离入口。 */
export function planHistoryChanges(initial:Document,planned:readonly Change[],registry:Registry){ export function planHistoryChanges(initial:Document,planned:readonly Change[],registry:Registry){
// ID →(旧 incarnation → 新 incarnation);同一批恢复可能形成多级映射链。 // ID →(旧 incarnation → 新 incarnation);同一批恢复可能形成多级映射链。
const generations=new Map<Id,Map<number,number>>(); const generations=new Map<Id,Map<number,number>>();
// 追踪到最终代次,避免后面的字段修改仍指向本批较早的已替换代次。 // 追踪到本批最终代次;每次恢复分配更大代次,因此按这些内部生成的映射链前进。
const incarnation=(id:Id,value:number)=>{const mapping=generations.get(id);while(mapping?.has(value))value=mapping.get(value)!;return value;}; const incarnation=(id:Id,value:number)=>{const mapping=generations.get(id);while(mapping?.has(value))value=mapping.get(value)!;return value;};
// 只改生命周期身份:字段目标与整元素 before/after 都要一致,canvas 变更原样通过。 // 只改生命周期身份:字段目标与整元素 before/after 都要一致,canvas 变更原样通过。
const remap=(change:Change):Change=>{ const remap=(change:Change):Change=>{
if(change.kind==='field'){const next=incarnation(change.target.id,change.target.incarnation);return next===change.target.incarnation?change:freeze({...change,target:{...change.target,incarnation:next}});} if(change.kind==='field'){const next=incarnation(change.target.id,change.target.incarnation);return next===change.target.incarnation?change:freeze({...change,target:{...change.target,incarnation:next}});}
// 整元素 before/after 只替换顶层 incarnation,记录内其它字段与引用不在此重新解释。
if(change.kind==='element'){const record=(value:ElementRecord|null)=>value?{...value,incarnation:incarnation(value.id,value.incarnation)}:null;return freeze({...change,before:record(change.before),after:record(change.after)});} if(change.kind==='element'){const record=(value:ElementRecord|null)=>value?{...value,incarnation:incarnation(value.id,value.incarnation)}:null;return freeze({...change,before:record(change.before),after:record(change.after)});}
return change; return change;
}; };
@@ -31,5 +33,6 @@ export function planHistoryChanges(initial:Document,planned:readonly Change[],re
} }
// 暂态允许先恢复端点再恢复边;只有整批完成后才检查最终树、引用和 owner 一致性。 // 暂态允许先恢复端点再恢复边;只有整批完成后才检查最终树、引用和 owner 一致性。
validateDocument(document,registry); validateDocument(document,registry);
// restored 只表示是否建立过恢复代次映射;即使 false,文档也可能因普通字段/删除变化而改变。
return{document,changes:freeze(changes),remap,restored:generations.size>0}; return{document,changes:freeze(changes),remap,restored:generations.size>0};
} }
+3 -1
View File
@@ -21,8 +21,10 @@ export type {DuplicatePlan,DuplicateOptions} from './duplicate.ts';
export {createDuplicatePlanner,validateDuplicateCheckpoint} from './duplicate-checkpoint.ts'; export {createDuplicatePlanner,validateDuplicateCheckpoint} from './duplicate-checkpoint.ts';
export type {DuplicateCheckpoint} from './duplicate-checkpoint.ts'; export type {DuplicateCheckpoint} from './duplicate-checkpoint.ts';
// 查询按不可变文档快照绑定;更新 API 用于沿发布变化推进关系与空间索引。 // 查询按不可变文档快照绑定;此处公开建表/增量推进与查询类型。
// withQueryCoverage 是宿主加载链直接使用的模块 API,没有在这个根入口重导出。
export {createDocumentQueries,advanceDocumentQueries} from './queries.ts'; export {createDocumentQueries,advanceDocumentQueries} from './queries.ts';
export type {DocumentQueries,Bounds,QueryBudget,QueryCoverage,ElementLookup,IdQueryResult} from './queries.ts'; export type {DocumentQueries,Bounds,QueryBudget,QueryCoverage,ElementLookup,IdQueryResult} from './queries.ts';
// 公开共享几何函数;预算曲线细分 hitCubic 和 history 执行规划器等仍从内部模块使用,不在此整包重导出。
export {rectangleGeometry,rectangleBounds,hitRectangle,bezierChainSegments} from './geometry.ts'; export {rectangleGeometry,rectangleBounds,hitRectangle,bezierChainSegments} from './geometry.ts';
+3 -2
View File
@@ -7,8 +7,9 @@ import {clone,freeze} from './values.ts';
// Private provenance for a fresh, recursively frozen JSON clone. A caller can // Private provenance for a fresh, recursively frozen JSON clone. A caller can
// request a detached copy, but cannot grant this fact to an existing object. // request a detached copy, but cannot grant this fact to an existing object.
const detached=new WeakSet<object>(); const detached=new WeakSet<object>();
/** JSON 复制后递归冻结;对象结果记录到 WeakSet,原始值直接返回。 /** JSON 复制后递归冻结;仅对象结果记录到 WeakSet。
* clone 会按 JSON 规则处理数据,所以不可用它代替迁移输入的严格捕获校验。 */ * 输入先经过 JSON 序列化语义:toJSON/getter 可能执行,稀疏槽/数组中的非法 JSON 值可能转为 null。
* 循环或 BigInt 等仍会抛错;它不保留任意输入原貌,不能替代迁移输入的严格捕获校验。 */
export function detachJsonData<T>(input:T):T { export function detachJsonData<T>(input:T):T {
const value=freeze(clone(input)); const value=freeze(clone(input));
if(value&&typeof value==='object')detached.add(value); if(value&&typeof value==='object')detached.add(value);
+27 -13
View File
@@ -3,6 +3,7 @@
* 关系表描述 owner、子 canvas、关联边与声明引用;空间表按 owner canvas 分网格存节点包围盒。 * 关系表描述 owner、子 canvas、关联边与声明引用;空间表按 owner canvas 分网格存节点包围盒。
* 更新采用 touched bucket 的 copy-on-write,未变索引保持身份,供局部订阅复用。 * 更新采用 touched bucket 的 copy-on-write,未变索引保持身份,供局部订阅复用。
* 查询完整性与命中成功分开:没查到不一定不存在,加载不完整/协议失败必须显式报告。 * 查询完整性与命中成功分开:没查到不一定不存在,加载不完整/协议失败必须显式报告。
* 工厂不执行 validateDocument;输入文档须已校验且不可变,局部加载的完整性由宿主绑定 coverage。
*/ */
import {rectangleBounds,hitRectangle} from './geometry.ts'; import {rectangleBounds,hitRectangle} from './geometry.ts';
import type {Change,Document,ElementRecord,Geometry,Id,Point,ReadonlyValueSet,Registry} from './types.ts'; import type {Change,Document,ElementRecord,Geometry,Id,Point,ReadonlyValueSet,Registry} from './types.ts';
@@ -18,7 +19,8 @@ type Relation=Map<Id,ReadonlyValueSet<Id>>;
type Spatial={cells:Relation;entries:Map<Id,Box>;oversized:ReadonlyValueSet<Id>}; type Spatial={cells:Relation;entries:Map<Id,Box>;oversized:ReadonlyValueSet<Id>};
/** 单元素声明引用的正向集合;complete=false 时不能据此证明它没有其它引用。 */ /** 单元素声明引用的正向集合;complete=false 时不能据此证明它没有其它引用。 */
type References={elements:ReadonlySet<Id>;canvases:ReadonlySet<Id>;complete:boolean}; type References={elements:ReadonlySet<Id>;canvases:ReadonlySet<Id>;complete:boolean};
/** cellSize 控制格子边长,两项上限限制单对象/单查询覆盖的格子数,避免坐标范围拖垮枚举。 */ /** cellSize 控制格子边长,两项上限限制单对象/单查询覆盖的格子数,避免坐标范围拖垮枚举。
* 这是格子枚举预算,不限制结果 ID 数或扫描耗时;超限查询可能转而扫描此 owner 全部节点。 */
export type QueryBudget={readonly cellSize?:number;readonly maxElementCells?:number;readonly maxQueryCells?:number}; export type QueryBudget={readonly cellSize?:number;readonly maxElementCells?:number;readonly maxQueryCells?:number};
/** 已补默认值并验证后的预算,后续同一索引链不能静默换配置。 */ /** 已补默认值并验证后的预算,后续同一索引链不能静默换配置。 */
type ResolvedBudget=Required<QueryBudget>; type ResolvedBudget=Required<QueryBudget>;
@@ -49,7 +51,8 @@ function cells(box:Box,limit:number,cellSize:number):string[]|undefined{
if(![x0,x1,y0,y1,count].every(Number.isSafeInteger)||count<1||count>limit)return; if(![x0,x1,y0,y1,count].every(Number.isSafeInteger)||count<1||count>limit)return;
const keys:string[]=[];for(let x=x0;x<=x1;x++)for(let y=y0;y<=y1;y++)keys.push(`${x},${y}`);return keys; const keys:string[]=[];for(let x=x0;x<=x1;x++)for(let y=y0;y<=y1;y++)keys.push(`${x},${y}`);return keys;
} }
/** 只解析组件明确声明的复制引用;未知 schema/解析失败标记不完整,不能猜 payload 中的 ID。 */ /** 只解析组件 copy.references 的声明路径;未知 schema/解析失败标记不完整,不猜 payload 中的 ID。
* complete 仅说明这些声明可解析,不验证被引用对象存在、external 策略或隐藏在未声明字段中的引用。 */
function refs(record:ElementRecord,registry:Registry):References{ function refs(record:ElementRecord,registry:Registry):References{
const elements=new Set<Id>(),canvases=new Set<Id>(),schema=registry.components.get(record.componentId)?.copy; const elements=new Set<Id>(),canvases=new Set<Id>(),schema=registry.components.get(record.componentId)?.copy;
if(registry.resolve(record)!=='known'||!schema)return{elements,canvases,complete:false}; if(registry.resolve(record)!=='known'||!schema)return{elements,canvases,complete:false};
@@ -72,7 +75,8 @@ export type ElementLookup={readonly status:'present';readonly element:ElementRec
export type IdQueryResult={readonly ids:readonly Id[];readonly complete:boolean}; export type IdQueryResult={readonly ids:readonly Id[];readonly complete:boolean};
/** 与一个确切 document/registry/revision 绑定的公共查询。 /** 与一个确切 document/registry/revision 绑定的公共查询。
* members/child/incident 提供稳定排序;Result 版本另外携带加载完整性。 * members/child/incident 提供稳定排序;Result 版本另外携带加载完整性。
* candidateNodes 是空间粗筛,hitNodeBounds 用矩形精判,hitNodes 使用组件 hit 协议。 */ * candidateNodes 是空间粗筛,hitNodeBounds 用矩形精判,hitNodes 使用组件 hit 协议。
* 查询的是指定 owner 的直接节点,不自动投影整棵子树、折叠重定向或检查编辑权限。 */
export type DocumentQueries={ export type DocumentQueries={
readonly coverage:QueryCoverage; readonly coverage:QueryCoverage;
lookupElement(id:Id):ElementLookup; lookupElement(id:Id):ElementLookup;
@@ -101,7 +105,8 @@ function expose(document:Document,revision:number,tables:Tables,previous?:Docume
// 关系子表按身份复用;referenceComplete 只有全部记录引用声明均可解释时才为真。 // 关系子表按身份复用;referenceComplete 只有全部记录引用声明均可解释时才为真。
const relation=Object.freeze({ownerMembers:previous&&tables.members===privateTables.get(previous)!.members?previous.relations.ownerMembers:readMap(tables.members),canvasChildren:previous&&tables.children===privateTables.get(previous)!.children?previous.relations.canvasChildren:readMap(tables.children),childCanvasByEntry:previous&&tables.childByEntry===privateTables.get(previous)!.childByEntry?previous.relations.childCanvasByEntry:readMap(tables.childByEntry),incidentEdges:previous&&tables.incident===privateTables.get(previous)!.incident?previous.relations.incidentEdges:readMap(tables.incident),elementReferrers:previous&&tables.elementRefs===privateTables.get(previous)!.elementRefs?previous.relations.elementReferrers:readMap(tables.elementRefs),canvasReferrers:previous&&tables.canvasRefs===privateTables.get(previous)!.canvasRefs?previous.relations.canvasReferrers:readMap(tables.canvasRefs),opaqueReferences:tables.opaque,referenceComplete:tables.opaque.size===0}); const relation=Object.freeze({ownerMembers:previous&&tables.members===privateTables.get(previous)!.members?previous.relations.ownerMembers:readMap(tables.members),canvasChildren:previous&&tables.children===privateTables.get(previous)!.children?previous.relations.canvasChildren:readMap(tables.children),childCanvasByEntry:previous&&tables.childByEntry===privateTables.get(previous)!.childByEntry?previous.relations.childCanvasByEntry:readMap(tables.childByEntry),incidentEdges:previous&&tables.incident===privateTables.get(previous)!.incident?previous.relations.incidentEdges:readMap(tables.incident),elementReferrers:previous&&tables.elementRefs===privateTables.get(previous)!.elementRefs?previous.relations.elementReferrers:readMap(tables.elementRefs),canvasReferrers:previous&&tables.canvasRefs===privateTables.get(previous)!.canvasRefs?previous.relations.canvasReferrers:readMap(tables.canvasRefs),opaqueReferences:tables.opaque,referenceComplete:tables.opaque.size===0});
const spatial=spatialViews(tables,previous); const spatial=spatialViews(tables,previous);
/** 从网格收集候选并做 AABB 过滤;超大查询扫描全部 entries,普通查询同时加入 oversized。 */ /** 从网格收集候选并做 AABB 过滤;超大查询扫描全部 entries,普通查询同时加入 oversized。
* 空间表只含 node;曲线命中由其它适配路径完成,返回顺序按模型插入 rank,不是渲染命中优先级。 */
function candidates(canvas:Id,rect:Bounds){ function candidates(canvas:Id,rect:Bounds){
if(!document.canvases.has(canvas))throw fault('canvas_not_loaded',[canvas]);if(![rect.x,rect.y,rect.width,rect.height].every(Number.isFinite)||rect.width<0||rect.height<0)throw fault('invalid_query_bounds'); if(!document.canvases.has(canvas))throw fault('canvas_not_loaded',[canvas]);if(![rect.x,rect.y,rect.width,rect.height].every(Number.isFinite)||rect.width<0||rect.height<0)throw fault('invalid_query_bounds');
// Set 合并多个格子的重复 ID;无效 Box 保留为候选,后续精判负责报告不完整。 // Set 合并多个格子的重复 ID;无效 Box 保留为候选,后续精判负责报告不完整。
@@ -118,9 +123,11 @@ function expose(document:Document,revision:number,tables:Tables,previous?:Docume
document,registry:tables.registry,revision,budget:tables.budget,relations:relation,spatial, document,registry:tables.registry,revision,budget:tables.budget,relations:relation,spatial,
// 关系查询首先检查目标表项存在,再按元素/画布各自 rank 排序;不是遍历请求 queue。 // 关系查询首先检查目标表项存在,再按元素/画布各自 rank 排序;不是遍历请求 queue。
members:(canvas:Id)=>{if(!document.canvases.has(canvas))throw fault('canvas_not_loaded',[canvas]);return sortedBucket(tables.members.get(canvas)??empty,tables.order);},childCanvases:(canvas:Id)=>{if(!document.canvases.has(canvas))throw fault('canvas_not_loaded',[canvas]);return sortedBucket(tables.children.get(canvas)??empty,tables.canvasOrder);},incident:(element:Id)=>{if(!document.elements.has(element))throw fault('element_missing',[element]);return sortedBucket(tables.incident.get(element)??empty,tables.order);},orderedElements:(ids:Iterable<Id>)=>sorted(ids),orderedCanvases:(ids:Iterable<Id>)=>sorted(ids,tables.canvasOrder),candidateNodes:candidates, members:(canvas:Id)=>{if(!document.canvases.has(canvas))throw fault('canvas_not_loaded',[canvas]);return sortedBucket(tables.members.get(canvas)??empty,tables.order);},childCanvases:(canvas:Id)=>{if(!document.canvases.has(canvas))throw fault('canvas_not_loaded',[canvas]);return sortedBucket(tables.children.get(canvas)??empty,tables.canvasOrder);},incident:(element:Id)=>{if(!document.elements.has(element))throw fault('element_missing',[element]);return sortedBucket(tables.incident.get(element)??empty,tables.order);},orderedElements:(ids:Iterable<Id>)=>sorted(ids),orderedCanvases:(ids:Iterable<Id>)=>sorted(ids,tables.canvasOrder),candidateNodes:candidates,
/** 点周围容差框粗筛,再调用自定义 hit(无协议则矩形回退);抛错项进入 unavailable。 */ /** 点周围容差框粗筛,再调用自定义 hit;无 node 协议或未知组件时回退到矩形,不自动列入 unavailable。
* 自定义 hit 抛错/返回非 boolean,或缓存 Box 非有限时才列 unavailable;命中不等于 registry 支持编辑。 */
hitNodes(canvas:Id,point:Point,tolerance=0){if(![point.x,point.y,tolerance].every(Number.isFinite)||tolerance<0)throw fault('invalid_query_point');const hits:Id[]=[],unavailable:Id[]=[];for(const id of candidates(canvas,{x:point.x-tolerance,y:point.y-tolerance,width:2*tolerance,height:2*tolerance})){const record=document.elements.get(id)! as Extract<ElementRecord,{structure:'node'}>;try{if(!finite(tables.spatial.get(canvas)!.entries.get(id)!))throw fault('geometry_unavailable');const protocol=tables.registry.resolve(record)==='known'?tables.registry.components.get(record.componentId)?.geometry:undefined,result=protocol?.kind==='node'?protocol.hit({element:record,geometry:record.geometry,point,tolerance}):hitRectangle(record.geometry,point,tolerance);if(typeof result!=='boolean')throw fault('invalid_geometry_response');if(result)hits.push(id);}catch{unavailable.push(id);}}return Object.freeze({ids:Object.freeze(hits),complete:!unavailable.length,unavailable:Object.freeze(unavailable)});}, hitNodes(canvas:Id,point:Point,tolerance=0){if(![point.x,point.y,tolerance].every(Number.isFinite)||tolerance<0)throw fault('invalid_query_point');const hits:Id[]=[],unavailable:Id[]=[];for(const id of candidates(canvas,{x:point.x-tolerance,y:point.y-tolerance,width:2*tolerance,height:2*tolerance})){const record=document.elements.get(id)! as Extract<ElementRecord,{structure:'node'}>;try{if(!finite(tables.spatial.get(canvas)!.entries.get(id)!))throw fault('geometry_unavailable');const protocol=tables.registry.resolve(record)==='known'?tables.registry.components.get(record.componentId)?.geometry:undefined,result=protocol?.kind==='node'?protocol.hit({element:record,geometry:record.geometry,point,tolerance}):hitRectangle(record.geometry,point,tolerance);if(typeof result!=='boolean')throw fault('invalid_geometry_response');if(result)hits.push(id);}catch{unavailable.push(id);}}return Object.freeze({ids:Object.freeze(hits),complete:!unavailable.length,unavailable:Object.freeze(unavailable)});},
/** 对候选按旋转矩形距离精判;无效几何使 complete=false,不当作可靠 miss。 */ /** 对候选按旋转矩形距离精判;缓存 Box 无效使 complete=false,不当作可靠 miss。
* 即使本次只需矩形,前面自定义 bounds 失败产生的 NaN Box 也会让该项跳过,不绕过共享粗筛失败。 */
hitNodeBounds(canvas:Id,point:Point,tolerance=0){if(![point.x,point.y,tolerance].every(Number.isFinite)||tolerance<0)throw fault('invalid_query_point');const ids=candidates(canvas,{x:point.x-tolerance,y:point.y-tolerance,width:2*tolerance,height:2*tolerance}),hits:Id[]=[];let complete=true;for(const id of ids){const g=(document.elements.get(id)! as Extract<ElementRecord,{structure:'node'}>).geometry,box=tables.spatial.get(canvas)!.entries.get(id)!;if(!finite(box)){complete=false;continue;}const angle=-g.rotation*Math.PI/180,c=Math.cos(angle),s=Math.sin(angle),dx=point.x-g.x-g.width/2,dy=point.y-g.y-g.height/2,x=dx*c-dy*s,y=dx*s+dy*c,ox=Math.max(0,Math.abs(x)-g.width/2),oy=Math.max(0,Math.abs(y)-g.height/2);if(Math.hypot(ox,oy)<=tolerance+Number.EPSILON*64*Math.max(1,Math.abs(point.x),Math.abs(point.y),g.width,g.height))hits.push(id);}return Object.freeze({ids:Object.freeze(hits),complete});}, hitNodeBounds(canvas:Id,point:Point,tolerance=0){if(![point.x,point.y,tolerance].every(Number.isFinite)||tolerance<0)throw fault('invalid_query_point');const ids=candidates(canvas,{x:point.x-tolerance,y:point.y-tolerance,width:2*tolerance,height:2*tolerance}),hits:Id[]=[];let complete=true;for(const id of ids){const g=(document.elements.get(id)! as Extract<ElementRecord,{structure:'node'}>).geometry,box=tables.spatial.get(canvas)!.entries.get(id)!;if(!finite(box)){complete=false;continue;}const angle=-g.rotation*Math.PI/180,c=Math.cos(angle),s=Math.sin(angle),dx=point.x-g.x-g.width/2,dy=point.y-g.y-g.height/2,x=dx*c-dy*s,y=dx*s+dy*c,ox=Math.max(0,Math.abs(x)-g.width/2),oy=Math.max(0,Math.abs(y)-g.height/2);if(Math.hypot(ox,oy)<=tolerance+Number.EPSILON*64*Math.max(1,Math.abs(point.x),Math.abs(point.y),g.width,g.height))hits.push(id);}return Object.freeze({ids:Object.freeze(hits),complete});},
});privateTables.set(view,tables);return view; });privateTables.set(view,tables);return view;
} }
@@ -128,10 +135,12 @@ function expose(document:Document,revision:number,tables:Tables,previous?:Docume
const queryBases=new WeakMap<DocumentQueries,DocumentQueries>(); const queryBases=new WeakMap<DocumentQueries,DocumentQueries>();
// 相同 namespace 来源只捕获一次只读副本,防止宿主后来修改集合污染已发布证据。 // 相同 namespace 来源只捕获一次只读副本,防止宿主后来修改集合污染已发布证据。
const namespaceCopies=new WeakMap<object,ReadonlyValueSet<Id>>(); const namespaceCopies=new WeakMap<object,ReadonlyValueSet<Id>>();
/** 捕获外部 ID 集合的成员,缓存仅按原对象身份复用;输入合同应视为不可变值。 */ /** 捕获外部 ID 集合的成员,缓存仅按原对象身份复用;输入合同应视为不可变值。
* 同一来源对象后续被原地改动,再绑定也仍取第一次副本;新的证据必须传新的集合对象。 */
function ownedNamespace(ids:ReadonlyValueSet<Id>){let value=namespaceCopies.get(ids);if(!value){value=readSet(ids);namespaceCopies.set(ids,value);}return value;} function ownedNamespace(ids:ReadonlyValueSet<Id>){let value=namespaceCopies.get(ids);if(!value){value=readSet(ids);namespaceCopies.set(ids,value);}return value;}
/** Bind completeness to the same publication as the model, without rebuilding indexes. */ /** Bind completeness to the same publication as the model, without rebuilding indexes. */
/** 不重建索引,仅替换同次发布中的加载证据与完整性方法;未知 canvas/矛盾完整声明拒绝。 */ /** 不重建索引,只绑定调用方提供的加载证据;检查重复/未知完整 canvas 以及 projectComplete 的显式矛盾。
* 不读取服务端 manifest,也不推导声明是否真实;基础 candidateNodes/关系读取仍只返回现有记录。 */
export function withQueryCoverage(queries:DocumentQueries,input:QueryCoverage):DocumentQueries { export function withQueryCoverage(queries:DocumentQueries,input:QueryCoverage):DocumentQueries {
const base=queryBases.get(queries)??queries,ids=Object.freeze([...input.completeCanvasIds]); const base=queryBases.get(queries)??queries,ids=Object.freeze([...input.completeCanvasIds]);
if(new Set(ids).size!==ids.length||ids.some(id=>!base.document.canvases.has(id))||input.projectComplete&&(!input.topologyComplete||ids.length!==base.document.canvases.size))throw fault('invalid_query_coverage'); if(new Set(ids).size!==ids.length||ids.some(id=>!base.document.canvases.has(id))||input.projectComplete&&(!input.topologyComplete||ids.length!==base.document.canvases.size))throw fault('invalid_query_coverage');
@@ -139,7 +148,8 @@ export function withQueryCoverage(queries:DocumentQueries,input:QueryCoverage):D
const coverage=Object.freeze({...(input.elementIds?{elementIds:ownedNamespace(input.elementIds)}:{}),...(input.loadedElementIds?{loadedElementIds:ownedNamespace(input.loadedElementIds)}:{}),projectComplete:input.projectComplete,topologyComplete:input.topologyComplete,completeCanvasIds:ids}),complete=new Set(ids); const coverage=Object.freeze({...(input.elementIds?{elementIds:ownedNamespace(input.elementIds)}:{}),...(input.loadedElementIds?{loadedElementIds:ownedNamespace(input.loadedElementIds)}:{}),projectComplete:input.projectComplete,topologyComplete:input.topologyComplete,completeCanvasIds:ids}),complete=new Set(ids);
const view:DocumentQueries=Object.freeze({...base,coverage, const view:DocumentQueries=Object.freeze({...base,coverage,
relations:Object.freeze({...base.relations,referenceComplete:base.relations.referenceComplete&&coverage.projectComplete}), relations:Object.freeze({...base.relations,referenceComplete:base.relations.referenceComplete&&coverage.projectComplete}),
/** 已有记录优先 present;完整项目、命名空间排除、或已加载且有墓碑时才能判 absent。 */ /** 已有记录优先 present;projectComplete 可直接判缺失为 absent。
* 部分项目须有 elementIds:命名空间排除该 ID,或该 ID 已加载且有墓碑时才判 absent,其余 unloaded。 */
lookupElement:(id:Id):ElementLookup=>{const element=base.document.elements.get(id);return element?Object.freeze({status:'present',element}):Object.freeze({status:coverage.projectComplete||coverage.elementIds&&(!coverage.elementIds.has(id)||coverage.loadedElementIds?.has(id)&&base.document.tombstones.has(id))?'absent':'unloaded'});}, lookupElement:(id:Id):ElementLookup=>{const element=base.document.elements.get(id);return element?Object.freeze({status:'present',element}):Object.freeze({status:coverage.projectComplete||coverage.elementIds&&(!coverage.elementIds.has(id)||coverage.loadedElementIds?.has(id)&&base.document.tombstones.has(id))?'absent':'unloaded'});},
// 成员完整性按 owner,子树按 topology,关联边按整个项目,三者不能互相替代。 // 成员完整性按 owner,子树按 topology,关联边按整个项目,三者不能互相替代。
membersResult:(canvas:Id)=>Object.freeze({ids:base.members(canvas),complete:complete.has(canvas)}), membersResult:(canvas:Id)=>Object.freeze({ids:base.members(canvas),complete:complete.has(canvas)}),
@@ -152,13 +162,16 @@ export function withQueryCoverage(queries:DocumentQueries,input:QueryCoverage):D
});queryBases.set(view,base);const tables=privateTables.get(base);if(tables)privateTables.set(view,tables);return view; });queryBases.set(view,base);const tables=privateTables.get(base);if(tables)privateTables.set(view,tables);return view;
} }
/** Pure derived query snapshot. Never serialize these tables or publish them as a second document. */ /** Pure derived query snapshot. Never serialize these tables or publish them as a second document. */
/** 从零构造纯派生索引,复用同一 advance 算法,避免维护两套建表规则。 */ /** 从零构造纯派生索引,复用 advance 建表;默认把输入当完整文档,不自动检测部分加载。
* 部分模型调用方必须随后 withQueryCoverage 绑定真实证据,否则默认 complete=true 不代表读取已完成。 */
export function createDocumentQueries(document:Document,registry:Registry,revision=0,options:QueryBudget={}):DocumentQueries{return advanceDocumentQueries(undefined,document,registry,revision,undefined,options);} export function createDocumentQueries(document:Document,registry:Registry,revision=0,options:QueryBudget={}):DocumentQueries{return advanceDocumentQueries(undefined,document,registry,revision,undefined,options);}
/** 在当前发布前准备下一份索引;changes 精确列出受影响 ID,否则按新旧表并集检查。 /** 在当前发布前准备下一份索引;changes 精确列出受影响 ID,否则按新旧表并集检查。
* 必须与同一项目/registry/预算配合,revision 不可倒退,同 revision 不接受另一份 document。 */ * 必须与同一项目/registry/预算配合,revision 不可倒退,同 revision 不接受另一份 document。
* changes 是可信提示,不核对是否漏列真实差异;提供不完整/错误提示可能留下旧索引,无清单时才全表比较。 */
export function advanceDocumentQueries(previous:DocumentQueries|undefined,document:Document,registry:Registry,revision:number,changes?:readonly Change[],options:QueryBudget={}):DocumentQueries{ export function advanceDocumentQueries(previous:DocumentQueries|undefined,document:Document,registry:Registry,revision:number,changes?:readonly Change[],options:QueryBudget={}):DocumentQueries{
if(!Number.isSafeInteger(revision)||revision<0||previous&&(previous.document.projectId!==document.projectId||revision<previous.revision||revision===previous.revision&&document!==previous.document))throw fault('invalid_query_revision'); if(!Number.isSafeInteger(revision)||revision<0||previous&&(previous.document.projectId!==document.projectId||revision<previous.revision||revision===previous.revision&&document!==previous.document))throw fault('invalid_query_revision');
const old=previous?privateTables.get(previous):undefined;if(previous&&!old)throw fault('invalid_query_snapshot');if(old&&old.registry!==registry)throw fault('query_registry_changed');if(old&&Object.keys(options).length&&!equal(old.budget,budget(options)))throw fault('query_budget_changed');if(previous?.document===document&&previous.revision===revision)return previous; const old=previous?privateTables.get(previous):undefined;if(previous&&!old)throw fault('invalid_query_snapshot');if(old&&old.registry!==registry)throw fault('query_registry_changed');if(old&&Object.keys(options).length&&!equal(old.budget,budget(options)))throw fault('query_budget_changed');if(previous?.document===document&&previous.revision===revision)return previous;
// 非空 options 是按默认值补齐后的完整预算声明,不是叠加旧预算的 patch;须与旧配置相等。
// 精确 changes 限定扫描范围;ACK 等没有差异列表的路径保守比较新旧记录。 // 精确 changes 限定扫描范围;ACK 等没有差异列表的路径保守比较新旧记录。
const configuration=old?.budget??budget(options),oldDoc=previous?.document,ids=changes?new Set(changes.filter(change=>change.kind!=='canvas').map(change=>change.kind==='field'?change.target.id:change.id)):new Set([...(oldDoc?.elements.keys()??[]),...document.elements.keys()]),canvasIds=changes?new Set(changes.filter(change=>change.kind==='canvas').map(change=>change.id)):new Set([...(oldDoc?.canvases.keys()??[]),...document.canvases.keys()]); const configuration=old?.budget??budget(options),oldDoc=previous?.document,ids=changes?new Set(changes.filter(change=>change.kind!=='canvas').map(change=>change.kind==='field'?change.target.id:change.id)):new Set([...(oldDoc?.elements.keys()??[]),...document.elements.keys()]),canvasIds=changes?new Set(changes.filter(change=>change.kind==='canvas').map(change=>change.id)):new Set([...(oldDoc?.canvases.keys()??[]),...document.canvases.keys()]);
const members=editRelation(old?.members??new Map()),children=editRelation(old?.children??new Map()),incident=editRelation(old?.incident??new Map()),elementRefs=editRelation(old?.elementRefs??new Map()),canvasRefs=editRelation(old?.canvasRefs??new Map());let childByEntry=old?.childByEntry??new Map<Id,Id>(),forward=old?.forward??new Map<Id,References>(),opaque=old?.opaque??empty,spatial=old?.spatial??new Map<Id,Spatial>(); const members=editRelation(old?.members??new Map()),children=editRelation(old?.children??new Map()),incident=editRelation(old?.incident??new Map()),elementRefs=editRelation(old?.elementRefs??new Map()),canvasRefs=editRelation(old?.canvasRefs??new Map());let childByEntry=old?.childByEntry??new Map<Id,Id>(),forward=old?.forward??new Map<Id,References>(),opaque=old?.opaque??empty,spatial=old?.spatial??new Map<Id,Spatial>();
@@ -177,7 +190,7 @@ export function advanceDocumentQueries(previous:DocumentQueries|undefined,docume
const oldRefs=forward.get(id),schema=after?registry.components.get(after.componentId)?.copy:undefined,referenceFieldsUnchanged=!!changes&&!replaced.has(id)&&!!schema&&schema.references.every(ref=>!dataFields.get(id)?.has(ref.path[0])),nextRefs=after?(before&&oldRefs&&before.componentId===after.componentId&&before.dataVersion===after.dataVersion&&(referenceFieldsUnchanged||equal(before.data,after.data))?oldRefs:refs(after,registry)):undefined; const oldRefs=forward.get(id),schema=after?registry.components.get(after.componentId)?.copy:undefined,referenceFieldsUnchanged=!!changes&&!replaced.has(id)&&!!schema&&schema.references.every(ref=>!dataFields.get(id)?.has(ref.path[0])),nextRefs=after?(before&&oldRefs&&before.componentId===after.componentId&&before.dataVersion===after.dataVersion&&(referenceFieldsUnchanged||equal(before.data,after.data))?oldRefs:refs(after,registry)):undefined;
if(oldRefs!==nextRefs)for(const kind of ['elements','canvases'] as const){const table=kind==='elements'?elementRefs:canvasRefs;for(const target of oldRefs?.[kind]??[])if(!nextRefs?.[kind].has(target))table.change(target,id,false);for(const target of nextRefs?.[kind]??[])if(!oldRefs?.[kind].has(target))table.change(target,id,true);} if(oldRefs!==nextRefs)for(const kind of ['elements','canvases'] as const){const table=kind==='elements'?elementRefs:canvasRefs;for(const target of oldRefs?.[kind]??[])if(!nextRefs?.[kind].has(target))table.change(target,id,false);for(const target of nextRefs?.[kind]??[])if(!oldRefs?.[kind].has(target))table.change(target,id,true);}
if(oldRefs!==nextRefs&&(!!oldRefs?.complete!==!!nextRefs?.complete||!equal([...(oldRefs?.elements??[])],[...(nextRefs?.elements??[])])||!equal([...(oldRefs?.canvases??[])],[...(nextRefs?.canvases??[])])||!oldRefs&&nextRefs||oldRefs&&!nextRefs)){if(forward===old?.forward)forward=new Map(forward);if(nextRefs)forward.set(id,nextRefs);else forward.delete(id);} if(oldRefs!==nextRefs&&(!!oldRefs?.complete!==!!nextRefs?.complete||!equal([...(oldRefs?.elements??[])],[...(nextRefs?.elements??[])])||!equal([...(oldRefs?.canvases??[])],[...(nextRefs?.canvases??[])])||!oldRefs&&nextRefs||oldRefs&&!nextRefs)){if(forward===old?.forward)forward=new Map(forward);if(nextRefs)forward.set(id,nextRefs);else forward.delete(id);}
// 无法解释引用的活元素进入 opaque 集合,提示调用方不能把空引用结果当作完整证明。 // 无法解释引用的活元素进入 opaque 集合;opaqueReferences 描述引用可解释性,不表示整元素已失去几何/显示能力。
const shouldOpaque=!!after&&!nextRefs?.complete;if(opaque.has(id)!==shouldOpaque){const next=new Set(opaque);if(shouldOpaque)next.add(id);else next.delete(id);opaque=readSet(next);} const shouldOpaque=!!after&&!nextRefs?.complete;if(opaque.has(id)!==shouldOpaque){const next=new Set(opaque);if(shouldOpaque)next.add(id);else next.delete(id);opaque=readSet(next);}
// 自定义几何未声明 dataDependencies 时保守重建;明确空数组则普通 data 编辑不重算 Box。 // 自定义几何未声明 dataDependencies 时保守重建;明确空数组则普通 data 编辑不重算 Box。
const was=before?.structure==='node'?before:undefined,now=after?.structure==='node'?after:undefined,protocol=now&&registry.resolve(now)==='known'?registry.components.get(now.componentId)?.geometry:undefined,shapeDataChanged=protocol?.kind==='node'&&!equal(before?.data,after?.data)&&(protocol.dataDependencies===undefined||protocol.dataDependencies.some(key=>!equal(before?.data[key],after?.data[key])));if(was?.ownerCanvasId!==now?.ownerCanvasId||!equal(was?.geometry,now?.geometry)||before?.componentId!==after?.componentId||before?.dataVersion!==after?.dataVersion||shapeDataChanged){if(was)spatialChange(was.ownerCanvasId,id,null);if(now)spatialChange(now.ownerCanvasId,id,nodeBox(now,registry));} const was=before?.structure==='node'?before:undefined,now=after?.structure==='node'?after:undefined,protocol=now&&registry.resolve(now)==='known'?registry.components.get(now.componentId)?.geometry:undefined,shapeDataChanged=protocol?.kind==='node'&&!equal(before?.data,after?.data)&&(protocol.dataDependencies===undefined||protocol.dataDependencies.some(key=>!equal(before?.data[key],after?.data[key])));if(was?.ownerCanvasId!==now?.ownerCanvasId||!equal(was?.geometry,now?.geometry)||before?.componentId!==after?.componentId||before?.dataVersion!==after?.dataVersion||shapeDataChanged){if(was)spatialChange(was.ownerCanvasId,id,null);if(now)spatialChange(now.ownerCanvasId,id,nodeBox(now,registry));}
@@ -187,6 +200,7 @@ export function advanceDocumentQueries(previous:DocumentQueries|undefined,docume
for(const id of canvasIds){const before=oldDoc?.canvases.get(id)?.parent,after=document.canvases.get(id)?.parent;if(equal(before,after)||!after)continue;children.change(after.canvasId,id,true);if(childByEntry===old?.childByEntry)childByEntry=new Map(childByEntry);childByEntry.set(after.entryElementId,id);} for(const id of canvasIds){const before=oldDoc?.canvases.get(id)?.parent,after=document.canvases.get(id)?.parent;if(equal(before,after)||!after)continue;children.change(after.canvasId,id,true);if(childByEntry===old?.childByEntry)childByEntry=new Map(childByEntry);childByEntry.set(after.entryElementId,id);}
// 删除旧格子占用、再插新 Box;超限/无效 Box 放 oversized,不能因预算或错误漏出候选。 // 删除旧格子占用、再插新 Box;超限/无效 Box 放 oversized,不能因预算或错误漏出候选。
if(spatialChanges.size){spatial=new Map(spatial);for(const[canvas,updates]of spatialChanges){const base=spatial.get(canvas),entries=new Map(base?.entries),grid=editRelation(base?.cells??new Map()),large=new Set(base?.oversized??[]);for(const[id,box]of updates){const before=entries.get(id);if(before)for(const key of cells(before,configuration.maxElementCells,configuration.cellSize)??[])grid.change(key,id,false);large.delete(id);if(box){entries.set(id,box);const keys=cells(box,configuration.maxElementCells,configuration.cellSize);if(keys)for(const key of keys)grid.change(key,id,true);else large.add(id);}else entries.delete(id);}if(entries.size)spatial.set(canvas,{entries,cells:grid.finish(),oversized:readSet(large)});else spatial.delete(canvas);}} if(spatialChanges.size){spatial=new Map(spatial);for(const[canvas,updates]of spatialChanges){const base=spatial.get(canvas),entries=new Map(base?.entries),grid=editRelation(base?.cells??new Map()),large=new Set(base?.oversized??[]);for(const[id,box]of updates){const before=entries.get(id);if(before)for(const key of cells(before,configuration.maxElementCells,configuration.cellSize)??[])grid.change(key,id,false);large.delete(id);if(box){entries.set(id,box);const keys=cells(box,configuration.maxElementCells,configuration.cellSize);if(keys)for(const key of keys)grid.change(key,id,true);else large.add(id);}else entries.delete(id);}if(entries.size)spatial.set(canvas,{entries,cells:grid.finish(),oversized:readSet(large)});else spatial.delete(canvas);}}
// 关系桶 finish 后装配;只在增删记录时重算全局插入顺序,最后返回与文档同 revision 的视图。 // 关系桶 finish 后装配;精确 changes 未报告增删时复用旧 rank,否则按文档迭代顺序重算/比较。
// expose 返回基础完整 coverage,不继承旧查询的加载声明;宿主应在同次发布重新绑定当前 coverage。
const tables:Tables={registry,budget:configuration,members:members.finish(),children:children.finish(),childByEntry,incident:incident.finish(),elementRefs:elementRefs.finish(),canvasRefs:canvasRefs.finish(),forward,opaque,spatial,order:old&&changes&&!changes.some(change=>change.kind==='element'&&(!change.before||!change.after))?old.order:order(old?.order,document.elements.keys()),canvasOrder:old&&changes&&!changes.some(change=>change.kind==='canvas'&&(!change.before||!change.after))?old.canvasOrder:order(old?.canvasOrder,document.canvases.keys())};return expose(document,revision,tables,previous); const tables:Tables={registry,budget:configuration,members:members.finish(),children:children.finish(),childByEntry,incident:incident.finish(),elementRefs:elementRefs.finish(),canvasRefs:canvasRefs.finish(),forward,opaque,spatial,order:old&&changes&&!changes.some(change=>change.kind==='element'&&(!change.before||!change.after))?old.order:order(old?.order,document.elements.keys()),canvasOrder:old&&changes&&!changes.some(change=>change.kind==='canvas'&&(!change.before||!change.after))?old.canvasOrder:order(old?.canvasOrder,document.canvases.keys())};return expose(document,revision,tables,previous);
} }
+11 -5
View File
@@ -2,6 +2,7 @@
* 组件定义的装配与显式数据迁移。Registry 保存可复用的定义/handler,不保存元素实例或 view 状态。 * 组件定义的装配与显式数据迁移。Registry 保存可复用的定义/handler,不保存元素实例或 view 状态。
* 装配时把 feature 合成为 componentId→capability→handler 查找表,冲突即拒绝,避免依赖安装顺序。 * 装配时把 feature 合成为 componentId→capability→handler 查找表,冲突即拒绝,避免依赖安装顺序。
* 迁移只生成候选记录,不写 editor;调用方负责把结果作为一次合法事务发布。 * 迁移只生成候选记录,不写 editor;调用方负责把结果作为一次合法事务发布。
* 冻结的是定义配置容器,handler 函数及其闭包仍沿用;容器只读不证明外部上下文不会变化。
*/ */
import type { Capability, CapabilityHandler, ComponentDefinition, Data, ElementRecord, Feature, Json, Registry, ReadonlyValueSet } from './types.ts'; import type { Capability, CapabilityHandler, ComponentDefinition, Data, ElementRecord, Feature, Json, Registry, ReadonlyValueSet } from './types.ts';
import { clone, fault, freeze, readMap, readSet } from './values.ts'; import { clone, fault, freeze, readMap, readSet } from './values.ts';
@@ -11,7 +12,8 @@ export function defineFeature(id: string, handlers: Partial<Record<Capability, C
if (!id || !Object.values(handlers).length || Object.values(handlers).some(value => typeof value !== 'function')) throw fault('invalid_feature'); if (!id || !Object.values(handlers).length || Object.values(handlers).some(value => typeof value !== 'function')) throw fault('invalid_feature');
return freeze({ id, handlers: { ...handlers } }); return freeze({ id, handlers: { ...handlers } });
} }
/** 校验组件定义与复制/几何协议,捕获其配置容器,防止装配后被外部改写。 */ /** 校验组件定义与复制/几何协议,并复制声明过的配置容器后冻结。
* validate、几何方法、feature handler 和 migration 函数本身保持原引用,不复制函数闭包。 */
export function defineComponent(definition: ComponentDefinition): ComponentDefinition { export function defineComponent(definition: ComponentDefinition): ComponentDefinition {
if (!definition.id || !Number.isSafeInteger(definition.dataVersion) || definition.dataVersion < 1 || typeof definition.validate !== 'function') throw fault('invalid_component'); if (!definition.id || !Number.isSafeInteger(definition.dataVersion) || definition.dataVersion < 1 || typeof definition.validate !== 'function') throw fault('invalid_component');
// 复制路径访问自有字段,禁止原型污染键;数组遍历由 * 路径段表达。 // 复制路径访问自有字段,禁止原型污染键;数组遍历由 * 路径段表达。
@@ -47,7 +49,8 @@ export function createRegistry(definitions: readonly ComponentDefinition[]): Reg
} }
components.set(definition.id, definition); capabilities.set(definition.id, readSet(found.keys())); handlers.set(definition.id, readMap(found)); components.set(definition.id, definition); capabilities.set(definition.id, readSet(found.keys())); handlers.set(definition.id, readMap(found));
} }
// 只识别已安装的精确数据版本;可保留未知记录,但不能假设它可被当前 handler 编辑。 // 这里只按 componentId+dataVersion 分派,不执行 data schema 或结构校验;known 不是整条记录合法的证明。
// 可保留未知记录,但不能假设它可被当前 handler 编辑。
const resolve: Registry['resolve'] = element => { const resolve: Registry['resolve'] = element => {
const definition = components.get(element.componentId); const definition = components.get(element.componentId);
if (!definition) return 'unknown-component'; if (!definition) return 'unknown-component';
@@ -59,10 +62,10 @@ export function createRegistry(definitions: readonly ComponentDefinition[]): Reg
supports: (element: ElementRecord, capability: Capability) => resolve(element) === 'known' && !!capabilities.get(element.componentId)?.has(capability) }); supports: (element: ElementRecord, capability: Capability) => resolve(element) === 'known' && !!capabilities.get(element.componentId)?.has(capability) });
} }
/** Capture each field once; JSON.stringify would silently erase invalid values. */ /** Capture each field once; JSON.stringify would silently erase invalid values. */
/** 迁移边界的严格 JSON 捕获:每个字段只读取一次,捕获后递归冻结。 /** 迁移边界的 JSON 捕获:遍历的自有可枚举字符串字段取值一次,捕获后递归冻结。
* 不先 JSON.stringify,因为 undefined、函数、非有限数会被丢弃/改值,掩盖非法迁移输出。 */ * 不先 JSON.stringify,因为 undefined、函数、非有限数会被丢弃/改值,掩盖非法迁移输出。 */
function captureMigrationData(input:unknown,id:string):Data { function captureMigrationData(input:unknown,id:string):Data {
// 统一带原元素 ID 抛出 invalid_migration,便于调用方保留原记录并报告失败对象。 // 本函数发现形状非法时带原元素 ID 抛 invalid_migration;getter/转换器自身抛出的异常不在这里包装。
const invalid=():never=>{throw fault('invalid_migration',[id]);}; const invalid=():never=>{throw fault('invalid_migration',[id]);};
// 递归接收 JSON 原子、稠密数组和普通对象;深度上限防止循环/异常深输入无限递归。 // 递归接收 JSON 原子、稠密数组和普通对象;深度上限防止循环/异常深输入无限递归。
const capture=(value:unknown,depth=0):Json=>{ const capture=(value:unknown,depth=0):Json=>{
@@ -78,6 +81,7 @@ function captureMigrationData(input:unknown,id:string):Data {
const result:Json[]=[];for(let index=0;index<value.length;index++){if(!Object.hasOwn(value,index))return invalid();result.push(capture(value[index],depth+1));}return freeze(result); const result:Json[]=[];for(let index=0;index<value.length;index++){if(!Object.hasOwn(value,index))return invalid();result.push(capture(value[index],depth+1));}return freeze(result);
} }
if(Object.getPrototypeOf(value)!==Object.prototype)return invalid(); if(Object.getPrototypeOf(value)!==Object.prototype)return invalid();
// Object.keys 不遍历不可枚举字段;getter 在这里读取,结果复制为普通数据属性,不保留原描述符。
const source=value as Record<string,unknown>; const source=value as Record<string,unknown>;
return freeze(Object.fromEntries(Object.keys(source).map(key=>[key,capture(source[key],depth+1)]))); return freeze(Object.fromEntries(Object.keys(source).map(key=>[key,capture(source[key],depth+1)])));
}; };
@@ -88,9 +92,11 @@ function captureMigrationData(input:unknown,id:string):Data {
/** 按 migrations[旧版本] 逐级转换到当前版本,每步重新捕获,最后通过目标 schema。 /** 按 migrations[旧版本] 逐级转换到当前版本,每步重新捕获,最后通过目标 schema。
* 未知组件/未来版本原样保留;缺 converter 或任一步失败抛错,不返回半迁移记录。 * 未知组件/未来版本原样保留;缺 converter 或任一步失败抛错,不返回半迁移记录。
* ID、incarnation、owner 与其它信封字段不变,转换器只负责 data。 */ * ID、incarnation、owner 与其它信封字段值不变,转换器只负责 data。
* 其余信封对象按引用沿用,返回时 freeze 可能冻结这些共享嵌套值;调用方应传入已捕获的记录。 */
export function migrateElement(element: ElementRecord, registry: Registry): ElementRecord { export function migrateElement(element: ElementRecord, registry: Registry): ElementRecord {
const definition = registry.components.get(element.componentId); const definition = registry.components.get(element.componentId);
// 未安装/未来版本保持原引用且不捕获;即使版本已是当前版本,下方仍捕获 data 并校验 schema。
if (!definition || element.dataVersion > definition.dataVersion) return element; if (!definition || element.dataVersion > definition.dataVersion) return element;
let data = captureMigrationData(element.data,element.id), version = element.dataVersion; let data = captureMigrationData(element.data,element.id), version = element.dataVersion;
// 一个 converter 仅前进一个版本,不把“有版本号”当成自动跨版本兼容承诺。 // 一个 converter 仅前进一个版本,不把“有版本号”当成自动跨版本兼容承诺。
+8 -2
View File
@@ -1,6 +1,7 @@
/** /**
* 文档结构修改的纯规划函数:删除拥有子树,以及重挂后的连线 owner 修复。 * 文档结构修改的纯规划函数:删除拥有子树,以及重挂后的连线 owner 修复。
* 只返回条件 Change,不直接发布 editor 状态;调用方把它们合入一次事务再校验。 * 只返回条件 Change,不直接发布 editor 状态;调用方把它们合入一次事务再校验。
* 文档应是有效、已捕获的不可变快照;返回 before 保留记录引用,freeze 不提供任意外部输入隔离。
*/ */
import type {DocumentQueries} from './queries.ts'; import type {DocumentQueries} from './queries.ts';
import type { Change, Document, Id, Target } from './types.ts'; import type { Change, Document, Id, Target } from './types.ts';
@@ -18,11 +19,13 @@ export function removalChanges(document:Document,target:Target,queries?:Document
while(stack.length){const id=stack.pop()!;if(canvases.has(id))continue;canvases.add(id);stack.push(...queries.childCanvases(id));for(const member of queries.members(id))elements.add(member);} while(stack.length){const id=stack.pop()!;if(canvases.has(id))continue;canvases.add(id);stack.push(...queries.childCanvases(id));for(const member of queries.members(id))elements.add(member);}
// 关联边可能属于更上级 canvas,即使不在子树成员表中也必须随端点删除。 // 关联边可能属于更上级 canvas,即使不在子树成员表中也必须随端点删除。
for(const id of [...elements])for(const edge of queries.incident(id))elements.add(edge); for(const id of [...elements])for(const edge of queries.incident(id))elements.add(edge);
// 按原表顺序输出元素删除,再输出 canvas 删除;最终关系合法性在事务整批完成后检查。
return freeze([...queries.orderedElements(elements).map(id=>({kind:'element' as const,id,before:document.elements.get(id)!,after:null})),...queries.orderedCanvases(canvases).map(id=>({kind:'canvas' as const,id,before:document.canvases.get(id)!,after:null}))]); return freeze([...queries.orderedElements(elements).map(id=>({kind:'element' as const,id,before:document.elements.get(id)!,after:null})),...queries.orderedCanvases(canvases).map(id=>({kind:'canvas' as const,id,before:document.canvases.get(id)!,after:null}))]);
} }
// 扫描路径构造 parent→children 临时表;Set 去重,stack 避免 JS 递归调用栈增长。 // 扫描路径构造 parent→children 临时表;Set 去重,stack 避免 JS 递归调用栈增长。
const element=targetElement(document,target),elements=new Set<Id>([element.id]),canvases=new Set<Id>(),children=new Map<Id,Id[]>(); const element=targetElement(document,target),elements=new Set<Id>([element.id]),canvases=new Set<Id>(),children=new Map<Id,Id[]>();
for(const canvas of document.canvases.values())if(canvas.parent){const list=children.get(canvas.parent.canvasId)??[];list.push(canvas.id);children.set(canvas.parent.canvasId,list);} for(const canvas of document.canvases.values())if(canvas.parent){const list=children.get(canvas.parent.canvasId)??[];list.push(canvas.id);children.set(canvas.parent.canvasId,list);}
// 只从“目标是入口”的子 canvas 开始,不因节点视觉上处在某个矩形内就扩大删除范围。
const stack=[...document.canvases.values()].filter(canvas=>canvas.parent?.entryElementId===element.id).map(canvas=>canvas.id); const stack=[...document.canvases.values()].filter(canvas=>canvas.parent?.entryElementId===element.id).map(canvas=>canvas.id);
while(stack.length){const id=stack.pop()!;if(canvases.has(id))continue;canvases.add(id);for(const child of children.get(id)??[])stack.push(child);} while(stack.length){const id=stack.pop()!;if(canvases.has(id))continue;canvases.add(id);for(const child of children.get(id)??[])stack.push(child);}
// 先收集全部被删成员,再找相关边,防止边扫描先后顺序影响删除结果。 // 先收集全部被删成员,再找相关边,防止边扫描先后顺序影响删除结果。
@@ -35,11 +38,13 @@ export function removalChanges(document:Document,target:Target,queries?:Document
} }
/** Ownership changes and their curve coordinates are one operation, with the same stable edge identity. */ /** Ownership changes and their curve coordinates are one operation, with the same stable edge identity. */
/** 端点重挂后,把边移到两端所属 canvas 的最近共同上级,保持边 ID/incarnation。 /** 端点或端点 owner 改变后,把边移到两端所属 canvas 的最近共同上级,保持边 ID/incarnation。
* 曲线坐标属于旧 owner 空间,因此 owner 与 path 必须作为同一操作一起转换。 */ * 曲线坐标属于旧 owner 空间,因此需要转换时 owner 与 path 必须作为同一操作一起提交。
* 只修复 owner 发生变化的边;不会仅因祖先几何变了就重写同 owner 的局部路径。 */
export function repairConnectionOwners(before:Document,document:Document):readonly Change[] { export function repairConnectionOwners(before:Document,document:Document):readonly Change[] {
const changes:Change[]=[]; const changes:Change[]=[];
for(const edge of document.elements.values())if(edge.structure==='connection'){ for(const edge of document.elements.values())if(edge.structure==='connection'){
// 缺端点暂时跳过,交给整图校验拒绝;此规划器不会自行补端点或删除悬空边。
const source=document.elements.get(edge.source.elementId),target=document.elements.get(edge.target.elementId);if(!source||!target)continue; const source=document.elements.get(edge.source.elementId),target=document.elements.get(edge.target.elementId);if(!source||!target)continue;
const owner=commonCanvas(document,source.ownerCanvasId,target.ownerCanvasId);if(owner===edge.ownerCanvasId)continue; const owner=commonCanvas(document,source.ownerCanvasId,target.ownerCanvasId);if(owner===edge.ownerCanvasId)continue;
// 旧 owner 可能已从新文档移除:优先用 before 的变换解释旧曲线,再转到新 owner。 // 旧 owner 可能已从新文档移除:优先用 before 的变换解释旧曲线,再转到新 owner。
@@ -47,5 +52,6 @@ export function repairConnectionOwners(before:Document,document:Document):readon
changes.push({kind:'field',target:{id:edge.id,incarnation:edge.incarnation},field:'ownerCanvasId',before:edge.ownerCanvasId,after:owner}); changes.push({kind:'field',target:{id:edge.id,incarnation:edge.incarnation},field:'ownerCanvasId',before:edge.ownerCanvasId,after:owner});
if(!equal(edge.path,path))changes.push({kind:'field',target:{id:edge.id,incarnation:edge.incarnation},field:'path',before:edge.path,after:path}); if(!equal(edge.path,path))changes.push({kind:'field',target:{id:edge.id,incarnation:edge.incarnation},field:'path',before:edge.path,after:path});
} }
// 这里只返回补偿变化;document 参数不在本函数内被替换或发布。
return freeze(changes); return freeze(changes);
} }
+23 -11
View File
@@ -17,7 +17,8 @@ export type Point = { readonly x: number; readonly y: number };
export type Geometry = Point & { readonly width: number; readonly height: number; readonly rotation: number }; export type Geometry = Point & { readonly width: number; readonly height: number; readonly rotation: number };
/** 拥有树的一条记录;根的 parent 为 null,子 canvas 指向父 canvas 及父内的入口元素。 */ /** 拥有树的一条记录;根的 parent 为 null,子 canvas 指向父 canvas 及父内的入口元素。 */
export type CanvasRecord = { readonly id: Id; readonly parent: null | { readonly canvasId: Id; readonly entryElementId: Id } }; export type CanvasRecord = { readonly id: Id; readonly parent: null | { readonly canvasId: Id; readonly entryElementId: Id } };
/** 元素 ID 加生命周期 incarnation;删除后恢复同 ID 时换代,旧回调不能命中新对象。 */ /** 元素 ID 加生命周期 incarnation;删除后恢复同 ID 时换代,由 targetElement 等运行时检查拦截旧目标。
* 类型本身不验证代次数值,也不是权限凭证。 */
export type Target = { readonly id: Id; readonly incarnation: number }; export type Target = { readonly id: Id; readonly incarnation: number };
/** 连线指向的节点及可选端口;端点的 owner 不必等于连线 owner。 */ /** 连线指向的节点及可选端口;端点的 owner 不必等于连线 owner。 */
export type Endpoint = { readonly elementId: Id; readonly portId?: string }; export type Endpoint = { readonly elementId: Id; readonly portId?: string };
@@ -28,11 +29,13 @@ export type ElementRecord = ElementBase & (
| { readonly structure: 'node'; readonly geometry: Geometry } | { readonly structure: 'node'; readonly geometry: Geometry }
| { readonly structure: 'connection'; readonly source: Endpoint; readonly target: Endpoint; readonly path: Json } | { readonly structure: 'connection'; readonly source: Endpoint; readonly target: Endpoint; readonly path: Json }
); );
/** 项目规范化事实表;父子/引用关系通过 ID 连接,tombstones 保留已死代次下限。 */ /** 项目规范化事实表;父子/引用关系通过 ID 连接,tombstones 保存每个 ID 已死亡的最大代次。
* Map 在运行时使用,持久化要经 codec;readonly 类型不等于 JSON 可直接序列化或输入已校验。 */
export type Document = { readonly projectId: Id; readonly canvases: ReadonlyMap<Id, CanvasRecord>; readonly elements: ReadonlyMap<Id, ElementRecord>; readonly tombstones: ReadonlyMap<Id, number> }; export type Document = { readonly projectId: Id; readonly canvases: ReadonlyMap<Id, CanvasRecord>; readonly elements: ReadonlyMap<Id, ElementRecord>; readonly tombstones: ReadonlyMap<Id, number> };
/** 可修改字段白名单;data. 后缀表示一个顶层键,整份 geometry 是一个条件字段。 */ /** 可修改字段白名单;data. 后缀表示一个顶层键,整份 geometry 是一个条件字段。 */
export type Field = `data.${string}` | 'geometry' | 'ownerCanvasId' | 'source' | 'target' | 'path'; export type Field = `data.${string}` | 'geometry' | 'ownerCanvasId' | 'source' | 'target' | 'path';
/** 字段条件变化:before/after 与存在性一起比较,使缺失和显式 null 不混淆。 */ /** 字段条件变化;data 顶层键结合 beforePresent/afterPresent 区分缺失与显式 null,缺省存在性为 true。
* 非 data 字段按整体 before/after 比较,不用这两个标记表达信封字段缺失。 */
export type FieldChange = { readonly kind: 'field'; readonly target: Target; readonly field: Field; readonly before: Json; readonly after: Json; readonly beforePresent?: boolean; readonly afterPresent?: boolean }; export type FieldChange = { readonly kind: 'field'; readonly target: Target; readonly field: Field; readonly before: Json; readonly after: Json; readonly beforePresent?: boolean; readonly afterPresent?: boolean };
/** 统一变化格式;元素/canvas 的 null 表示不存在,可表达创建、删除、替换及反向修改。 */ /** 统一变化格式;元素/canvas 的 null 表示不存在,可表达创建、删除、替换及反向修改。 */
export type Change = FieldChange export type Change = FieldChange
@@ -59,13 +62,15 @@ export type Rejected = { readonly status: 'rejected'; readonly code: string; rea
export type Applied = { readonly status: 'applied'; readonly revision: number; readonly operationIds: readonly Id[]; readonly historyEntryId?: Id }; export type Applied = { readonly status: 'applied'; readonly revision: number; readonly operationIds: readonly Id[]; readonly historyEntryId?: Id };
/** 公共命令结果的判别联合,调用方必须按 status 处理失败。 */ /** 公共命令结果的判别联合,调用方必须按 status 处理失败。 */
export type Result = Applied | Rejected; export type Result = Applied | Rejected;
/** 一个逻辑撤销单元,包含正向变化和关联操作 ID;不把每个 pointermove 当一项。 */ /** 一个逻辑撤销单元,保存正向 changes 和关联操作 ID;一次 entry 可按 groupId 合并多次提交。
* undo 时计算逆变化,恢复代次后可修订这些正向 changes;它不是永远不变的原请求 body。 */
export type HistoryEntry = { readonly id: Id; readonly label: string; readonly changes: readonly Change[]; readonly operationIds: readonly Id[]; readonly groupId?: Id }; export type HistoryEntry = { readonly id: Id; readonly label: string; readonly changes: readonly Change[]; readonly operationIds: readonly Id[]; readonly groupId?: Id };
/** undo/redo 两个 ID 栈(尾部为栈顶),entries 保存记录,栈项与 ledger 事实分开。 */ /** undo/redo 两个 ID 栈(尾部为栈顶),entries 保存记录,栈项与 ledger 事实分开。 */
export type HistoryState = { readonly undo: readonly Id[]; readonly redo: readonly Id[]; readonly entries: ReadonlyMap<Id, HistoryEntry> }; export type HistoryState = { readonly undo: readonly Id[]; readonly redo: readonly Id[]; readonly entries: ReadonlyMap<Id, HistoryEntry> };
/** 图片默认尺寸解析对原创建历史的修正计划;绑定目标代次与创建 entry,避免多造用户步骤。 */ /** 图片默认尺寸解析对原创建历史的修正计划;绑定目标代次与创建 entry,避免多造用户步骤。 */
export type CreationSizeEffect = { readonly version: 1; readonly kind: 'creation-size'; readonly entryId: Id; readonly target: Target; readonly size: { readonly width: number; readonly height: number } }; export type CreationSizeEffect = { readonly version: 1; readonly kind: 'creation-size'; readonly entryId: Id; readonly target: Target; readonly size: { readonly width: number; readonly height: number } };
/** 所有意图共用的身份、单调序列、参与 canvas、changes、依赖及可选历史修正证据。 */ /** 所有意图共用的身份、单调序列、参与 canvas、changes、依赖及可选历史修正证据。
* operationSchemaVersion 是意图解释版本,historyTransition/historyEffect 是本地历史证据,不是服务端请求版本。 */
type IntentBase = { readonly historyEffect?:CreationSizeEffect; readonly historyTransition?:HistoryTransition; readonly id: Id; readonly operationSchemaVersion: 1; readonly sequence: number; readonly origin: Origin; readonly canvasIds: readonly Id[]; readonly changes: readonly Change[]; readonly dependencies: readonly Id[] }; type IntentBase = { readonly historyEffect?:CreationSizeEffect; readonly historyTransition?:HistoryTransition; readonly id: Id; readonly operationSchemaVersion: 1; readonly sequence: number; readonly origin: Origin; readonly canvasIds: readonly Id[]; readonly changes: readonly Change[]; readonly dependencies: readonly Id[] };
/** 生成任务的稳定输入;目标代次、模型/提示词/参数随逻辑操作保存,凭证不入文档。 */ /** 生成任务的稳定输入;目标代次、模型/提示词/参数随逻辑操作保存,凭证不入文档。 */
export type RunInput = { readonly target: Target; readonly boardId: Id; readonly modelId: string; readonly prompt: string; readonly parameters: Data; readonly propagate: boolean }; export type RunInput = { readonly target: Target; readonly boardId: Id; readonly modelId: string; readonly prompt: string; readonly parameters: Data; readonly propagate: boolean };
@@ -84,24 +89,31 @@ export type CoreSnapshot = { readonly revision: number; readonly document: Docum
export type Snapshot<Runtime = unknown> = CoreSnapshot & { readonly rootRevision: number; readonly runtime: Runtime }; export type Snapshot<Runtime = unknown> = CoreSnapshot & { readonly rootRevision: number; readonly runtime: Runtime };
/** 命令或能力预检查结果;enabled=false 给出原因,不以按钮是否灰掉代替真正提交校验。 */ /** 命令或能力预检查结果;enabled=false 给出原因,不以按钮是否灰掉代替真正提交校验。 */
export type Availability = { readonly enabled: true } | { readonly enabled: false; readonly code: string; readonly targets: readonly Id[] }; export type Availability = { readonly enabled: true } | { readonly enabled: false; readonly code: string; readonly targets: readonly Id[] };
/** 只暴露读能力的集合合同,forEach 也只接触只读视图。 */ /** 类型合同仅列读方法;真正隐藏 add/delete 和保护 forEach 第三个参数要由 readSet 等工厂实现。
* TypeScript 结构兼容并不阻止外部把原生 Set 当作这个接口传入。 */
export type ReadonlyValueSet<T> = Pick<ReadonlySet<T>, 'size' | 'has' | 'entries' | 'keys' | 'values' | typeof Symbol.iterator> & { forEach(callback: (value: T, value2: T, set: ReadonlyValueSet<T>) => void): void }; export type ReadonlyValueSet<T> = Pick<ReadonlySet<T>, 'size' | 'has' | 'entries' | 'keys' | 'values' | typeof Symbol.iterator> & { forEach(callback: (value: T, value2: T, set: ReadonlyValueSet<T>) => void): void };
/** 编辑行为的稳定标识,组件通过安装 feature 获得对应 handler。 */ /** 编辑行为的稳定标识,组件通过安装 feature 获得对应 handler。 */
export type Capability = 'geometry.move' | 'geometry.resize' | 'geometry.rotate' | 'content.edit' | 'connection.edit'; export type Capability = 'geometry.move' | 'geometry.resize' | 'geometry.rotate' | 'content.edit' | 'connection.edit';
/** 读取当前元素和输入,返回字段变化;不能越权写其它元素,也不在这里发布或访问网络。 */ /** 读取当前元素和输入,返回字段变化;约定只规划而不发布/访问网络。
* editor 再检查输出目标/字段;函数类型不自动证明实现纯净,也不能回滚 handler 自己的外部副作用。 */
export type CapabilityHandler = (context: { readonly element: ElementRecord; readonly input: Readonly<Record<string, unknown>> }) => readonly FieldChange[]; export type CapabilityHandler = (context: { readonly element: ElementRecord; readonly input: Readonly<Record<string, unknown>> }) => readonly FieldChange[];
/** 可复用行为集合;同一组件安装重复 capability 时 registry 明确拒绝。 */ /** 可复用行为集合;同一组件安装重复 capability 时 registry 明确拒绝。 */
export type Feature = { readonly id: string; readonly handlers: Readonly<Partial<Record<Capability, CapabilityHandler>>> }; export type Feature = { readonly id: string; readonly handlers: Readonly<Partial<Record<Capability, CapabilityHandler>>> };
/** Path segments address own fields; * iterates array entries. many marks a terminal ID array. */ /** Path segments address own fields; * iterates array entries. many marks a terminal ID array. */
/** 声明 data 内的 ID 路径;* 遍历数组,many 表示终端 ID 数组,external 决定外部引用保留/拒绝。 */ /** 声明 data 内的 ID 路径;* 遍历数组,many 表示终端 ID 数组。
* external 是深复制的外部引用策略;其它规划器可复用路径,但不必沿用复制的 preserve/reject 语义。 */
export type CopyReference = { readonly path: readonly string[]; readonly target: 'element' | 'canvas'; readonly many?: boolean; readonly external: 'preserve' | 'reject' }; export type CopyReference = { readonly path: readonly string[]; readonly target: 'element' | 'canvas'; readonly many?: boolean; readonly external: 'preserve' | 'reject' };
/** 深复制时只重写声明引用并删 omit 字段;不能扫描任意相似字符串猜测引用。 */ /** 深复制时只重写声明引用并删 omit 字段;不能扫描任意相似字符串猜测引用。 */
export type CopySchema = { readonly references: readonly CopyReference[]; readonly omit: readonly (readonly string[])[] }; export type CopySchema = { readonly references: readonly CopyReference[]; readonly omit: readonly (readonly string[])[] };
/** 节点几何协议的计算输入;geometry 可是预览值,不要求等于记录的 committed 几何。 */ /** 节点几何协议的计算输入;geometry 可是预览值,不要求等于记录的 committed 几何。 */
export type NodeGeometryContext={readonly element:Extract<ElementRecord,{structure:'node'}>;readonly geometry:Geometry}; export type NodeGeometryContext={readonly element:Extract<ElementRecord,{structure:'node'}>;readonly geometry:Geometry};
/** 版本化的纯几何扩展:节点 bounds/hit,边 strokeWidth/segments;不依赖 renderer 实例。 */ /** 版本化的纯几何扩展约定:节点 bounds/hit,边 strokeWidth/segments;不依赖 renderer 实例。
* node dataDependencies 缺省时数据变化保守重算,空数组表示不依赖 data;role 提示 bounds 的用途。
* connection 输入已含展示锚点/变换,displayPath 可为 undefined;返回值仍由查询边界验证,类型不代替校验。 */
export type GeometryProtocol=({readonly kind:'node';readonly version:1;readonly dataDependencies?:readonly string[];bounds(context:NodeGeometryContext&{readonly role:'logical'|'hit'|'paint'}):import('./geometry.ts').Rect;hit(context:NodeGeometryContext&{readonly point:Point;readonly tolerance:number}):boolean}|{readonly kind:'connection';readonly version:1;strokeWidth(context:{readonly element:Extract<ElementRecord,{structure:'connection'}>;readonly transform:{readonly x:number;readonly y:number;readonly scale:number;readonly rotation:number}}):number;segments(context:{readonly element:Extract<ElementRecord,{structure:'connection'}>;readonly source:import('./geometry.ts').Connector;readonly target:import('./geometry.ts').Connector;readonly displayPath:Json|undefined;readonly transform:{readonly x:number;readonly y:number;readonly scale:number;readonly rotation:number}}):readonly import('./geometry.ts').Cubic[]}); export type GeometryProtocol=({readonly kind:'node';readonly version:1;readonly dataDependencies?:readonly string[];bounds(context:NodeGeometryContext&{readonly role:'logical'|'hit'|'paint'}):import('./geometry.ts').Rect;hit(context:NodeGeometryContext&{readonly point:Point;readonly tolerance:number}):boolean}|{readonly kind:'connection';readonly version:1;strokeWidth(context:{readonly element:Extract<ElementRecord,{structure:'connection'}>;readonly transform:{readonly x:number;readonly y:number;readonly scale:number;readonly rotation:number}}):number;segments(context:{readonly element:Extract<ElementRecord,{structure:'connection'}>;readonly source:import('./geometry.ts').Connector;readonly target:import('./geometry.ts').Connector;readonly displayPath:Json|undefined;readonly transform:{readonly x:number;readonly y:number;readonly scale:number;readonly rotation:number}}):readonly import('./geometry.ts').Cubic[]});
/** 一种组件的定义:精确版本/schema、feature、迁移、资源字段、几何及复制声明,可服务多个实例。 */ /** 一种组件的定义,可服务多个实例:dataVersion/validate、features、逐级 migrations、顶层 resourceFields、geometry 和 copy。
* 未提供 copy 不表示可以猜引用复制;resourceFields 只声明资源扫描入口,不在定义里保存文件字节或用户凭证。 */
export type ComponentDefinition = { readonly id: string; readonly dataVersion: number; readonly structure: ElementRecord['structure']; readonly features: readonly Feature[]; readonly validate: (data: Data) => boolean; readonly migrations?: Readonly<Record<number, (data: Data) => Data>>; readonly resourceFields?: readonly string[]; readonly geometry?:GeometryProtocol; readonly copy?: CopySchema }; export type ComponentDefinition = { readonly id: string; readonly dataVersion: number; readonly structure: ElementRecord['structure']; readonly features: readonly Feature[]; readonly validate: (data: Data) => boolean; readonly migrations?: Readonly<Record<number, (data: Data) => Data>>; readonly resourceFields?: readonly string[]; readonly geometry?:GeometryProtocol; readonly copy?: CopySchema };
/** 只读定义与编译后能力表;resolve 区分未知组件和不支持版本,supports 只允许精确已知版本。 */ /** 只读定义与编译后能力表;resolve 只按组件 ID/精确数据版本分类,不执行 data schema。
* supports 还要求 capability 已安装,仍不代替 policy、命令规划或最终文档校验。 */
export type Registry = { readonly components: ReadonlyMap<string, ComponentDefinition>; readonly capabilities: ReadonlyMap<string, ReadonlyValueSet<Capability>>; readonly handlers: ReadonlyMap<string, ReadonlyMap<Capability, CapabilityHandler>>; readonly resolve: (element: ElementRecord) => 'known' | 'unknown-component' | 'unsupported-version'; readonly supports: (element: ElementRecord, capability: Capability) => boolean }; export type Registry = { readonly components: ReadonlyMap<string, ComponentDefinition>; readonly capabilities: ReadonlyMap<string, ReadonlyValueSet<Capability>>; readonly handlers: ReadonlyMap<string, ReadonlyMap<Capability, CapabilityHandler>>; readonly resolve: (element: ElementRecord) => 'known' | 'unknown-component' | 'unsupported-version'; readonly supports: (element: ElementRecord, capability: Capability) => boolean };
+3 -1
View File
@@ -30,13 +30,15 @@ export function presentationRecord(value:Json|undefined):Readonly<Record<string,
unitScale(value);return value; unitScale(value);return value;
} }
/** Normalize only the framework-owned unit field; all other presentation properties survive. */ /** Normalize only the framework-owned unit field; all other presentation properties survive. */
/** 返回替换比例后的元数据:等效值复用原对象,比例为 1 时省略保留字段。 /** 返回替换比例后的元数据:等效值原样复用;需要重写且新比例为 1 时才删除保留字段。
* 仅更新 unitScale,不清掉宿主扩展字段;字段全部清空时返回 undefined。 */ * 仅更新 unitScale,不清掉宿主扩展字段;字段全部清空时返回 undefined。 */
export function withUnitScale(value:Json|undefined,scale:number):Json|undefined { export function withUnitScale(value:Json|undefined,scale:number):Json|undefined {
if(!Number.isFinite(scale)||scale<=0)throw fault('invalid_presentation_units'); if(!Number.isFinite(scale)||scale<=0)throw fault('invalid_presentation_units');
// 坐标换算可能产生接近 1 的浮点误差,统一表示能减少无意义的模型修改。 // 坐标换算可能产生接近 1 的浮点误差,统一表示能减少无意义的模型修改。
const normalized=closeNumber(scale,1)?1:scale; const normalized=closeNumber(scale,1)?1:scale;
// no-op 先返回,因此显式 unitScale:1 不一定被删;原始值/数组按默认比例 1 等效时也可原样通过。
if(closeScale(unitScale(value),normalized))return value; if(closeScale(unitScale(value),normalized))return value;
// 只有确实改比例才要求元数据可合并,拒绝原始值/数组,避免把宿主扩展值覆盖成对象。
const fields=new Map(Object.entries(presentationRecord(value))); const fields=new Map(Object.entries(presentationRecord(value)));
if(normalized===1)fields.delete('unitScale');else fields.set('unitScale',normalized); if(normalized===1)fields.delete('unitScale');else fields.set('unitScale',normalized);
return fields.size?Object.fromEntries(fields):undefined; return fields.size?Object.fromEntries(fields):undefined;
+14 -6
View File
@@ -5,11 +5,13 @@
*/ */
import type { Id, Json, ReadonlyValueSet } from './types.ts'; import type { Id, Json, ReadonlyValueSet } from './types.ts';
/** 构造携带稳定错误码和相关对象 ID 的错误,供命令层返回可解释的拒绝结果。 */ /** 构造携带稳定错误码和相关对象 ID 的错误,供命令层返回可解释的拒绝结果。
* targets 沿用传入数组引用;这个辅助函数本身不捕获或冻结错误明细。 */
export function fault(code: string, targets: readonly Id[] = []): Error & { code: string; targets: readonly Id[] } { export function fault(code: string, targets: readonly Id[] = []): Error & { code: string; targets: readonly Id[] } {
return Object.assign(new Error(code), { code, targets }); return Object.assign(new Error(code), { code, targets });
} }
/** 将捕获的异常归一化为只读 rejected 结果;无领域错误码时使用 invalid_command。 */ /** 将捕获的异常归一化为浅冻结的 rejected 外壳;无领域错误码时使用 invalid_command。
* 已有 targets 数组仍沿用原引用,不应把外壳冻结解释为整个错误结果深度不可变。 */
export function reject(error: unknown) { export function reject(error: unknown) {
const value = error as { code?: string; targets?: readonly string[] }; const value = error as { code?: string; targets?: readonly string[] };
return Object.freeze({ status: 'rejected' as const, code: value?.code || 'invalid_command', targets: value?.targets || [] }); return Object.freeze({ status: 'rejected' as const, code: value?.code || 'invalid_command', targets: value?.targets || [] });
@@ -24,13 +26,15 @@ export function freeze<T>(value: T): T {
return value; return value;
} }
/** JSON 往返复制已验证的数据,切断共享引用;undefined 原样返回。 /** JSON 往返复制已验证的数据,切断共享引用;undefined 原样返回。
* 此处不是校验器:JSON 会丢弃某些非法字段,因此外部输入必须在复制前验证。 */ * 此处不是校验器:JSON 会执行 toJSON、读取可枚举字段并丢弃/改写不支持的值。
* 不保留原型、描述符或共享子对象身份;需要严格输入语义时须在调用边界捕获。 */
export function clone<T>(value: T): T { export function clone<T>(value: T): T {
if (value === undefined) return value; if (value === undefined) return value;
return JSON.parse(JSON.stringify(value)) as T; return JSON.parse(JSON.stringify(value)) as T;
} }
/** 按可枚举自有键递归比较 JSON 风格的值;数组与对象不能互相视为相等。 /** 先以 Object.is 判断,再按可枚举自有字符串键递归比较 JSON 风格的值;数组与对象不能互相视为相等。
* 不处理任意循环对象,也不把 Map/Set 等实例当成业务值来比较。 */ * 不检查 Symbol/不可枚举字段,数组长度本身也不在 Object.keys 中;稀疏数组不适合作为此比较器的输入。
* 不处理任意循环对象,也不提供 Map/Set 成员比较语义。 */
export function equal(a: unknown, b: unknown): boolean { export function equal(a: unknown, b: unknown): boolean {
if (Object.is(a, b)) return true; if (Object.is(a, b)) return true;
if (!a || !b || typeof a !== 'object' || typeof b !== 'object' || Array.isArray(a) !== Array.isArray(b)) return false; if (!a || !b || typeof a !== 'object' || typeof b !== 'object' || Array.isArray(a) !== Array.isArray(b)) return false;
@@ -46,6 +50,7 @@ export function isOwnedMapView(value:object){return ownedMapViews.has(value);}
/** 复制表成员并仅暴露读取能力,外部既拿不到原 Map,也拿不到 set/delete。 /** 复制表成员并仅暴露读取能力,外部既拿不到原 Map,也拿不到 set/delete。
* value 保留原引用,深度不可变性由生产这些 value 的模块负责。 */ * value 保留原引用,深度不可变性由生产这些 value 的模块负责。 */
export function readMap<K, V>(input: ReadonlyMap<K, V>): ReadonlyMap<K, V> { export function readMap<K, V>(input: ReadonlyMap<K, V>): ReadonlyMap<K, V> {
// 从此以后 input 的增删与本视图无关;但 K/V 对象仍共享引用,键/值本身不是在此深复制的。
const map = new Map(input); const map = new Map(input);
const view: ReadonlyMap<K, V> = { const view: ReadonlyMap<K, V> = {
get size() { return map.size; }, get: key => map.get(key), has: key => map.has(key), get size() { return map.size; }, get: key => map.get(key), has: key => map.has(key),
@@ -58,6 +63,7 @@ export function readMap<K, V>(input: ReadonlyMap<K, V>): ReadonlyMap<K, V> {
} }
/** 捕获一份成员集合并隐藏 add/delete;回调同样只接触只读视图。 */ /** 捕获一份成员集合并隐藏 add/delete;回调同样只接触只读视图。 */
export function readSet<T>(input: Iterable<T>): ReadonlyValueSet<T> { export function readSet<T>(input: Iterable<T>): ReadonlyValueSet<T> {
// 成员表独立于原 iterable,成员若为对象则仍保留原身份;隐藏可变方法并不冻结对象成员。
const set = new Set(input); const set = new Set(input);
const view: ReadonlyValueSet<T> = { const view: ReadonlyValueSet<T> = {
get size() { return set.size; }, has: key => set.has(key), entries: () => set.entries(), keys: () => set.keys(), values: () => set.values(), get size() { return set.size; }, has: key => set.has(key), entries: () => set.entries(), keys: () => set.keys(), values: () => set.values(),
@@ -66,7 +72,9 @@ export function readSet<T>(input: Iterable<T>): ReadonlyValueSet<T> {
return Object.freeze(view); return Object.freeze(view);
} }
/** 递归检查 JSON 值域:排除 undefined/函数/非有限数,最多允许 64 层。 /** 递归检查 JSON 值域:排除 undefined/函数/非有限数,最多允许 64 层。
* 这里只检查值形状,不验证组件 schema,也不是所有原型/属性描述符的严格捕获器。 */ * 对象要求 Object.prototype 原型,但只遍历 Object.values;Symbol/不可枚举属性不参与。
* 数组用 every 跳过空洞,也不检查额外属性;它不是稠密数组或所有属性描述符的严格捕获器。
* 校验读取可能触发 getter;这里不证明组件 schema、输入稳定性或数据所有权。 */
export function validJson(value: unknown, depth = 0): value is Json { export function validJson(value: unknown, depth = 0): value is Json {
if (depth > 64) return false; if (depth > 64) return false;
if (value === null || typeof value === 'string' || typeof value === 'boolean') return true; if (value === null || typeof value === 'string' || typeof value === 'boolean') return true;
+20 -9
View File
@@ -27,32 +27,43 @@ export type AssetDepotPort={
read(leader:StorageLeader,authority:string,id:Id):Promise<DepotEntry|undefined>; read(leader:StorageLeader,authority:string,id:Id):Promise<DepotEntry|undefined>;
collect(leader:StorageLeader,input:{readonly authority:string;readonly now:number;readonly retentionMs:number;readonly limit:number;readonly after?:Id}):Promise<DepotCollection>; collect(leader:StorageLeader,input:{readonly authority:string;readonly now:number;readonly retentionMs:number;readonly limit:number;readonly after?:Id}):Promise<DepotCollection>;
}; };
/** 严格验证授权域、64位小写十六进制摘要、图片 MIME、正尺寸/字节及像素/20MiB上限。 */ /** 校验版本、非空授权域/URL、64 字符小写摘要、图片 MIME、正整数尺寸/字节及像素/20MiB上限。
* 只检查元数据:URL 未限制协议/域,摘要未重算,尺寸未通过实际解码核实;授权真实性由服务端口保证。 */
export function validateSharedAsset(value:SharedAsset):void{ export function validateSharedAsset(value:SharedAsset):void{
if(!value||value.version!==1||typeof value.authority!=='string'||!value.authority||value.authority.length>8192||typeof value.digest!=='string'||!/^([a-f0-9]{64})$/.test(value.digest))throw fault('invalid_shared_asset'); if(!value||value.version!==1||typeof value.authority!=='string'||!value.authority||value.authority.length>8192||typeof value.digest!=='string'||!/^([a-f0-9]{64})$/.test(value.digest))throw fault('invalid_shared_asset');
const asset=value.asset;if(!asset||typeof asset.id!=='string'||!asset.id||typeof asset.url!=='string'||!asset.url||asset.url.length>8192||![asset.width,asset.height,asset.bytes].every(n=>Number.isSafeInteger(n)&&n>0)||asset.width*asset.height>40_000_000||asset.bytes>20*1024*1024||!['image/png','image/jpeg','image/gif','image/webp'].includes(asset.mime_type))throw fault('invalid_shared_asset'); const asset=value.asset;if(!asset||typeof asset.id!=='string'||!asset.id||typeof asset.url!=='string'||!asset.url||asset.url.length>8192||![asset.width,asset.height,asset.bytes].every(n=>Number.isSafeInteger(n)&&n>0)||asset.width*asset.height>40_000_000||asset.bytes>20*1024*1024||!['image/png','image/jpeg','image/gif','image/webp'].includes(asset.mime_type))throw fault('invalid_shared_asset');
} }
/** 验证后捕获并冻结资源元信息,隔离调用者可变的 asset 对象。 */ /** 验证后重建标准外层字段并浅复制 asset,再递归冻结,隔离标准标量元信息。
* asset 的扩展属性仍由 spread 保留,嵌套扩展值不深克隆,不能当作任意外部对象的完整隔离入口。 */
export function sharedAssetRef(value:SharedAsset):SharedAsset{validateSharedAsset(value);return freeze({version:1 as const,authority:value.authority,digest:value.digest,asset:{...value.asset}});} export function sharedAssetRef(value:SharedAsset):SharedAsset{validateSharedAsset(value);return freeze({version:1 as const,authority:value.authority,digest:value.digest,asset:{...value.asset}});}
/** 比较完整不可变引用,不能只因 ID 或 URL 一样就容忍摘要/尺寸变化。 */ /** 比较完整不可变引用,不能只因 ID 或 URL 一样就容忍摘要/尺寸变化。 */
const same=(a:SharedAsset,b:SharedAsset)=>equal(sharedAssetRef(a),sharedAssetRef(b)); const same=(a:SharedAsset,b:SharedAsset)=>equal(sharedAssetRef(a),sharedAssetRef(b));
/** Immutable metadata cache only. Every new adoption resolves current permission first. */ /** Immutable metadata cache only. Every new adoption resolves current permission first. */
/** 建立工作区门面,绑定作用域与当前身份,不把跨账号缓存混入同一个 records 表。 */ /** 捕获 account/workspace 标量,建立仅存元信息的 Map;字节仍由 port 持有,不建立内存 Blob 缓存。
* 宿主 current/leader 校验约束使用生命周期,实际存储 fence/权限检查仍由端口实现。
* 每次 getResources 返回成员快照,旧快照不随 remember/close 改变;它不另做授权或关闭检查。 */
export function createAssetDepot(options:{scope:Pick<StorageScope,'accountId'|'workspaceId'>;port:AssetDepotPort;current():boolean;digest(bytes:Blob,signal?:AbortSignal):Promise<string>}){ export function createAssetDepot(options:{scope:Pick<StorageScope,'accountId'|'workspaceId'>;port:AssetDepotPort;current():boolean;digest(bytes:Blob,signal?:AbortSignal):Promise<string>}){
const scope=freeze({accountId:options.scope.accountId,workspaceId:options.scope.workspaceId});let closed=false;const records=new Map<string,SharedAsset>(),controllers=new Set<AbortController>(); const scope=freeze({accountId:options.scope.accountId,workspaceId:options.scope.workspaceId});let closed=false;const records=new Map<string,SharedAsset>(),controllers=new Set<AbortController>();
/** 每个 await 前后使用:宿主仍有效且 leader 属于同账号/工作区;允许不同项目共享仓库。 */ /** 入口及异步结果采纳前检查宿主/leader,允许同账号工作区的不同项目共享。
* 不比较 projectId,不读取远端 fence;在途 I/O 是否立即停止由端口/信号决定。 */
const check=(leader:StorageLeader)=>{if(closed||!options.current())throw fault('identity_changed');if(!leader.active||leader.scope.accountId!==scope.accountId||leader.scope.workspaceId!==scope.workspaceId)throw fault('asset_depot_scope_mismatch');}; const check=(leader:StorageLeader)=>{if(closed||!options.current())throw fault('identity_changed');if(!leader.active||leader.scope.accountId!==scope.accountId||leader.scope.workspaceId!==scope.workspaceId)throw fault('asset_depot_scope_mismatch');};
/** 缓存同 authority/assetId 的不可变引用;相同身份元数据改变视为冲突,不静默覆盖。 */ /** 缓存同 authority/assetId 的不可变引用;相同身份元数据改变视为冲突,不静默覆盖。 */
function remember(ref:SharedAsset){validateSharedAsset(ref);const key=JSON.stringify([ref.authority,ref.asset.id]),previous=records.get(key);if(previous&&!same(previous,ref))throw fault('shared_asset_identity_conflict',[ref.asset.id]);records.set(key,sharedAssetRef(ref));} function remember(ref:SharedAsset){validateSharedAsset(ref);const key=JSON.stringify([ref.authority,ref.asset.id]),previous=records.get(key);if(previous&&!same(previous,ref))throw fault('shared_asset_identity_conflict',[ref.asset.id]);records.set(key,sharedAssetRef(ref));}
return Object.freeze({scope,getResources:()=>readMap(records), return Object.freeze({scope,getResources:()=>readMap(records),
/** 新采纳先远端 resolve,再比本地身份/读取原字节并验长度+SHA;每个 await 后重新检查取消/身份。 /** 新采纳先 resolve 当前权限,再核对缓存身份;有缓存字节仍验长度/摘要,否则向 authority 读取。
* 成功只返回 ref/bytes 和记元信息,目标 binding 与字节持久化由 engine 的单 Root 完成。 */ * combined 信号交端口,异步返回后再检查;入口不先 throwIfAborted,已取消信号仍可能传给 resolve。
* remember 捕获元信息,但返回 ref 沿用 resolve 结果(只冻结返回 envelope),端口须提供不可变身份。
* 成功不写共享字节/项目 binding;这些由 engine 同 Root 持久化,不是新上传任务。 */
async authorize(leader:StorageLeader,authority:AssetAuthority,id:Id,signal:AbortSignal){check(leader);const controller=new AbortController(),combined=AbortSignal.any([signal,controller.signal]);controllers.add(controller);try{const ref=await authority.resolve(id,combined);check(leader);combined.throwIfAborted();validateSharedAsset(ref);if(ref.authority!==authority.scope||ref.asset.id!==id)throw fault('asset_authorization_mismatch');const cached=await options.port.read(leader,authority.scope,id);check(leader);combined.throwIfAborted();if(cached&&!same(cached.ref,ref))throw fault('shared_asset_identity_conflict',[id]);let bytes=cached?.bytes;if(!bytes)bytes=await authority.read(id,combined);check(leader);combined.throwIfAborted();if(bytes.size!==ref.asset.bytes||await options.digest(bytes,combined)!==ref.digest)throw fault('shared_asset_bytes_changed',[id]);check(leader);combined.throwIfAborted();remember(ref);return Object.freeze({ref,bytes});}finally{controllers.delete(controller);}}, async authorize(leader:StorageLeader,authority:AssetAuthority,id:Id,signal:AbortSignal){check(leader);const controller=new AbortController(),combined=AbortSignal.any([signal,controller.signal]);controllers.add(controller);try{const ref=await authority.resolve(id,combined);check(leader);combined.throwIfAborted();validateSharedAsset(ref);if(ref.authority!==authority.scope||ref.asset.id!==id)throw fault('asset_authorization_mismatch');const cached=await options.port.read(leader,authority.scope,id);check(leader);combined.throwIfAborted();if(cached&&!same(cached.ref,ref))throw fault('shared_asset_identity_conflict',[id]);let bytes=cached?.bytes;if(!bytes)bytes=await authority.read(id,combined);check(leader);combined.throwIfAborted();if(bytes.size!==ref.asset.bytes||await options.digest(bytes,combined)!==ref.digest)throw fault('shared_asset_bytes_changed',[id]);check(leader);combined.throwIfAborted();remember(ref);return Object.freeze({ref,bytes});}finally{controllers.delete(controller);}},
/** 读取已绑定本地共享字节并验身份/摘要;不是新 adoption 的授权捷径,缺字节返回 undefined。 */ /** 按已绑定 ref 读取本地共享字节并验身份/摘要,不调用 authority.resolve 或回源下载。
* 无记录直接 undefined;有记录无字节也记下元信息再返回 undefined,不能据此判定服务器资产消失。
* 输入 ref 沿用调用方身份;本方法不单独证明调用者有权新采纳该资源。 */
async read(leader:StorageLeader,ref:SharedAsset,signal?:AbortSignal){check(leader);signal?.throwIfAborted();const row=await options.port.read(leader,ref.authority,ref.asset.id);check(leader);signal?.throwIfAborted();if(!row)return;if(!same(row.ref,ref))throw fault('shared_asset_identity_conflict',[ref.asset.id]);if(row.bytes&&(row.bytes.size!==ref.asset.bytes||await options.digest(row.bytes,signal)!==ref.digest))throw fault('shared_asset_bytes_changed',[ref.asset.id]);check(leader);signal?.throwIfAborted();remember(ref);return row.bytes;}, async read(leader:StorageLeader,ref:SharedAsset,signal?:AbortSignal){check(leader);signal?.throwIfAborted();const row=await options.port.read(leader,ref.authority,ref.asset.id);check(leader);signal?.throwIfAborted();if(!row)return;if(!same(row.ref,ref))throw fault('shared_asset_identity_conflict',[ref.asset.id]);if(row.bytes&&(row.bytes.size!==ref.asset.bytes||await options.digest(row.bytes,signal)!==ref.digest))throw fault('shared_asset_bytes_changed',[ref.asset.id]);check(leader);signal?.throwIfAborted();remember(ref);return row.bytes;},
/** 作用域检查后委托存储扫描/保留期回收,结束再检查身份,不自行删除服务器资产。 */ /** 作用域检查后直接委托 port.collect,结果由端口负责结构与回收证明,不在此重验。
* 返回后再核对身份;检查失败不代表端口之前的本地存储事务未执行,也不删除服务器资产。 */
async collect(leader:StorageLeader,input:Parameters<AssetDepotPort['collect']>[1]){check(leader);const result=await options.port.collect(leader,input);check(leader);return result;}, async collect(leader:StorageLeader,input:Parameters<AssetDepotPort['collect']>[1]){check(leader);const result=await options.port.collect(leader,input);check(leader);return result;},
/** 关闭门面并 abort 授权任务、清元缓存;不抹掉共享存储中的持久身份/字节。 */ /** 关闭后拒绝新操作,abort 本门面追踪的 authorize 并清元缓存;不删除持久身份/字节。
* read/collect 没有被加入 controllers,在途结果由后续 check 拒绝采纳;close 不等待底层 I/O 退出。 */
close(){closed=true;for(const controller of controllers)controller.abort();controllers.clear();records.clear();}, close(){closed=true;for(const controller of controllers)controller.abort();controllers.clear();records.clear();},
}); });
} }
+13 -5
View File
@@ -10,7 +10,8 @@ import { compressChanges, type LedgerSnapshot } from './ledger.ts';
/** 使用 UTF-8 实际字节衡量 wire 大小,不能把 JS 字符串长度当 HTTP payload 大小。 */ /** 使用 UTF-8 实际字节衡量 wire 大小,不能把 JS 字符串长度当 HTTP payload 大小。 */
const encoder = new TextEncoder(); const encoder = new TextEncoder();
/** 仅计算候选 JSON 表示的字节成本,不在这里发送或冻结请求。 */ /** 仅比较传入 JSON 的 UTF-8 字节;调用处测量 operation 数组,不含 HTTP 头/请求 envelope。
* 不在这里发送或冻结请求,也不估算压缩传输后的成本。 */
const wireBytes = (value: unknown) => encoder.encode(JSON.stringify(value)).byteLength; const wireBytes = (value: unknown) => encoder.encode(JSON.stringify(value)).byteLength;
/** wire 中保留的 logical ID 与对应条件 changes;不是新的本地历史单元。 */ /** wire 中保留的 logical ID 与对应条件 changes;不是新的本地历史单元。 */
@@ -19,9 +20,13 @@ export type WireOperation = { readonly id: Id; readonly changes: readonly Change
const sameMap = <T>(a: ReadonlyMap<Id, T>, b: ReadonlyMap<Id, T>) => a.size === b.size && [...a].every(([id, value]) => b.has(id) && equal(value, b.get(id))); const sameMap = <T>(a: ReadonlyMap<Id, T>, b: ReadonlyMap<Id, T>) => a.size === b.size && [...a].every(([id, value]) => b.has(id) && equal(value, b.get(id)));
/** Pure projection for an already-selected mutable segment. Original ledger/history records never change. */ /** Pure projection for an already-selected mutable segment. Original ledger/history records never change. */
/** 尝试无损压缩多意图的 wire 投影;不足两项或任一不安全前提直接返回 ordered。 */ /** 为调用方已选好的未发送段尝试 wire 压缩;不足两项、基线不明或无收益时返回 ordered。
* 原始条件链非法会抛错,投影效果不等价也会抛错,并非所有失败都回退。
* 此处只比对 ledger 的 changes/phase 等局部条件,不验证完整意图身份、依赖、类型/版本或段连续性。
* 参与范围与选择合法性由调用方保证;这里不调用 registry/整图校验来证明所有权或权限。 */
export function projectBatch(intents: readonly OperationIntent[], acknowledged: Document, ledger: LedgerSnapshot, participants: readonly Id[]): readonly WireOperation[] { export function projectBatch(intents: readonly OperationIntent[], acknowledged: Document, ledger: LedgerSnapshot, participants: readonly Id[]): readonly WireOperation[] {
const scope = new Set(participants); const scope = new Set(participants);
// 回退表示创建新的外层数组/条目,但 changes 沿用输入引用;仅成功压缩的结果走末尾 freeze。
const ordered = intents.map(({ id, changes }) => ({ id, changes })); const ordered = intents.map(({ id, changes }) => ({ id, changes }));
if (intents.length < 2) return ordered; if (intents.length < 2) return ordered;
// 只收集这段触及的基线记录组成 sparse 文档,减少复制/比较完整项目的成本。 // 只收集这段触及的基线记录组成 sparse 文档,减少复制/比较完整项目的成本。
@@ -52,9 +57,11 @@ export function projectBatch(intents: readonly OperationIntent[], acknowledged:
// wire 投影保留生命周期和 no-op 检查,完整零效果段由独立 elision 证明处理。 // wire 投影保留生命周期和 no-op 检查,完整零效果段由独立 elision 证明处理。
const compacted = compressChanges(raw, { preserveLifecycle: true, preserveChecks: true }); const compacted = compressChanges(raw, { preserveLifecycle: true, preserveChecks: true });
if (!compacted.length) return ordered; if (!compacted.length) return ordered;
// 原逻辑 ID 顺序逐项保留;函数本身不验证输入 ID 唯一性,去重身份由 ledger/请求编译边界保证。
// Protocol 2 requires a nonempty change list per logical ID. Prefix IDs get an explicit // Protocol 2 requires a nonempty change list per logical ID. Prefix IDs get an explicit
// conditional no-op; the final ID carries the composed effect. All IDs remain deduplicated. // conditional no-op; the final ID carries the composed effect. Input ID order is retained.
// 从参与 owner 的少量元素 title/text 或根 canvas 选 no-op guard,不扩大原 claim 范围。 // 从参与 owner 的基线元素选 title/text 条件 no-op,最多查看 8 个候选元素,每个产生两个 guard。
// guard 表达的是基线值/存在性,不是某个前缀逻辑操作的独立最终效果;最后一项统一携带组合变化。
const guards: Change[] = []; const guards: Change[] = [];
let candidates = 0; let candidates = 0;
for (const element of elements.values()) if (scope.has(element.ownerCanvasId)) { for (const element of elements.values()) if (scope.has(element.ownerCanvasId)) {
@@ -73,7 +80,8 @@ export function projectBatch(intents: readonly OperationIntent[], acknowledged:
if (!guard) return ordered; if (!guard) return ordered;
const projected = intents.map((intent, index) => ({ id: intent.id, changes: index === intents.length - 1 ? compacted : [guard!] })); const projected = intents.map((intent, index) => ({ id: intent.id, changes: index === intents.length - 1 ? compacted : [guard!] }));
if (projected.reduce((count, operation) => count + operation.changes.length, 0) > 10_000 || wireBytes(projected) >= wireBytes(ordered)) return ordered; if (projected.reduce((count, operation) => count + operation.changes.length, 0) > 10_000 || wireBytes(projected) >= wireBytes(ordered)) return ordered;
// 最后执行投影并对比 elements/canvases/tombstones 全效果,不只比较可见节点内容。 // 在同一 sparse 片段执行投影,比较它的三张表而非完整项目;未触及记录从未纳入片段。
// 这证明该基线上的组合效果一致,不替代请求执行时的版本检查或最终文档校验。
const result = applyChanges(sparse, projected.flatMap(operation => operation.changes)); const result = applyChanges(sparse, projected.flatMap(operation => operation.changes));
if (!sameMap(original.elements, result.elements) || !sameMap(original.canvases, result.canvases) || !sameMap(original.tombstones, result.tombstones)) throw fault('batch_projection_mismatch'); if (!sameMap(original.elements, result.elements) || !sameMap(original.canvases, result.canvases) || !sameMap(original.tombstones, result.tombstones)) throw fault('batch_projection_mismatch');
return freeze(projected); return freeze(projected);
+6 -1
View File
@@ -5,7 +5,10 @@
import {fault} from '../canvas-core/values.ts'; import {fault} from '../canvas-core/values.ts';
import {createDigestPool} from './digest-pool.ts'; import {createDigestPool} from './digest-pool.ts';
/** Browser capability stays at the adapter; WorkspaceRuntime owns the pool's lifetime. */ /** Browser capability stays at the adapter; WorkspaceRuntime owns the pool's lifetime. */
/** 构建 workspace 可共享的 DigestPort;任务 ID/长度/版本共同验证响应归属。 */ /** 在构造时选择能力:Worker 不存在就使用池的默认分块执行器,否则每个 execute 新建专用 Worker。
* 此选择不随后续 Worker 全局变化自动重算;创建/运行失败也不降级重试。
* 使用默认池预算,响应按实例内递增 ID/输入长度/版本和摘要格式核对,不独立重算摘要。
* 没有超时,线程无响应需外部取消/close;Worker 内原生哈希阈值由 digestWorkerSource 控制。 */
export function createBrowserDigests(){ export function createBrowserDigests(){
let next=0; let next=0;
return createDigestPool({execute:typeof Worker==='undefined'?undefined:(bytes,signal)=>new Promise<string>((resolve,reject)=>{ return createDigestPool({execute:typeof Worker==='undefined'?undefined:(bytes,signal)=>new Promise<string>((resolve,reject)=>{
@@ -15,6 +18,8 @@ export function createBrowserDigests(){
const id=++next;let done=false; const id=++next;let done=false;
/** 幂等完成:解绑所有消息/错误/abort 监听并终止 Worker,防止迟到响应泄漏或重复 resolve。 */ /** 幂等完成:解绑所有消息/错误/abort 监听并终止 Worker,防止迟到响应泄漏或重复 resolve。 */
const finish=(error?:unknown,sha256?:string)=>{if(done)return;done=true;signal.removeEventListener('abort',abort);worker.onmessage=null;worker.onerror=null;worker.onmessageerror=null;worker.terminate();if(error)reject(error);else resolve(sha256!);},abort=()=>finish(fault('import_aborted')); const finish=(error?:unknown,sha256?:string)=>{if(done)return;done=true;signal.removeEventListener('abort',abort);worker.onmessage=null;worker.onerror=null;worker.onmessageerror=null;worker.terminate();if(error)reject(error);else resolve(sha256!);},abort=()=>finish(fault('import_aborted'));
// 稳定 digest_worker_failed 只有身份匹配才采纳;其它响应走摘要字段校验,不把任意 error 字段当服务错误。
// 格式正确只表明消息可解释,真实性来自受信的专用 Worker 算法;不在 UI 线程重复计算。
// 消息须精确匹配版本、任务 ID 和输入长度,再验64位十六进制摘要;已知错误也必须匹配身份。 // 消息须精确匹配版本、任务 ID 和输入长度,再验64位十六进制摘要;已知错误也必须匹配身份。
signal.addEventListener('abort',abort,{once:true});worker.onmessage=event=>{const value=event.data;if(value?.version===1&&value.id===id&&value.byteLength===bytes.size&&value.error==='digest_worker_failed'){finish(fault('digest_worker_failed'));return;}if(!value||value.version!==1||value.id!==id||value.byteLength!==bytes.size||typeof value.sha256!=='string'||!(/^[a-f0-9]{64}$/.test(value.sha256)))finish(fault('invalid_digest_response'));else finish(undefined,value.sha256);};worker.onerror=event=>{event.preventDefault();finish(fault('digest_worker_failed'));};worker.onmessageerror=()=>finish(fault('invalid_digest_response')); signal.addEventListener('abort',abort,{once:true});worker.onmessage=event=>{const value=event.data;if(value?.version===1&&value.id===id&&value.byteLength===bytes.size&&value.error==='digest_worker_failed'){finish(fault('digest_worker_failed'));return;}if(!value||value.version!==1||value.id!==id||value.byteLength!==bytes.size||typeof value.sha256!=='string'||!(/^[a-f0-9]{64}$/.test(value.sha256)))finish(fault('invalid_digest_response'));else finish(undefined,value.sha256);};worker.onerror=event=>{event.preventDefault();finish(fault('digest_worker_failed'));};worker.onmessageerror=()=>finish(fault('invalid_digest_response'));
// Blob 通过结构化克隆传递,不在 UI 主线程先读取整份 arrayBuffer。 // Blob 通过结构化克隆传递,不在 UI 主线程先读取整份 arrayBuffer。
+4 -1
View File
@@ -4,7 +4,9 @@
*/ */
import {fault} from '../canvas-core/values.ts'; import {fault} from '../canvas-core/values.ts';
import {createImageDecodePool,type ImageDimensions} from './image-decoder.ts'; import {createImageDecodePool,type ImageDimensions} from './image-decoder.ts';
/** 返回 ImageDecoderPort;版本/id/字节数验证响应归属,尺寸合法性由 pool 再检查。 */ /** 将每次 execute 转成专用 module Worker,请求 ID 在此 decoder 实例内递增。
* 外围池使用默认并发/排队预算;此适配按响应版本/id/输入长度核对归属,尺寸合法性由池检查。
* 没有超时;Worker 无响应时要由外部取消/close 才能终止,构造失败也不回退主线程解码。 */
export function createBrowserImageDecoder(){let next=0;return createImageDecodePool({execute:(bytes,signal)=>new Promise<ImageDimensions>((resolve,reject)=>{ export function createBrowserImageDecoder(){let next=0;return createImageDecodePool({execute:(bytes,signal)=>new Promise<ImageDimensions>((resolve,reject)=>{
if(typeof Worker==='undefined'){reject(fault('image_decoder_unavailable'));return;} if(typeof Worker==='undefined'){reject(fault('image_decoder_unavailable'));return;}
// 每任务一个 Worker,取消只 terminate 自己,不会打断其它图片的解码。 // 每任务一个 Worker,取消只 terminate 自己,不会打断其它图片的解码。
@@ -12,6 +14,7 @@ export function createBrowserImageDecoder(){let next=0;return createImageDecodeP
/** finish 幂等解除消息/错误/abort 监听并 terminate;abort 在监听安装后再检查一次覆盖竞态。 */ /** finish 幂等解除消息/错误/abort 监听并 terminate;abort 在监听安装后再检查一次覆盖竞态。 */
const id=++next;let done=false;const finish=(error?:unknown,size?:ImageDimensions)=>{if(done)return;done=true;signal.removeEventListener('abort',abort);worker.onmessage=null;worker.onerror=null;worker.onmessageerror=null;worker.terminate();if(error)reject(error);else resolve(size!);},abort=()=>finish(fault('image_decode_aborted')); const id=++next;let done=false;const finish=(error?:unknown,size?:ImageDimensions)=>{if(done)return;done=true;signal.removeEventListener('abort',abort);worker.onmessage=null;worker.onerror=null;worker.onmessageerror=null;worker.terminate();if(error)reject(error);else resolve(size!);},abort=()=>finish(fault('image_decode_aborted'));
signal.addEventListener('abort',abort,{once:true});if(signal.aborted){abort();return;} signal.addEventListener('abort',abort,{once:true});if(signal.aborted){abort();return;}
// Worker error 会 preventDefault 并转稳定错误,消息无法反序列化则拒绝;所有路径共用 finish 终止线程。
// 只采纳精确匹配请求的成功尺寸或已知错误;未知错误码/错误协议作为 invalid response 拒绝。 // 只采纳精确匹配请求的成功尺寸或已知错误;未知错误码/错误协议作为 invalid response 拒绝。
worker.onmessage=event=>{const value=event.data;if(!value||value.version!==1||value.id!==id||value.byteLength!==bytes.size){finish(fault('invalid_image_response'));return;}if(value.error){finish(fault(value.error==='image_decode_failed'?'image_decode_failed':'invalid_image_response'));return;}finish(undefined,{width:value.width,height:value.height});};worker.onerror=event=>{event.preventDefault();finish(fault('image_decode_failed'));};worker.onmessageerror=()=>finish(fault('invalid_image_response')); worker.onmessage=event=>{const value=event.data;if(!value||value.version!==1||value.id!==id||value.byteLength!==bytes.size){finish(fault('invalid_image_response'));return;}if(value.error){finish(fault(value.error==='image_decode_failed'?'image_decode_failed':'invalid_image_response'));return;}finish(undefined,{width:value.width,height:value.height});};worker.onerror=event=>{event.preventDefault();finish(fault('image_decode_failed'));};worker.onmessageerror=()=>finish(fault('invalid_image_response'));
// 直接传不可变 Blob,不先在 UI 线程转成像素或整份 ArrayBuffer。 // 直接传不可变 Blob,不先在 UI 线程转成像素或整份 ArrayBuffer。
+26 -10
View File
@@ -13,7 +13,10 @@ import { equal, fault, freeze, readMap } from '../canvas-core/values.ts';
/** 原操作相关对象的前后记录及 owner 变换;用于区分用户意图与仅由坐标转换引起的差异。 */ /** 原操作相关对象的前后记录及 owner 变换;用于区分用户意图与仅由坐标转换引起的差异。 */
export type IntentContext = { readonly before:ReadonlyMap<Id,ElementRecord>; readonly after:ReadonlyMap<Id,ElementRecord>; readonly transforms:ReadonlyMap<Id,Transform>; readonly beforeTransforms:ReadonlyMap<Id,Transform> }; export type IntentContext = { readonly before:ReadonlyMap<Id,ElementRecord>; readonly after:ReadonlyMap<Id,ElementRecord>; readonly transforms:ReadonlyMap<Id,Transform>; readonly beforeTransforms:ReadonlyMap<Id,Transform> };
/** Reconstruct only immutable intention metadata. This mutable cursor is never published as a document. */ /** Reconstruct only immutable intention metadata. This mutable cursor is never published as a document. */
/** 从当前 working 反向遍历意图,重建每项的局部前后上下文;可变 cursor 永不作为业务文档发布。 */ /** 从已包含这些意图效果的 working 逆序退回,重建局部前后记录及 owner 变换。
* 输入顺序就是应用顺序;函数不按 sequence 排序,也不验证当前文档确由这些意图产生。
* 这里只还原意图元数据,不按 before/after 条件执行撤销,不回退墓碑、不分配新代次或发布文档。
* 只读 Map 捕获表成员,记录值沿用不可变输入;不是任意外部对象的深克隆边界。 */
export function intentContexts(document:Document,intents:readonly OperationIntent[]):ReadonlyMap<Id,IntentContext> { export function intentContexts(document:Document,intents:readonly OperationIntent[]):ReadonlyMap<Id,IntentContext> {
const elements=new Map(document.elements),canvases=new Map(document.canvases),contexts=new Map<Id,IntentContext>(); const elements=new Map(document.elements),canvases=new Map(document.canvases),contexts=new Map<Id,IntentContext>();
const cursor={...document,elements,canvases}; const cursor={...document,elements,canvases};
@@ -27,7 +30,8 @@ export function intentContexts(document:Document,intents:readonly OperationInten
} }
const after=new Map<Id,ElementRecord>();for(const id of ids){const record=elements.get(id);if(record){after.set(id,record);owners.add(record.ownerCanvasId);}} const after=new Map<Id,ElementRecord>();for(const id of ids){const record=elements.get(id);if(record){after.set(id,record);owners.add(record.ownerCanvasId);}}
const transforms=new Map<Id,Transform>();for(const owner of owners)if(canvases.has(owner))transforms.set(owner,canvasTransform(cursor,owner)); const transforms=new Map<Id,Transform>();for(const owner of owners)if(canvases.has(owner))transforms.set(owner,canvasTransform(cursor,owner));
// 私有 cursor 按原 before 逆向退回,不执行用户可见的 restore,也不因此分配新 incarnation。 // 直接把记录/字段写回原 before,不检查当前是否等于 after;field 缺对象/代次不符时跳过。
// cursor 中间树可不完整,随后仅在 owner 仍存在时取变换;无法解析的祖先链仍可能抛错。
for(const change of [...intent.changes].reverse()){ for(const change of [...intent.changes].reverse()){
if(change.kind==='element'){if(change.before)elements.set(change.id,change.before);else elements.delete(change.id);} if(change.kind==='element'){if(change.before)elements.set(change.id,change.before);else elements.delete(change.id);}
else if(change.kind==='canvas'){if(change.before)canvases.set(change.id,change.before);else canvases.delete(change.id);} else if(change.kind==='canvas'){if(change.before)canvases.set(change.id,change.before);else canvases.delete(change.id);}
@@ -51,7 +55,9 @@ function sameConvertedPath(left:Json,right:Json):boolean {
const point=(a:Json|undefined,b:Json|undefined)=>{if(a===undefined||b===undefined)return a===b;if(!a||!b||typeof a!=='object'||typeof b!=='object'||Array.isArray(a)||Array.isArray(b))return false;const{x:ax,y:ay,...restA}=a,{x:bx,y:by,...restB}=b;return typeof ax==='number'&&typeof ay==='number'&&typeof bx==='number'&&typeof by==='number'&&closeNumber(ax,bx)&&closeNumber(ay,by)&&equal(restA,restB);}; const point=(a:Json|undefined,b:Json|undefined)=>{if(a===undefined||b===undefined)return a===b;if(!a||!b||typeof a!=='object'||typeof b!=='object'||Array.isArray(a)||Array.isArray(b))return false;const{x:ax,y:ay,...restA}=a,{x:bx,y:by,...restB}=b;return typeof ax==='number'&&typeof ay==='number'&&typeof bx==='number'&&typeof by==='number'&&closeNumber(ax,bx)&&closeNumber(ay,by)&&equal(restA,restB);};
return equal(ar,br)&&point(as,bs)&&point(ae,be)&&a.length===b.length&&a.every((value,index)=>{const other=b[index];if(!value||!other||typeof value!=='object'||typeof other!=='object'||Array.isArray(value)||Array.isArray(other))return false;const{position:p,in:i,out:o,...rest}=value,{position:q,in:j,out:k,...remaining}=other;return equal(rest,remaining)&&point(p,q)&&point(i,j)&&point(o,k);}); return equal(ar,br)&&point(as,bs)&&point(ae,be)&&a.length===b.length&&a.every((value,index)=>{const other=b[index];if(!value||!other||typeof value!=='object'||typeof other!=='object'||Array.isArray(value)||Array.isArray(other))return false;const{position:p,in:i,out:o,...rest}=value,{position:q,in:j,out:k,...remaining}=other;return equal(rest,remaining)&&point(p,q)&&point(i,j)&&point(o,k);});
} }
/** 只把原意图实际改过的 presentation 键应用到 current;unitScale 另走视觉单位换算。 */ /** 以键的存在性和值比较找 presentation 顶层差异,再覆盖/删除 current 的对应键。
* 嵌套键值作为整体处理,不递归合并;unitScale 排除在本函数外,交几何分支做视觉单位换算。
* 无变化时沿用 current;有变化后若结果无键则返回 undefined,交调用方处理字段存在性。 */
function presentationDelta(current:Json|undefined,before:Json|undefined,after:Json|undefined):Json|undefined { function presentationDelta(current:Json|undefined,before:Json|undefined,after:Json|undefined):Json|undefined {
if(equal(before,after))return current; if(equal(before,after))return current;
const start=presentationRecord(before),end=presentationRecord(after),keys=new Set([...Object.keys(start),...Object.keys(end)]);keys.delete('unitScale'); const start=presentationRecord(before),end=presentationRecord(after),keys=new Set([...Object.keys(start),...Object.keys(end)]);keys.delete('unitScale');
@@ -63,6 +69,7 @@ function presentationDelta(current:Json|undefined,before:Json|undefined,after:Js
* 用户明确改变的中心/尺寸/旋转取其意图值,再转到 fresh destination;不是盲加一份旧 local patch。 */ * 用户明确改变的中心/尺寸/旋转取其意图值,再转到 fresh destination;不是盲加一份旧 local patch。 */
function movedNode(current:Extract<ElementRecord,{structure:'node'}>,originalBefore:Extract<ElementRecord,{structure:'node'}>,originalAfter:Extract<ElementRecord,{structure:'node'}>,beforeBasis:Transform,afterBasis:Transform,source:Transform,destination:Transform,intent:OperationIntent){ function movedNode(current:Extract<ElementRecord,{structure:'node'}>,originalBefore:Extract<ElementRecord,{structure:'node'}>,originalAfter:Extract<ElementRecord,{structure:'node'}>,beforeBasis:Transform,afterBasis:Transform,source:Transform,destination:Transform,intent:OperationIntent){
const world:Transform={x:0,y:0,scale:1,rotation:0},start=convertGeometry(originalBefore.geometry,beforeBasis,world),end=convertGeometry(originalAfter.geometry,afterBasis,world),fresh=convertGeometry(current.geometry,source,world); const world:Transform={x:0,y:0,scale:1,rotation:0},start=convertGeometry(originalBefore.geometry,beforeBasis,world),end=convertGeometry(originalAfter.geometry,afterBasis,world),fresh=convertGeometry(current.geometry,source,world);
// 世界中心用于分别判断横/纵移动意图;未变化的维度取 fresh,修改维度取原意图终值,而非叠加 delta。
const center=(g:typeof start)=>({x:g.x+g.width/2,y:g.y+g.height/2}),a=center(start),b=center(end),c=center(fresh),width=closeNumber(start.width,end.width)?fresh.width:end.width,height=closeNumber(start.height,end.height)?fresh.height:end.height; const center=(g:typeof start)=>({x:g.x+g.width/2,y:g.y+g.height/2}),a=center(start),b=center(end),c=center(fresh),width=closeNumber(start.width,end.width)?fresh.width:end.width,height=closeNumber(start.height,end.height)?fresh.height:end.height;
const x=closeNumber(a.x,b.x)?c.x:b.x,y=closeNumber(a.y,b.y)?c.y:b.y,rotation=Math.abs(((start.rotation-end.rotation+540)%360)-180)<1e-9?fresh.rotation:end.rotation; const x=closeNumber(a.x,b.x)?c.x:b.x,y=closeNumber(a.y,b.y)?c.y:b.y,rotation=Math.abs(((start.rotation-end.rotation+540)%360)-180)<1e-9?fresh.rotation:end.rotation;
const geometry=convertGeometry({x:x-width/2,y:y-height/2,width,height,rotation},world,destination); const geometry=convertGeometry({x:x-width/2,y:y-height/2,width,height,rotation},world,destination);
@@ -75,7 +82,9 @@ function movedNode(current:Extract<ElementRecord,{structure:'node'}>,originalBef
} }
/** Restore batches may have inverse storage order (canvas before entry). Only reorder adjacent creates, never move across an edit/delete. */ /** Restore batches may have inverse storage order (canvas before entry). Only reorder adjacent creates, never move across an edit/delete. */
/** 只在连续创建段内按 parent/entry/owner/端点依赖拓扑排序,绝不跨编辑/删除重排。 */ /** 只在连续创建段内按 parent/entry/owner/端点依赖拓扑排序,绝不跨编辑/删除重排。
* 仅段内依赖参与 Kahn 计数;段外依赖是否存在留给执行/最终校验。元素与 canvas 分命名空间。
* ready 按发现顺序排队,不保证每次都选择剩余项中原索引最小者;重 ID 或环直接拒绝。 */
function orderedCreates(changes:readonly Change[]):readonly Change[]{ function orderedCreates(changes:readonly Change[]):readonly Change[]{
const result:Change[]=[]; const result:Change[]=[];
/** before=null 且 after 存在才是创建,整记录替换不能冒充依赖排序候选。 */ /** before=null 且 after 存在才是创建,整记录替换不能冒充依赖排序候选。 */
@@ -101,17 +110,22 @@ function orderedCreates(changes:readonly Change[]):readonly Change[]{
return result; return result;
} }
/** Re-plan against current data. Whole records are used only for explicit creation/removal. */ /** Re-plan against current data; whole element replacement is unsupported. */
/** 在 fresh 私有文档上重新生成条件 changes,原 intent/context 保持不变;最终完整校验才返回。 */ /** 在 fresh 私有候选上重新生成条件 changes,保留当前未被用户意图修改的数据,最终整图校验才返回。
* 本函数不验证恢复选择/账本依赖,不发布 Root、不记录 history、不修改原冻结请求;这些由冲突协调器负责。
* 整元素仅支持显式增删,canvas 记录可重建 parent;普通字段的 before 从 fresh 实值重新生成。
* 输入应为已捕获的不可变模型/上下文,辅助 apply 的冻结不构成任意外部对象的深隔离保证。 */
export function rebaseIntent(document:Document,intent:OperationIntent,context:IntentContext,registry:Registry):{document:Document;changes:readonly Change[]} { export function rebaseIntent(document:Document,intent:OperationIntent,context:IntentContext,registry:Registry):{document:Document;changes:readonly Change[]} {
// moved 标记需整体世界空间重规划的节点;deferredPaths 等端点/树最终确定后再算曲线。 // moved 标记需整体世界空间重规划的节点;deferredPaths 等端点/树最终确定后再算曲线。
const before=document,changes:Change[]=[],moved=new Set<Id>(),deferredPaths:FieldChange[]=[]; const before=document,changes:Change[]=[],moved=new Set<Id>(),deferredPaths:FieldChange[]=[];
for(const change of intent.changes)if(change.kind==='field'&&change.field==='ownerCanvasId'&&context.after.get(change.target.id)?.structure==='node')moved.add(change.target.id); for(const change of intent.changes)if(change.kind==='field'&&change.field==='ownerCanvasId'&&context.after.get(change.target.id)?.structure==='node')moved.add(change.target.id);
/** 每条新变化立即作用于私有 candidate,后面的条件读取已重规划后的实际 before。 */ /** 每条新变化立即作用于私有 candidate,后面的条件读取已重规划后的实际 before。 */
function apply(change:Change){document=applyChanges(document,[change]);changes.push(freeze(change));} function apply(change:Change){document=applyChanges(document,[change]);changes.push(freeze(change));}
/** 同 ID 还必须同 incarnation/结构/组件/版本且 registry 可解释,防止旧意图套到新对象上。 */ /** 目标必须存在、代次相同且 registry 已知;上下文含 prior 时再比较结构/组件/版本。
* 这是字段分支共用准入,具体能力检查在各分支;上下文缺 prior 不在这里补造类型依据。 */
function target(change:FieldChange){const current=document.elements.get(change.target.id),prior=context.before.get(change.target.id)??context.after.get(change.target.id);if(!current||current.incarnation!==change.target.incarnation)throw fault('stale_incarnation',[change.target.id]);if(prior&&(current.componentId!==prior.componentId||current.dataVersion!==prior.dataVersion||current.structure!==prior.structure))throw fault('recovery_target_type_changed',[current.id]);if(registry.resolve(current)!=='known')throw fault('unsupported_component',[current.id]);return current;} function target(change:FieldChange){const current=document.elements.get(change.target.id),prior=context.before.get(change.target.id)??context.after.get(change.target.id);if(!current||current.incarnation!==change.target.incarnation)throw fault('stale_incarnation',[change.target.id]);if(prior&&(current.componentId!==prior.componentId||current.dataVersion!==prior.dataVersion||current.structure!==prior.structure))throw fault('recovery_target_type_changed',[current.id]);if(registry.resolve(current)!=='known')throw fault('unsupported_component',[current.id]);return current;}
/** 端点必须仍为原上下文中的同代次节点;不能把恢复出来的新节点视为旧外部引用。 */ /** 当前端点须为 node,且与 context.after 中对象代次一致;无上下文也拒绝。
* 这里不单独比较端点的 componentId/dataVersion,图关系是否合法仍由最终校验判断。 */
function endpoint(id:Id){const current=document.elements.get(id),expected=context.after.get(id);if(!current||current.structure!=='node'||!expected||current.incarnation!==expected.incarnation)throw fault('stale_endpoint',[id]);} function endpoint(id:Id){const current=document.elements.get(id),expected=context.after.get(id);if(!current||current.structure!=='node'||!expected||current.incarnation!==expected.incarnation)throw fault('stale_endpoint',[id]);}
// 按受限依赖顺序处理创建;删除/字段意图的原语义顺序仍保留。 // 按受限依赖顺序处理创建;删除/字段意图的原语义顺序仍保留。
for(const original of orderedCreates(intent.changes)){ for(const original of orderedCreates(intent.changes)){
@@ -132,7 +146,8 @@ export function rebaseIntent(document:Document,intent:OperationIntent,context:In
after={...after,ownerCanvasId:owner,path:convertPath(after.path,basis,canvasTransform(document,owner))}; after={...after,ownerCanvasId:owner,path:convertPath(after.path,basis,canvasTransform(document,owner))};
} }
apply({...original,after}); apply({...original,after});
// 已经不存在的删除可满足;仍存在时按 fresh 拥有树规划移除,不能直接覆盖旧整记录。 // 删除目标已不存在时直接满足,不在此以墓碑证明它曾是哪一代;仍存在则要求旧代次和已知类型。
// 按 fresh 拥有树重新规划删除,可包含远端新增子内容/关联边,而不是照抄旧删除清单。
}else if(original.before&&original.after===null){ }else if(original.before&&original.after===null){
if(!current)continue; if(!current)continue;
if(current.incarnation!==original.before.incarnation)throw fault('stale_incarnation',[original.id]); if(current.incarnation!==original.before.incarnation)throw fault('stale_incarnation',[original.id]);
@@ -210,7 +225,8 @@ export function rebaseIntent(document:Document,intent:OperationIntent,context:In
if(edge.ownerCanvasId!==owner)apply({kind:'field',target:original.target,field:'ownerCanvasId',before:edge.ownerCanvasId,after:owner}); if(edge.ownerCanvasId!==owner)apply({kind:'field',target:original.target,field:'ownerCanvasId',before:edge.ownerCanvasId,after:owner});
if(!equal(edge.path,path))apply({...original,before:edge.path,after:path}); if(!equal(edge.path,path))apply({...original,before:edge.path,after:path});
} }
// 所有端点/树变化结束后统一修复边 owner/path,再验证最终文档,避免暴露半份图。 // 使用初始 fresh 与最终候选修复受影响边的 owner/path,再做整图校验;空 changes 也要校验。
// 抛错不返回半成品,成功仍只是规划结果;是否采纳以及新的 operation/history 身份由调用方决定。
for(const change of repairConnectionOwners(before,document))apply(change); for(const change of repairConnectionOwners(before,document))apply(change);
validateDocument(document,registry);return{document,changes:freeze(changes)}; validateDocument(document,registry);return{document,changes:freeze(changes)};
} }
+5 -2
View File
@@ -5,12 +5,15 @@
import {fault} from '../canvas-core/values.ts'; import {fault} from '../canvas-core/values.ts';
import {createImportBudget,type ImportScheduling} from './import-budget.ts'; import {createImportBudget,type ImportScheduling} from './import-budget.ts';
/** Share import scheduling while retaining the copy cancellation/error contract. */ /** Share import scheduling while retaining the copy cancellation/error contract. */
/** 验证copy预算后包装ImportBudget;调用者只有真正await返回Promise才会交出执行权。 */ /** 复制任务的 ImportBudget 包装:构造与每次 step 都先检查取消,错误码使用 copy_aborted。
* 只预校验 frameBudgetMs;units/时钟/调度器沿用底层合同,单位可以是记录数或由调用方折算的字节工作量。
* 与同一个包装交互应顺序 await;没有独立计数器、持久进度或强制抢占能力。 */
export function createCopyBudget(signal:AbortSignal,options:ImportScheduling={}){ export function createCopyBudget(signal:AbortSignal,options:ImportScheduling={}){
if(signal.aborted)throw fault('copy_aborted'); if(signal.aborted)throw fault('copy_aborted');
if(options.frameBudgetMs!==undefined&&(!Number.isFinite(options.frameBudgetMs)||options.frameBudgetMs<=0))throw fault('invalid_copy_budget'); if(options.frameBudgetMs!==undefined&&(!Number.isFinite(options.frameBudgetMs)||options.frameBudgetMs<=0))throw fault('invalid_copy_budget');
const budget=createImportBudget(signal,options); const budget=createImportBudget(signal,options);
/** signal已取消时将同步/异步预算失败统一为copy_aborted,其它错误原样抛给宿主。 */ /** 只在处理错误时 signal 已取消才改成 copy_aborted;其它错误保留原对象。
* 因此取消和宿主调度失败并发出现时,取消优先,不一定暴露原始调度错误。 */
const fail=(error:unknown):never=>{if(signal.aborted)throw fault('copy_aborted');throw error;}; const fail=(error:unknown):never=>{if(signal.aborted)throw fault('copy_aborted');throw error;};
/** 每次step先查copy取消,保持底层同步返回undefined或异步让出的原调度语义。 */ /** 每次step先查copy取消,保持底层同步返回undefined或异步让出的原调度语义。 */
return Object.freeze({step(units=1):Promise<void>|undefined{ return Object.freeze({step(units=1):Promise<void>|undefined{
+10 -2
View File
@@ -12,11 +12,15 @@ import type {StorageLeader,StoragePort} from './storage-port.ts';
import {resourceRetentionPins} from './resource-retention.ts'; import {resourceRetentionPins} from './resource-retention.ts';
/** Derive one project's shared-byte manifest from the exact Root being persisted. */ /** Derive one project's shared-byte manifest from the exact Root being persisted. */
/** 生成已确认上传/跨项目 binding 的共享身份、所需字节与保留清单;无 depot 能力明确拒绝。 */ /** 从指定不可变 snapshot 派生共享身份/字节写入项与 pins,缺少 assetDepot 端口则拒绝。
* 会异步读取 depot/本地文件;不提交存储事务、不重算摘要,也不在 await 后自行校验 leader/宿主身份。
* 返回可变描述数组而非冻结 Root;调用方须把清单与该 snapshot 同次提交,rootRevision 不另存于返回值。
* snapshot 和各输入缓存需遵循宿主捕获约定,尤其 adoptedFiles 用 revision 防止混入较晚的采纳。 */
export async function prepareDepotCut(input:{snapshot:EngineSnapshot;registry:Registry;storage:StoragePort;leader:StorageLeader;authority:string;files:ReadonlyMap<string,Blob>;displays:ReadonlyMap<string,number>;temporary:ReadonlyMap<string,readonly string[]>;adoptedFiles?:ReadonlyMap<string,{readonly bytes:Blob;readonly rootRevision:number}>}):Promise<DepotCut>{ export async function prepareDepotCut(input:{snapshot:EngineSnapshot;registry:Registry;storage:StoragePort;leader:StorageLeader;authority:string;files:ReadonlyMap<string,Blob>;displays:ReadonlyMap<string,number>;temporary:ReadonlyMap<string,readonly string[]>;adoptedFiles?:ReadonlyMap<string,{readonly bytes:Blob;readonly rootRevision:number}>}):Promise<DepotCut>{
const {snapshot,storage,leader,authority}=input,pins=resourceRetentionPins(snapshot,input.registry,input.displays,input.temporary),writes:DepotWrite[]=[]; const {snapshot,storage,leader,authority}=input,pins=resourceRetentionPins(snapshot,input.registry,input.displays,input.temporary),writes:DepotWrite[]=[];
if(!storage.assetDepot)throw fault('asset_depot_unavailable'); if(!storage.assetDepot)throw fault('asset_depot_unavailable');
// 仅 stored+digest 的原图可进入共享身份;未上传文件仍留在项目本地文件仓库。 // 每个 stored+digest 资源都登记身份(包括已 purge 的项目记录),但只有仍保留的才尝试补字节。
// sharedAssetRef 校验并捕获元数据,随后要求资产 ID/长度对应本地记录;未上传资源不进入共享写入清单。
for(const resource of snapshot.runtime.resources.values())if(resource.stored&&resource.digest){ for(const resource of snapshot.runtime.resources.values())if(resource.stored&&resource.digest){
const ref=sharedAssetRef({version:1,authority,digest:resource.digest,asset:resource.stored}); const ref=sharedAssetRef({version:1,authority,digest:resource.digest,asset:resource.stored});
if(ref.asset.id!==resource.id||ref.asset.bytes!==resource.byteLength)throw fault('shared_asset_identity_conflict',[resource.id]); if(ref.asset.id!==resource.id||ref.asset.bytes!==resource.byteLength)throw fault('shared_asset_identity_conflict',[resource.id]);
@@ -25,6 +29,8 @@ export async function prepareDepotCut(input:{snapshot:EngineSnapshot;registry:Re
if(resource.retirement?.purgedAt===undefined&&pins.pins.has(resource.id)){ if(resource.retirement?.purgedAt===undefined&&pins.pins.has(resource.id)){
const cached=await storage.assetDepot.read(leader,authority,resource.id); const cached=await storage.assetDepot.read(leader,authority,resource.id);
// The store enforces immutable identity even when bytes are already present. // The store enforces immutable identity even when bytes are already present.
// 缓存已有字节则只提交身份;否则优先项目内存文件,再读项目存储。
// 两处均无字节时仍可形成仅身份写入项;是否满足事务要求、摘要与不可变身份由 adapter 检查。
if(!cached?.bytes)bytes=input.files.get(resource.id)??await storage.readFile(leader,resource.id); if(!cached?.bytes)bytes=input.files.get(resource.id)??await storage.readFile(leader,resource.id);
} }
writes.push({ref,...(bytes?{bytes}:{})}); writes.push({ref,...(bytes?{bytes}:{})});
@@ -34,9 +40,11 @@ export async function prepareDepotCut(input:{snapshot:EngineSnapshot;registry:Re
if(binding.authority!==authority||binding.asset.id!==id||snapshot.runtime.resources.has(id))throw fault('shared_asset_identity_conflict',[id]); if(binding.authority!==authority||binding.asset.id!==id||snapshot.runtime.resources.has(id))throw fault('shared_asset_identity_conflict',[id]);
const pending=input.adoptedFiles?.get(id),bytes=pending&&pending.rootRevision<=snapshot.rootRevision?pending.bytes:undefined;writes.push({ref:sharedAssetRef(binding),...(bytes?{bytes}:{})}); const pending=input.adoptedFiles?.get(id),bytes=pending&&pending.rootRevision<=snapshot.rootRevision?pending.bytes:undefined;writes.push({ref:sharedAssetRef(binding),...(bytes?{bytes}:{})});
} }
// binding 不以 pins 决定是否写入,只有其暂存 bytes 的采纳 revision 不晚于本 Root 才携带字节。
// A source/run envelope may refer to resources outside this project's upload table. // A source/run envelope may refer to resources outside this project's upload table.
// Its complete resource interpreter must be installed before global deletion is allowed. // Its complete resource interpreter must be installed before global deletion is allowed.
// 引用、加载与任务解释都完整才允许全局回收;opaque run/未完导入不能假定无共享资源引用。 // 引用、加载与任务解释都完整才允许全局回收;opaque run/未完导入不能假定无共享资源引用。
const complete=pins.complete&&!snapshot.runtime.loading&&![...(snapshot.runtime.imports?.values()??[])].some(job=>!['completed','partial','cancelled'].includes(job.status))&&snapshot.runtime.runs.size===0; const complete=pins.complete&&!snapshot.runtime.loading&&![...(snapshot.runtime.imports?.values()??[])].some(job=>!['completed','partial','cancelled'].includes(job.status))&&snapshot.runtime.runs.size===0;
// complete 是共享仓库全局删除所需的覆盖声明,不是字节写入完成回执;真正删除由仓库协议决定。
return{version:1,authority,complete,pins:[...pins.pins].map(([id,owners])=>[id,[...owners]] as const),writes}; return{version:1,authority,complete,pins:[...pins.pins].map(([id,owners])=>[id,[...owners]] as const),writes};
} }
+11 -4
View File
@@ -8,7 +8,9 @@ import {importSourceDigest} from './import-integrity.ts';
/** 可替换摘要端口,输出完整 Blob 的小写 SHA256;close 终止本池,不清业务数据或外部回执。 */ /** 可替换摘要端口,输出完整 Blob 的小写 SHA256;close 终止本池,不清业务数据或外部回执。 */
export type DigestPort={digest(bytes:Blob,signal?:AbortSignal):Promise<string>;close():void}; export type DigestPort={digest(bytes:Blob,signal?:AbortSignal):Promise<string>;close():void};
/** Read-only compute scheduling: bounded active slots + FIFO Map, never durable operation facts. */ /** Read-only compute scheduling: bounded active slots + FIFO Map, never durable operation facts. */
/** 默认2并发/64等待,上限8并发;可注入 Worker/native/测试执行器,默认使用分块摘要。 */ /** 默认 2 并发/64 等待,上限 8 并发;可注入 execute,默认 importSourceDigest 分块计算。
* maxQueued 只计算 waiting,不含 active;不限制 Blob 大小/MIME,不做相同文件去重或结果缓存。
* execute 负责计算真实完整摘要,池仅用正则检查输出格式,不能证明输出确实对应输入字节。 */
export function createDigestPool(options:{execute?:(bytes:Blob,signal:AbortSignal)=>Promise<string>;concurrency?:number;maxQueued?:number}={}):DigestPort{ export function createDigestPool(options:{execute?:(bytes:Blob,signal:AbortSignal)=>Promise<string>;concurrency?:number;maxQueued?:number}={}):DigestPort{
const concurrency=options.concurrency??2,maxQueued=options.maxQueued??64,execute=options.execute??importSourceDigest; const concurrency=options.concurrency??2,maxQueued=options.maxQueued??64,execute=options.execute??importSourceDigest;
if(!Number.isSafeInteger(concurrency)||concurrency<1||concurrency>8||!Number.isSafeInteger(maxQueued)||maxQueued<1)throw fault('invalid_digest_budget'); if(!Number.isSafeInteger(concurrency)||concurrency<1||concurrency>8||!Number.isSafeInteger(maxQueued)||maxQueued<1)throw fault('invalid_digest_budget');
@@ -16,9 +18,13 @@ export function createDigestPool(options:{execute?:(bytes:Blob,signal:AbortSigna
type Task={id:number;bytes:Blob;controller:AbortController;resolve:(value:string)=>void;reject:(error:unknown)=>void;cleanup:()=>void;done:boolean}; type Task={id:number;bytes:Blob;controller:AbortController;resolve:(value:string)=>void;reject:(error:unknown)=>void;cleanup:()=>void;done:boolean};
// Map 既保留入队次序,又可在等待期取消时 O(1) 删除;active 保留真实未结束执行的容量占用。 // Map 既保留入队次序,又可在等待期取消时 O(1) 删除;active 保留真实未结束执行的容量占用。
const waiting=new Map<number,Task>(),active=new Map<number,Task>();let next=0,closed=false; const waiting=new Map<number,Task>(),active=new Map<number,Task>();let next=0,closed=false;
/** 只 settle 一次并移除等待项/监听;故意不在这里 active.delete,迟到结果不能二次采纳。 */ /** 只 settle 一次并移除 waiting/外部监听,不在这里 active.delete,迟到结果不能二次采纳。
* error 用真值分支,execute 应以 Error 等真值拒绝;falsy rejection 不保证被当成失败处理。 */
function finish(task:Task,error?:unknown,value?:string){if(task.done)return;task.done=true;task.cleanup();waiting.delete(task.id);if(error)task.reject(error);else task.resolve(value!);} function finish(task:Task,error?:unknown,value?:string){if(task.done)return;task.done=true;task.cleanup();waiting.delete(task.id);if(error)task.reject(error);else task.resolve(value!);}
/** 有空槽就取最早等待项,先登记 active 再微任务执行;结果校验/取消后 finally 归还真实槽。 */ /** 从 Map 首项 FIFO 取任务,先占 active 再微任务调用 execute;启动前、结果返回后均复查 abort。
* execute 的拒绝由第二个 then 回调处理,结果校验抛错再由 catch 处理,最终统一归还槽位并泵下一项。
* 正则会按 JS 规则强制转字符串,未另做 typeof 检查;注入执行器应遵守 Promise<string> 合同。
* 无视 abort 且不 settle 的执行器会一直占槽;池没有超时或强制终止通用执行器的能力。 */
function pump(){ function pump(){
if(closed)return; if(closed)return;
while(active.size<concurrency&&waiting.size){const task=waiting.values().next().value!;waiting.delete(task.id);active.set(task.id,task); while(active.size<concurrency&&waiting.size){const task=waiting.values().next().value!;waiting.delete(task.id);active.set(task.id,task);
@@ -31,7 +37,8 @@ export function createDigestPool(options:{execute?:(bytes:Blob,signal:AbortSigna
if(closed)return Promise.reject(fault('digest_pool_closed'));if(signal?.aborted)return Promise.reject(fault('import_aborted'));if(!(bytes instanceof Blob))return Promise.reject(fault('invalid_digest_input'));if(active.size>=concurrency&&waiting.size>=maxQueued)return Promise.reject(fault('digest_capacity')); if(closed)return Promise.reject(fault('digest_pool_closed'));if(signal?.aborted)return Promise.reject(fault('import_aborted'));if(!(bytes instanceof Blob))return Promise.reject(fault('invalid_digest_input'));if(active.size>=concurrency&&waiting.size>=maxQueued)return Promise.reject(fault('digest_capacity'));
return new Promise((resolve,reject)=>{const task:Task={id:++next,bytes,controller:new AbortController(),resolve,reject,cleanup:()=>signal?.removeEventListener('abort',abort),done:false},abort=()=>{task.controller.abort();finish(task,fault('import_aborted'));pump();};signal?.addEventListener('abort',abort,{once:true});waiting.set(task.id,task);pump();}); return new Promise((resolve,reject)=>{const task:Task={id:++next,bytes,controller:new AbortController(),resolve,reject,cleanup:()=>signal?.removeEventListener('abort',abort),done:false},abort=()=>{task.controller.abort();finish(task,fault('import_aborted'));pump();};signal?.addEventListener('abort',abort,{once:true});waiting.set(task.id,task);pump();});
}, },
/** 幂等关闭,取消等待/活跃任务的调用结果;无视 signal 的执行仍由其 Promise 收尾后释放 active。 */ /** 幂等关闭并取消 waiting/active 的调用结果,停止接受新任务;不返回 drain Promise。
* 无视 signal 的执行仍由自身 Promise 收尾后释放 active,close 返回不证明底层计算已停止。 */
close(){if(closed)return;closed=true;for(const task of [...waiting.values(),...active.values()]){task.controller.abort();finish(task,fault('import_aborted'));}}, close(){if(closed)return;closed=true;for(const task of [...waiting.values(),...active.values()]){task.controller.abort();finish(task,fault('import_aborted'));}},
}); });
} }
@@ -11,7 +11,10 @@ export const nativeDigestByteLimit=64*1024*1024;
/** Dedicated Worker only: whole-buffer native hashing never moves onto the UI thread. /** Dedicated Worker only: whole-buffer native hashing never moves onto the UI thread.
* The host still owns cancellation by terminating the request's Worker. */ * The host still owns cancellation by terminating the request's Worker. */
/** 验证 Blob,按能力/大小选 native 或增量实现,均返回相同的完整原字节摘要。 */ /** 验证 Blob,SubtleCrypto 存在且大小 ≤64MiB 时读取整个 ArrayBuffer 做 SHA-256,否则流式计算。
* 原生读/算失败直接传播,不自动切到流式重试;注入 subtle 应遵守原生 digest 的 ArrayBuffer 合同。
* 本函数无 signal,取消靠宿主终止 Worker;代码本身不检测当前线程,Worker 限制由装配调用方遵守。
* 阈值只限制此输入的整缓冲分配,未计 Blob/原生算法内部内存及其它并发 Worker。 */
export async function digestWorkerSource(bytes:Blob,subtle:Pick<SubtleCrypto,'digest'>|null|undefined=globalThis.crypto?.subtle):Promise<string>{ export async function digestWorkerSource(bytes:Blob,subtle:Pick<SubtleCrypto,'digest'>|null|undefined=globalThis.crypto?.subtle):Promise<string>{
if(!(bytes instanceof Blob))throw fault('invalid_digest_input'); if(!(bytes instanceof Blob))throw fault('invalid_digest_input');
// native digest 必须返回32字节;输出固定64字符十六进制,不接受变长响应。 // native digest 必须返回32字节;输出固定64字符十六进制,不接受变长响应。
@@ -20,6 +23,7 @@ export async function digestWorkerSource(bytes:Blob,subtle:Pick<SubtleCrypto,'di
if(digest.byteLength!==32)throw fault('invalid_digest_response'); if(digest.byteLength!==32)throw fault('invalid_digest_response');
return [...new Uint8Array(digest)].map(value=>value.toString(16).padStart(2,'0')).join(''); return [...new Uint8Array(digest)].map(value=>value.toString(16).padStart(2,'0')).join('');
} }
// Worker fallback 每块1MiB,只做微任务让出;UI 让出策略由 UI fallback 的端口另行决定。 // 每块 1MiB,块间 yieldTask 仅完成一个 Promise 微任务,不保证 Worker 消息队列获得宏任务机会。
// UI fallback 用 importSourceDigest 默认定时器让出;不要把这里的微任务策略移作 UI 响应性承诺。
return importSourceDigest(bytes,undefined,{chunkBytes:1024*1024,yieldTask:()=>Promise.resolve()}); return importSourceDigest(bytes,undefined,{chunkBytes:1024*1024,yieldTask:()=>Promise.resolve()});
} }
+3 -1
View File
@@ -8,7 +8,9 @@ import {digestWorkerSource} from './digest-worker-source.ts';
const scope=globalThis as unknown as {onmessage:((event:MessageEvent)=>void)|null;postMessage(value:unknown):void}; const scope=globalThis as unknown as {onmessage:((event:MessageEvent)=>void)|null;postMessage(value:unknown):void};
/** 首条有效请求被接受后保持 true,直到整个 Worker 被宿主终止。 */ /** 首条有效请求被接受后保持 true,直到整个 Worker 被宿主终止。 */
let accepted=false; let accepted=false;
/** 校验协议和请求后启动只读摘要;成功/失败都回显输入 ID 与字节数,供宿主核对。 */ /** 首条有效 version=1/正安全整数 ID/Blob 设置 accepted,之后包括失败后的重复消息都忽略。
* 无效请求直接忽略,不返回错误;这里不检查 MIME/大小或资源身份,不拥有持久任务。
* 算法拒绝会尝试回稳定错误;成功回信自身抛错不由同一个 then 的失败回调捕获,宿主仍需错误/取消收尾。 */
scope.onmessage=event=>{ scope.onmessage=event=>{
const request=event.data;if(accepted||!request||request.version!==1||!Number.isSafeInteger(request.id)||request.id<1||!(request.bytes instanceof Blob))return;accepted=true; const request=event.data;if(accepted||!request||request.version!==1||!Number.isSafeInteger(request.id)||request.id<1||!(request.bytes instanceof Blob))return;accepted=true;
void digestWorkerSource(request.bytes).then(sha256=>scope.postMessage({version:1,id:request.id,byteLength:request.bytes.size,sha256}),()=>scope.postMessage({version:1,id:request.id,byteLength:request.bytes.size,error:'digest_worker_failed'})); void digestWorkerSource(request.bytes).then(sha256=>scope.postMessage({version:1,id:request.id,byteLength:request.bytes.size,sha256}),()=>scope.postMessage({version:1,id:request.id,byteLength:request.bytes.size,error:'digest_worker_failed'}));
+11 -4
View File
@@ -16,12 +16,15 @@ type Entry = { readonly key: string; readonly owner: object; readonly controller
* durable bytes before acquiring. Scope objects are never serialized or reused * durable bytes before acquiring. Scope objects are never serialized or reused
* across project engines/auth epochs. There is no idle URL retention. * across project engines/auth epochs. There is no idle URL retention.
*/ */
/** 创建纯显示生命周期容器;owner 应绑定 engine/auth epoch,不能序列化或跨账号复用。 */ /** 创建纯显示生命周期容器,owners 两层 Map 用对象身份+字符串 key 索引共享条目。
* owner 应绑定 engine/auth epoch;本模块不解释账号、资源 ID、URL 权限或实际文件 pin。
* 没有容量上限/空闲保留,最后 lease 释放即清理;永久撤销 owner 则另记到 WeakSet。 */
export function createDisplayCache(options: { onError?: (error: unknown) => void } = {}) { export function createDisplayCache(options: { onError?: (error: unknown) => void } = {}) {
// owners 仅保留活条目;retired WeakSet 禁止退役 owner 再 acquire,但不强持有其内存。 // owners 仅保留活条目;retired WeakSet 禁止退役 owner 再 acquire,但不强持有其内存。
const owners = new Map<object, Map<string, Entry>>(), retired = new WeakSet<object>(); const owners = new Map<object, Map<string, Entry>>(), retired = new WeakSet<object>();
let closed = false; let closed = false;
/** 统一调用来源 release 并隔离错误,清理/日志失败不阻止释放其它 URL。 */ /** 同步调用来源 release,隔离其抛错和 onError 的抛错;onError 只报告清理失败。
* load 的失败通过各 lease.ready 传出,不在这里自动记录或重试;release 合同也不是异步清理任务。 */
function dispose(source: DisplaySource) { try { source.release?.(); } catch (error) { try { options.onError?.(error); } catch { /* Reporting cannot interrupt cleanup. */ } } } function dispose(source: DisplaySource) { try { source.release?.(); } catch (error) { try { options.onError?.(error); } catch { /* Reporting cannot interrupt cleanup. */ } } }
/** 幂等退休共享条目,仅删除仍指向自己的表项,避免旧任务删掉同 key 的新 entry。 */ /** 幂等退休共享条目,仅删除仍指向自己的表项,避免旧任务删掉同 key 的新 entry。 */
function retire(entry: Entry) { function retire(entry: Entry) {
@@ -41,7 +44,9 @@ export function createDisplayCache(options: { onError?: (error: unknown) => void
} }
} }
return Object.freeze({ return Object.freeze({
/** 借用显示来源:同 owner/key 合并加载,但每个调用者可独立取消自己的等待。 */ /** 同 owner/key 复用进行中或已加载来源,命中时忽略本次传入的 load,不逐次重验权限。
* 每个调用者独立 ready/release;释放已兑现的 ready 不会改变 Promise 的成功结果,但 URL 可能随最后释放失效。
* 不自动接收 AbortSignal,取消通过 lease.release;单个释放不取消仍有其它消费者的共享加载。 */
acquire(owner: object, key: string, load: (signal: AbortSignal) => Promise<DisplaySource>): DisplayLease { acquire(owner: object, key: string, load: (signal: AbortSignal) => Promise<DisplaySource>): DisplayLease {
if (closed || retired.has(owner)) throw fault('display_scope_closed'); if (closed || retired.has(owner)) throw fault('display_scope_closed');
if (!owner || typeof owner !== 'object' || typeof key !== 'string' || !key) throw fault('invalid_display_key'); if (!owner || typeof owner !== 'object' || typeof key !== 'string' || !key) throw fault('invalid_display_key');
@@ -55,6 +60,7 @@ export function createDisplayCache(options: { onError?: (error: unknown) => void
if (created.retired) throw fault('display_released'); if (created.retired) throw fault('display_released');
return load(created.controller.signal); return load(created.controller.signal);
}).then(source => { }).then(source => {
// 这里只检查 URL 为非空字符串及可选 release 为函数,不验证 URL 协议/可访问性或图片内容。
if (!source || typeof source.url !== 'string' || !source.url || source.release !== undefined && typeof source.release !== 'function') { if (typeof source?.release === 'function') dispose(source); throw fault('invalid_display_source'); } if (!source || typeof source.url !== 'string' || !source.url || source.release !== undefined && typeof source.release !== 'function') { if (typeof source?.release === 'function') dispose(source); throw fault('invalid_display_source'); }
// 即使 load 不响应 abort,迟到返回也只能 dispose,不允许挂回已无消费者的缓存。 // 即使 load 不响应 abort,迟到返回也只能 dispose,不允许挂回已无消费者的缓存。
if (created.retired || !created.leases.size) { dispose(source); throw fault('display_released'); } if (created.retired || !created.leases.size) { dispose(source); throw fault('display_released'); }
@@ -70,7 +76,8 @@ export function createDisplayCache(options: { onError?: (error: unknown) => void
if (!shared.leases.size) retire(shared); if (!shared.leases.size) retire(shared);
}; };
shared.leases.add(release); shared.leases.add(release);
// 将共享 promise 映射成每租约的独立 ready;来源失败时本租约也自动归还,不泄漏计数。 // 映射共享 promise 到各 lease.ready,失败逐个归还;校验 source 时抛错也沿这条失败路径清理。
// 缓存不吞 ready 的拒绝,消费者需接住取消/加载错误;释放后迟到成功不能再次 resolve 本租约。
const ready = new Promise<string>((resolve, reject) => { const ready = new Promise<string>((resolve, reject) => {
rejectReady = reject; rejectReady = reject;
shared.promise.then(source => { if (!released) resolve(source.url); }, error => { if (!released) { reject(error); release(); } }); shared.promise.then(source => { if (!released) resolve(source.url); }, error => { if (!released) { reject(error); release(); } });
+12 -3
View File
@@ -13,7 +13,10 @@ import { duplicateImportLine } from './import-duplicate.ts';
import type { ImportSource } from './imports.ts'; import type { ImportSource } from './imports.ts';
/** A seekable record cursor over the original SHA-bound snapshot, not a second mapped Blob. */ /** A seekable record cursor over the original SHA-bound snapshot, not a second mapped Blob. */
/** 要求未输出的emit起点、当前registry签名及有效绑定,ID工厂在恢复重校验中禁止意外再分配。 */ /** 要求 emit 起点/零输出计数与当前 registry 签名,调用 planner 复核 checkpoint 结构绑定且禁止再分配 ID。
* 映射复制为私有 Map,但 document/registry/state 及 readElement 仍沿用调用方引用,须保持不可变/稳定。
* sourceToken/fingerprint 的真实字节绑定由上层验证;本函数不读取文件或重新计算 SHA。
* 构造后可按 ordinal 任意重读,read 不推进 state.emitted;持久进度由 runner 的 job.cursor 保存。 */
export function createDuplicatePlanSource(document:Document,registry:Registry,state:DuplicateCheckpoint,fingerprint:string,readElement?:(id:Id,signal:AbortSignal)=>Promise<ElementRecord>):ImportSource{ export function createDuplicatePlanSource(document:Document,registry:Registry,state:DuplicateCheckpoint,fingerprint:string,readElement?:(id:Id,signal:AbortSignal)=>Promise<ElementRecord>):ImportSource{
if(state.phase!=='emit'||state.emitted!==0||state.emittedEdges!==0||state.schemaToken!==copyRegistrySignature(document,registry))throw fault('copy_interpreter_changed'); if(state.phase!=='emit'||state.emitted!==0||state.emittedEdges!==0||state.schemaToken!==copyRegistrySignature(document,registry))throw fault('copy_interpreter_changed');
// Structural/source binding checks; the decoded source has already passed document validation. // Structural/source binding checks; the decoded source has already passed document validation.
@@ -22,7 +25,9 @@ export function createDuplicatePlanSource(document:Document,registry:Registry,st
const elements=new Map(state.elementIds),canvases=new Map(state.canvasIds),copiedCanvases=new Set(state.canvases),keys=new Map<Id,string>(),reverseElements=new Map(state.elementIds.map(([id,mapped])=>[mapped,id])),reverseCanvases=new Map(state.canvasIds.map(([id,mapped])=>[mapped,id])); const elements=new Map(state.elementIds),canvases=new Map(state.canvasIds),copiedCanvases=new Set(state.canvases),keys=new Map<Id,string>(),reverseElements=new Map(state.elementIds.map(([id,mapped])=>[mapped,id])),reverseCanvases=new Map(state.canvasIds.map(([id,mapped])=>[mapped,id]));
for(const[id,mapped]of elements)keys.set(mapped,`element:${id}`);for(const[id,mapped]of canvases)keys.set(mapped,`canvas:${id}`); for(const[id,mapped]of elements)keys.set(mapped,`element:${id}`);for(const[id,mapped]of canvases)keys.set(mapped,`canvas:${id}`);
// Edge geometry needs mapped endpoint/owner records. Compute these on demand; retain a bounded cache. // Edge geometry needs mapped endpoint/owner records. Compute these on demand; retain a bounded cache.
/** 给边计算提供新端点/owner视图,get按需转换并只保留32条缓存,不批量生成所有副本对象。 */ /** 给边计算提供原表+映射表的只读投影,get 按需转换映射 ID,每个投影最多缓存 32 项。
* 淘汰按插入顺序,缓存命中不刷新顺序,因此是 FIFO 而非 LRU;不缓存 readElement 的完整 payload。
* size/迭代将两组成员拼接,依赖映射不与原 ID 碰撞的 planner 合同,不能当可发布的规范文档表。 */
function projection<T extends ElementRecord|CanvasRecord>(base:ReadonlyMap<Id,T>,reverse:ReadonlyMap<Id,Id>,kind:'element'|'canvas'):ReadonlyMap<Id,T>{ function projection<T extends ElementRecord|CanvasRecord>(base:ReadonlyMap<Id,T>,reverse:ReadonlyMap<Id,Id>,kind:'element'|'canvas'):ReadonlyMap<Id,T>{
// 未映射ID沿用原表,新ID转换源记录;缓存淘汰只影响计算成本,不改变稳定映射/源数据。 // 未映射ID沿用原表,新ID转换源记录;缓存淘汰只影响计算成本,不改变稳定映射/源数据。
const cache=new Map<Id,T>();const get=(id:Id):T|undefined=>{const old=reverse.get(id);if(!old)return base.get(id);const found=cache.get(id);if(found)return found;const original=base.get(old);if(!original)throw fault('invalid_copy_stream_checkpoint');const record=duplicateTreeChange(document,registry,kind,original,elements,canvases,copiedCanvases,state.offset).after as T;cache.set(id,record);if(cache.size>32)cache.delete(cache.keys().next().value!);return record;}; const cache=new Map<Id,T>();const get=(id:Id):T|undefined=>{const old=reverse.get(id);if(!old)return base.get(id);const found=cache.get(id);if(found)return found;const original=base.get(old);if(!original)throw fault('invalid_copy_stream_checkpoint');const record=duplicateTreeChange(document,registry,kind,original,elements,canvases,copiedCanvases,state.offset).after as T;cache.set(id,record);if(cache.size>32)cache.delete(cache.keys().next().value!);return record;};
@@ -32,7 +37,11 @@ export function createDuplicatePlanSource(document:Document,registry:Registry,st
} }
// projected只服务边端点和公共owner的坐标计算;length是tree order+edges的固定ordinal总数。 // projected只服务边端点和公共owner的坐标计算;length是tree order+edges的固定ordinal总数。
const projected:Document={...document,elements:projection(document.elements,reverseElements,'element'),canvases:projection(document.canvases,reverseCanvases,'canvas')},length=state.order.length+state.edges.length; const projected:Document={...document,elements:projection(document.elements,reverseElements,'element'),canvases:projection(document.canvases,reverseCanvases,'canvas')},length=state.order.length+state.edges.length;
/** read按ordinal选择共享tree/edge转换器;单记录错误返回error,源指纹/完整性与调度另由宿主约束。 */ /** 前 state.order.length 项用树转换器,其余用边转换器,生成同一预映射 CopyLine。
* readElement 可按需异步加载元素;其身份/源一致性由注入端口保证,canvas 直接从已捕获文档取。
* 预先取消/非法 ordinal 在 try 外拒绝;try 内失败(含读取后取消)转成 ImportLine.error,runner 必须复查信号。
* 成功检查单行 JSON 的 UTF-8 长度 ≤256KiB(不含 NDJSON 换行),不限制完整输出总字节或在单条内让出。
* 失败不会推进此源的全局游标;是否保留失败、跳过或停止由 runner 管理。 */
return Object.freeze({fingerprint,parserVersion:2,async read(cursor:number,signal:AbortSignal){ return Object.freeze({fingerprint,parserVersion:2,async read(cursor:number,signal:AbortSignal){
if(signal.aborted)throw fault('import_aborted');if(!Number.isSafeInteger(cursor)||cursor<0||cursor>length)throw fault('invalid_import_cursor');if(cursor===length)return null; if(signal.aborted)throw fault('import_aborted');if(!Number.isSafeInteger(cursor)||cursor<0||cursor>length)throw fault('invalid_import_cursor');if(cursor===length)return null;
const task=state.order[cursor],key=task?`${task.kind}:${task.id}`:`element:${state.edges[cursor-state.order.length]}`; const task=state.order[cursor],key=task?`${task.kind}:${task.id}`:`element:${state.edges[cursor-state.order.length]}`;
+17 -4
View File
@@ -23,7 +23,10 @@ export type DuplicateScheduling = {
* No document publication or network effects. The caller must persist source/mapping and * No document publication or network effects. The caller must persist source/mapping and
* revalidate external identities before applying a plan; this is not the import executor. * revalidate external identities before applying a plan; this is not the import executor.
*/ */
/** 分片消费完整plan迭代器,每片默认6ms/512步;返回前全部记录已生成并校验,但不执行导入。 */ /** 分片消费完整 plan 生成器,默认每片 6ms/512 个语义步,完成后返回全量计划而非逐条发布。
* checkpoint 仅接收累计步数,不含映射/生成器状态,不能用该数字恢复规划;最终返回前不保证再调用一次。
* 每次 next 本身可包含同步固定成本,软预算不能抢占它;now/yieldTask 决定实际调度,默认 timer 让出。
* 异常/取消关闭生成器,不回滚已调用 createId 或 checkpoint 的外部副作用;调用方负责可靠源/映射保存。 */
export async function planDuplicateAsync(document: Document, registry: Registry, selection: readonly Target[], options: DuplicateOptions, scheduling: DuplicateScheduling = {}): Promise<DuplicatePlan> { export async function planDuplicateAsync(document: Document, registry: Registry, selection: readonly Target[], options: DuplicateOptions, scheduling: DuplicateScheduling = {}): Promise<DuplicatePlan> {
const budget = scheduling.frameBudgetMs ?? 6, limit = scheduling.maxStepsPerSlice ?? 512; const budget = scheduling.frameBudgetMs ?? 6, limit = scheduling.maxStepsPerSlice ?? 512;
if (!Number.isFinite(budget) || budget <= 0 || !Number.isSafeInteger(limit) || limit < 1) throw fault('invalid_copy_budget'); if (!Number.isFinite(budget) || budget <= 0 || !Number.isSafeInteger(limit) || limit < 1) throw fault('invalid_copy_budget');
@@ -58,7 +61,11 @@ export async function planDuplicateAsync(document: Document, registry: Registry,
} }
/** Resume semantic planning cursors. Source/output validation and derived output caches are rebuilt. */ /** Resume semantic planning cursors. Source/output validation and derived output caches are rebuilt. */
/** 以完整checkpoint恢复语义游标,仍重做源验证/派生输出缓存,不声称从任意字节零重算。 */ /** 从完整 DuplicateCheckpoint 恢复语义游标/已分配 ID,源验证和派生输出缓存仍由 planner 重建。
* 每个未完成时间片可将不可变 checkpoint 交宿主 await;回调成功只代表宿主 Promise 成功,不独自证明落盘。
* planner.step 完成后直接 result,不额外回调最终状态;需要最终可靠切点时由调用方保存返回计划/状态。
* selection/偏移/映射在入口捕获,document/registry 沿不可变快照合同使用;调度选项应保持稳定。
* 与完整生成器入口一样,不发布业务文档、不补偿已分配 ID 或宿主 checkpoint 副作用。 */
export async function planDuplicateResumable(document:Document,registry:Registry,selection:readonly Target[],options:DuplicateOptions,binding:{sourceToken:string;schemaToken:string;checkpoint?:DuplicateCheckpoint},scheduling:Omit<DuplicateScheduling,'checkpoint'>&{checkpoint?:(state:DuplicateCheckpoint)=>Promise<void>}={}):Promise<DuplicatePlan>{ export async function planDuplicateResumable(document:Document,registry:Registry,selection:readonly Target[],options:DuplicateOptions,binding:{sourceToken:string;schemaToken:string;checkpoint?:DuplicateCheckpoint},scheduling:Omit<DuplicateScheduling,'checkpoint'>&{checkpoint?:(state:DuplicateCheckpoint)=>Promise<void>}={}):Promise<DuplicatePlan>{
const budget=scheduling.frameBudgetMs??6,limit=scheduling.maxStepsPerSlice??512,now=scheduling.now??(()=>performance.now()),yieldTask=scheduling.yieldTask??(()=>new Promise<void>(resolve=>setTimeout(resolve,0))); const budget=scheduling.frameBudgetMs??6,limit=scheduling.maxStepsPerSlice??512,now=scheduling.now??(()=>performance.now()),yieldTask=scheduling.yieldTask??(()=>new Promise<void>(resolve=>setTimeout(resolve,0)));
if(!Number.isFinite(budget)||budget<=0||!Number.isSafeInteger(limit)||limit<1)throw fault('invalid_copy_budget'); if(!Number.isFinite(budget)||budget<=0||!Number.isSafeInteger(limit)||limit<1)throw fault('invalid_copy_budget');
@@ -73,7 +80,12 @@ export async function planDuplicateResumable(document:Document,registry:Registry
/** Stop before record conversion: the stable order/mappings are sufficient for seekable lazy output. */ /** Stop before record conversion: the stable order/mappings are sufficient for seekable lazy output. */
/** 只推进到emit起点:范围/顺序/全部新ID已固定,但尚未转换全部payload,可交lazy source逐条输出。 */ /** 仅推进至 ready/emit 起点,返回顺序/映射已固定的 checkpoint;尚未完成所有 payload 转换与输出校验。
* planner 捕获小输入与私有语义 state;此层只调度 step/checkpoint,不用另一个 live 文档替换源。
* checkpointIntervalMs=0 默认每个未完成切片保存;>0 时按阶段/首次分配/时间/步数门槛节奏化保存。
* 阈值只在切片末检查,maxStepsPerCheckpoint 可能多跨一片;不是逐语义步的硬持久间隔。
* ready 时只有 interval>0 且有回调才补一次保存;默认路径可直接返回,宿主仍须持久化返回状态。
* topologyOnly 仅选择源拓扑检查,不证明全部组件 payload 可复制;懒源逐条转换时仍可报记录失败。 */
export async function prepareDuplicateStream(document:Document,registry:Registry,selection:readonly Target[],options:DuplicateOptions,binding:{sourceToken:string;schemaToken:string;checkpoint?:DuplicateCheckpoint;topologyOnly?:boolean},scheduling:Omit<DuplicateScheduling,'checkpoint'>&{checkpoint?:(state:DuplicateCheckpoint)=>Promise<void>;checkpointIntervalMs?:number;maxStepsPerCheckpoint?:number}={}):Promise<DuplicateCheckpoint>{ export async function prepareDuplicateStream(document:Document,registry:Registry,selection:readonly Target[],options:DuplicateOptions,binding:{sourceToken:string;schemaToken:string;checkpoint?:DuplicateCheckpoint;topologyOnly?:boolean},scheduling:Omit<DuplicateScheduling,'checkpoint'>&{checkpoint?:(state:DuplicateCheckpoint)=>Promise<void>;checkpointIntervalMs?:number;maxStepsPerCheckpoint?:number}={}):Promise<DuplicateCheckpoint>{
const budget=scheduling.frameBudgetMs??6,limit=scheduling.maxStepsPerSlice??512,now=scheduling.now??(()=>performance.now()),yieldTask=scheduling.yieldTask??(()=>new Promise<void>(resolve=>setTimeout(resolve,0))); const budget=scheduling.frameBudgetMs??6,limit=scheduling.maxStepsPerSlice??512,now=scheduling.now??(()=>performance.now()),yieldTask=scheduling.yieldTask??(()=>new Promise<void>(resolve=>setTimeout(resolve,0)));
if(!Number.isFinite(budget)||budget<=0||!Number.isSafeInteger(limit)||limit<1)throw fault('invalid_copy_budget'); if(!Number.isFinite(budget)||budget<=0||!Number.isSafeInteger(limit)||limit<1)throw fault('invalid_copy_budget');
@@ -82,7 +94,8 @@ export async function prepareDuplicateStream(document:Document,registry:Registry
const interval=scheduling.checkpointIntervalMs??0,maxCheckpointSteps=scheduling.maxStepsPerCheckpoint??8192; const interval=scheduling.checkpointIntervalMs??0,maxCheckpointSteps=scheduling.maxStepsPerCheckpoint??8192;
if(!Number.isFinite(interval)||interval<0||!Number.isSafeInteger(maxCheckpointSteps)||maxCheckpointSteps<1)throw fault('invalid_copy_budget'); if(!Number.isFinite(interval)||interval<0||!Number.isSafeInteger(maxCheckpointSteps)||maxCheckpointSteps<1)throw fault('invalid_copy_budget');
const planner=createDuplicatePlanner(document,registry,selection,options,binding); const planner=createDuplicatePlanner(document,registry,selection,options,binding);
// savedSteps等只记录上次已等待checkpoint回调成功的位置,保存耗时不冒充新增规划工作。 // saved* 是调度内存,不是另一份持久 checkpoint;首次 savedPhase 未知会在首片触发回调。
// savedAt 只在回调成功后重取,保存耗时不算新增规划工作,也不会单因慢存储强迫下一片立即再存。
let savedPhase:DuplicateCheckpoint['phase']|undefined,savedSteps=binding.checkpoint?.steps??0,savedAllocated=(binding.checkpoint?.elementIds.length??0)+(binding.checkpoint?.canvasIds.length??0),savedAt=now(); let savedPhase:DuplicateCheckpoint['phase']|undefined,savedSteps=binding.checkpoint?.steps??0,savedAllocated=(binding.checkpoint?.elementIds.length??0)+(binding.checkpoint?.canvasIds.length??0),savedAt=now();
while(true){ while(true){
const start=now();let count=0; const start=now();let count=0;
+15 -6
View File
@@ -13,7 +13,8 @@ export type ElisionProof = { readonly version: 1; readonly operationIds: readonl
/** Reconstruct a closed lifetime from an empty fragment. Existing objects cannot enter this proof. */ /** Reconstruct a closed lifetime from an empty fragment. Existing objects cannot enter this proof. */
/** 在空记录片段上重建至多 64 意图/10000 changes,验证每个对象首触及都是创建且最终全删。 /** 在空记录片段上重建至多 64 意图/10000 changes,验证每个对象首触及都是创建且最终全删。
* 墓碑变化是允许保留的本地效果,不等于服务器执行过这些创建/删除。 */ * 本函数不读 ledger 阶段、basePresence、请求或依赖,不独自证明这些操作从未发送/实际基线 absent。
* 只做条件变化与生命周期闭合检查,不调用 registry/整图校验;临时片段可无根,最终只保留本地墓碑。 */
export function proveElision(intents: readonly OperationIntent[], baseTombstones: Readonly<Record<Id, number>>): ElisionProof { export function proveElision(intents: readonly OperationIntent[], baseTombstones: Readonly<Record<Id, number>>): ElisionProof {
if (!intents.length || intents.length > 64 || new Set(intents.map(intent => intent.id)).size !== intents.length) throw fault('invalid_elision'); if (!intents.length || intents.length > 64 || new Set(intents.map(intent => intent.id)).size !== intents.length) throw fault('invalid_elision');
const created = new Set<Id>(), canvases = new Set<Id>(); const created = new Set<Id>(), canvases = new Set<Id>();
@@ -37,6 +38,7 @@ export function proveElision(intents: readonly OperationIntent[], baseTombstones
} }
} }
} }
// 起始墓碑只能描述本段确实创建过的元素,不能趁抵消夹带无关 ID 的生命周期水位。
if (Object.keys(baseTombstones).some(id => !created.has(id))) throw fault('invalid_elision'); if (Object.keys(baseTombstones).some(id => !created.has(id))) throw fault('invalid_elision');
const fragment: Document = { projectId: '', elements: new Map(), canvases: new Map(), tombstones: new Map(Object.entries(baseTombstones)) }; const fragment: Document = { projectId: '', elements: new Map(), canvases: new Map(), tombstones: new Map(Object.entries(baseTombstones)) };
// 条件执行仍使用真实 applyChanges;before 不匹配不能因最终想删除就被忽略。 // 条件执行仍使用真实 applyChanges;before 不匹配不能因最终想删除就被忽略。
@@ -46,10 +48,12 @@ export function proveElision(intents: readonly OperationIntent[], baseTombstones
} }
/** Longest closed prefix before an existing-object write or another unsafe precondition. */ /** Longest closed prefix before an existing-object write or another unsafe precondition. */
/** 寻找最长安全闭合前缀;遇 frozen/blocker/外部未解依赖/已有对象首写即停止扩展。 /** 在传入候选顺序内寻找最长闭合前缀,检查 pending/未绑请求、基线和依赖;不扫描全账本证明中间无漏项。
* preceding 只允许前面已选独立真实段作为依赖屏障,不把它当作已经 applied 的回执。 */ * 全账本连续性还由 ledger.elide/validateElisions 复核;preceding 只给批次边界规划暂时满足前段依赖。
* undefined 表示没找到;条件回放失败或闭合证明非法仍会抛错,不统一吞成 undefined。 */
export function findElision(intents: readonly OperationIntent[], acknowledged: Document, ledger: LedgerSnapshot, preceding: ReadonlySet<Id> = new Set()): ElisionProof | undefined { export function findElision(intents: readonly OperationIntent[], acknowledged: Document, ledger: LedgerSnapshot, preceding: ReadonlySet<Id> = new Set()): ElisionProof | undefined {
const selected: OperationIntent[] = [], seen = new Set<Id>(), canvases = new Set<Id>(), bases: Record<Id, number> = Object.create(null); const selected: OperationIntent[] = [], seen = new Set<Id>(), canvases = new Set<Id>(), bases: Record<Id, number> = Object.create(null);
// 候选超过 64 直接不寻找,不自动截取前 64;真正闭合后的 changes 上限由 proveElision 检查。
if (intents.length > 64 || !intents.some(intent => intent.changes.some(change => change.kind !== 'field' && change.after === null))) return undefined; if (intents.length > 64 || !intents.some(intent => intent.changes.some(change => change.kind !== 'field' && change.after === null))) return undefined;
// 每次片段归空时保存最佳前缀;后续又创建未删对象,不影响之前已找到的闭合边界。 // 每次片段归空时保存最佳前缀;后续又创建未删对象,不影响之前已找到的闭合边界。
let bestLength = 0, bestBases: Record<Id, number> = {}; let bestLength = 0, bestBases: Record<Id, number> = {};
@@ -57,6 +61,7 @@ export function findElision(intents: readonly OperationIntent[], acknowledged: D
for (const intent of intents) { for (const intent of intents) {
const record = ledger.operations.get(intent.id); const record = ledger.operations.get(intent.id);
if (!record || record.phase !== 'pending' || record.requestId || record.blockers.length || !equal(record.intent, intent) || intent.operationType !== 'document.change' || intent.operationSchemaVersion !== 1) break; if (!record || record.phase !== 'pending' || record.requestId || record.blockers.length || !equal(record.intent, intent) || intent.operationType !== 'document.change' || intent.operationSchemaVersion !== 1) break;
// resolved 只表示依赖已被本地解除,不在此区分 applied/elided/retired 的业务理由;preceding 不是远端执行证明。
if (intent.dependencies.some(id => !selected.some(intent => intent.id === id) && !preceding.has(id) && ledger.operations.get(id)?.phase !== 'resolved')) break; if (intent.dependencies.some(id => !selected.some(intent => intent.id === id) && !preceding.has(id) && ledger.operations.get(id)?.phase !== 'resolved')) break;
let safe = true; let safe = true;
for (const change of intent.changes) { for (const change of intent.changes) {
@@ -78,7 +83,8 @@ export function findElision(intents: readonly OperationIntent[], acknowledged: D
} }
/** Keep a later closed segment mutable while earlier real edits obtain their own receipt. */ /** Keep a later closed segment mutable while earlier real edits obtain their own receipt. */
/** 若后面存在可抵消段,限制前面的真实请求前缀,避免一次冻结把可抵消段也锁死。 */ /** 若后面存在可抵消候选段,缩短前面的真实请求前缀,避免本次 freeze 锁住后段。
* 这里只返回前缀长度,不标 ledger.elided、不证明前段已成功;原前缀可直接抵消时交其它钩子处理。 */
export function elisionBatchLimit(intents: readonly OperationIntent[], acknowledged: Document, ledger: LedgerSnapshot): number { export function elisionBatchLimit(intents: readonly OperationIntent[], acknowledged: Document, ledger: LedgerSnapshot): number {
if (!intents.some(intent => intent.changes.some(change => change.kind !== 'field' && change.after === null)) || findElision(intents, acknowledged, ledger)) return intents.length; if (!intents.some(intent => intent.changes.some(change => change.kind !== 'field' && change.after === null)) || findElision(intents, acknowledged, ledger)) return intents.length;
const preceding = new Set<Id>(); const preceding = new Set<Id>();
@@ -99,7 +105,8 @@ export function assertElisionLineage(proof: ElisionProof, intents: readonly Oper
/** Validate persisted local facts independently of the current (possibly newer) server baseline. */ /** Validate persisted local facts independently of the current (possibly newer) server baseline. */
/** 恢复时按逻辑顺序复验持久抵消组:成员连续、依赖闭合、anchor 唯一、证明与水位一致。 /** 恢复时按逻辑顺序复验持久抵消组:成员连续、依赖闭合、anchor 唯一、证明与水位一致。
* 不依赖当前可能更新过的 ACK 基线来重新猜历史 absent 事实。 */ * 不依赖当前可能更新过的 ACK 基线来重新猜历史 absent 事实,而是复核保存的 basePresence/proof。
* 它不向服务器查询当年是否接收过请求;此前的持久记录捕获与身份校验仍由恢复边界负责。 */
export function validateElisions(ledger: Pick<LedgerSnapshot, 'operations'>): void { export function validateElisions(ledger: Pick<LedgerSnapshot, 'operations'>): void {
const ordered = [...ledger.operations.values()].sort((a, b) => a.intent.sequence - b.intent.sequence), positions = new Map(ordered.map((record, index) => [record.intent.id, index])); const ordered = [...ledger.operations.values()].sort((a, b) => a.intent.sequence - b.intent.sequence), positions = new Map(ordered.map((record, index) => [record.intent.id, index]));
const previous = new Map<Id, number>(); const previous = new Map<Id, number>();
@@ -128,7 +135,9 @@ export function validateElisions(ledger: Pick<LedgerSnapshot, 'operations'>): vo
} }
/** Local high-water marks belong to working state, never to the server ACK document. */ /** Local high-water marks belong to working state, never to the server ACK document. */
/** 只给 working 候选补本地墓碑最大值,不修改源文档,也不向服务器 ACK 事实表灌入本地证明。 */ /** 只为候选复制墓碑表并补本地最大值,其余文档表沿用;不改传入文档。
* 调用方只能将结果当 working,不应覆写 acknowledged;这里不自动区分两种变量的用途。
* 直接读取保存的 elision 字段,不在此复验证据;ledger 应先经可信生成或 validateElisions。 */
export function withLocalLifecycles(document: Document, ledger: Pick<LedgerSnapshot, 'operations'>): Document { export function withLocalLifecycles(document: Document, ledger: Pick<LedgerSnapshot, 'operations'>): Document {
const tombstones = new Map(document.tombstones); const tombstones = new Map(document.tombstones);
for (const record of ledger.operations.values()) if (record.elision) for (const [id, incarnation] of Object.entries(record.elision.tombstones)) tombstones.set(id, Math.max(tombstones.get(id) ?? 0, incarnation)); for (const record of ledger.operations.values()) if (record.elision) for (const [id, incarnation] of Object.entries(record.elision.tombstones)) tombstones.set(id, Math.max(tombstones.get(id) ?? 0, incarnation));
+6 -2
View File
@@ -11,13 +11,16 @@ export type HistoryReplayEvent={readonly kind:'operation';readonly operationId:I
/** version=1 的 anchor+有序事件;所有 transition 绑定同一次 adoption Root revision。 */ /** version=1 的 anchor+有序事件;所有 transition 绑定同一次 adoption Root revision。 */
export type HistoryReplay={readonly version:1;readonly anchor:HistoryTransition;readonly events:readonly HistoryReplayEvent[]}; export type HistoryReplay={readonly version:1;readonly anchor:HistoryTransition;readonly events:readonly HistoryReplayEvent[]};
/** Local evidence only. The anchor and event deltas explain one atomic adoption, not new service writes. */ /** Local evidence only. The anchor and event deltas explain one atomic adoption, not new service writes. */
/** 检查证据版本/JSON、anchor无entry、事件 revision 一致和 operation ID 唯一;未知版本拒绝解释。 */ /** 检查证据版本/JSON、anchor 无 entry、事件 revision 一致及 operation ID 唯一;未知版本拒绝解释。
* revision 由调用方提供;不查询真实发布日志,checkChanges 缺省时不完整解释各条领域 Change。 */
export function validateHistoryReplay(value:unknown,revision:number,checkChanges?:(value:unknown)=>unknown):asserts value is HistoryReplay{ export function validateHistoryReplay(value:unknown,revision:number,checkChanges?:(value:unknown)=>unknown):asserts value is HistoryReplay{
if(!value||typeof value!=='object'||Array.isArray(value))throw fault('invalid_history_replay');const raw=value as Record<string,unknown>; if(!value||typeof value!=='object'||Array.isArray(value))throw fault('invalid_history_replay');const raw=value as Record<string,unknown>;
if(raw.version!==1)throw fault('history_replay_interpreter_unavailable');if(!validJson(value))throw fault('invalid_history_replay');validateHistoryTransition(raw.anchor,checkChanges);if(raw.anchor.rootRevision!==revision||raw.anchor.entryId!==undefined||!Array.isArray(raw.events))throw fault('invalid_history_replay');const ids=new Set<Id>(); if(raw.version!==1)throw fault('history_replay_interpreter_unavailable');if(!validJson(value))throw fault('invalid_history_replay');validateHistoryTransition(raw.anchor,checkChanges);if(raw.anchor.rootRevision!==revision||raw.anchor.entryId!==undefined||!Array.isArray(raw.events))throw fault('invalid_history_replay');const ids=new Set<Id>();
for(const value of raw.events){if(!value||typeof value!=='object'||Array.isArray(value))throw fault('invalid_history_replay');const event=value as Record<string,unknown>;validateHistoryTransition(event.transition,checkChanges);if(event.transition.rootRevision!==revision)throw fault('invalid_history_replay');if(event.kind==='operation'){if(typeof event.operationId!=='string'||!event.operationId||ids.has(event.operationId))throw fault('invalid_history_replay');ids.add(event.operationId);}else if(event.kind!=='adjustment'||event.operationId!==undefined)throw fault('invalid_history_replay');} for(const value of raw.events){if(!value||typeof value!=='object'||Array.isArray(value))throw fault('invalid_history_replay');const event=value as Record<string,unknown>;validateHistoryTransition(event.transition,checkChanges);if(event.transition.rootRevision!==revision)throw fault('invalid_history_replay');if(event.kind==='operation'){if(typeof event.operationId!=='string'||!event.operationId||ids.has(event.operationId))throw fault('invalid_history_replay');ids.add(event.operationId);}else if(event.kind!=='adjustment'||event.operationId!==undefined)throw fault('invalid_history_replay');}
} }
/** 纯条件回放 anchor/events;可选校验逻辑 ID 存在,带 entry 的事件必须在该项关联同 operation。 */ /** 条件回放历史元数据:先正向 anchor,再按 events 顺序更新两栈/entries,不执行文档 changes 或网络。
* 用 anchor 自身 revision 调结构校验,不独自核对外部 adoption 的 Root;调用方另需绑定那次发布。
* hasOperation 可选检查 ID 存在,带 entry 的操作事件需关联同 ID;不在此比较逻辑意图内容与 transition 是否语义等价。 */
export function replayHistoryEvidence(before:HistoryState,replay:HistoryReplay,hasOperation?:(id:Id)=>boolean):HistoryState{ export function replayHistoryEvidence(before:HistoryState,replay:HistoryReplay,hasOperation?:(id:Id)=>boolean):HistoryState{
validateHistoryReplay(replay,replay.anchor.rootRevision);let history=applyHistoryTransition(before,replay.anchor,'forward'); validateHistoryReplay(replay,replay.anchor.rootRevision);let history=applyHistoryTransition(before,replay.anchor,'forward');
for(const event of replay.events){if(event.kind==='operation'&&hasOperation&&!hasOperation(event.operationId))throw fault('history_replay_operation_missing',[event.operationId]);history=applyHistoryTransition(history,event.transition,'forward');if(event.kind==='operation'&&event.transition.entryId&&!history.entries.get(event.transition.entryId)?.operationIds.includes(event.operationId))throw fault('history_replay_operation_mismatch',[event.operationId]);} for(const event of replay.events){if(event.kind==='operation'&&hasOperation&&!hasOperation(event.operationId))throw fault('history_replay_operation_missing',[event.operationId]);history=applyHistoryTransition(history,event.transition,'forward');if(event.kind==='operation'&&event.transition.entryId&&!history.entries.get(event.transition.entryId)?.operationIds.includes(event.operationId))throw fault('history_replay_operation_mismatch',[event.operationId]);}
@@ -25,5 +28,6 @@ export function replayHistoryEvidence(before:HistoryState,replay:HistoryReplay,h
} }
/** 比较两栈和全部 entry;entry 表按 ID 排序,避免 Map 插入顺序差异被误认作历史不一致。 */ /** 比较两栈和全部 entry;entry 表按 ID 排序,避免 Map 插入顺序差异被误认作历史不一致。 */
export function sameHistory(left:HistoryState,right:HistoryState):boolean{ export function sameHistory(left:HistoryState,right:HistoryState):boolean{
// 仅规范化 entries 表的顺序;undo/redo 数组顺序仍按栈语义精确比较,不能排序后当集合。
const value=(history:HistoryState)=>({undo:history.undo,redo:history.redo,entries:[...history.entries].sort(([a],[b])=>a.localeCompare(b))});return equal(value(left),value(right)); const value=(history:HistoryState)=>({undo:history.undo,redo:history.redo,entries:[...history.entries].sort(([a],[b])=>a.localeCompare(b))});return equal(value(left),value(right));
} }
+16 -6
View File
@@ -11,7 +11,10 @@ import { createScopedLoader, type LoadPort, type ScopedLoader } from './loader.t
import type { CanonicalReadUpdate } from './contracts.ts'; import type { CanonicalReadUpdate } from './contracts.ts';
/** Host lifetime matches the signed-in application, independently of navigation. */ /** Host lifetime matches the signed-in application, independently of navigation. */
/** 宿主注入 workspace/loader 工厂;current 闭包绑定精确账号与 epoch,作为异步结果准入围栏。 */ /** 一个应用级宿主:workspaces/readers 按当前账号下的 workspaceId 复用,retired 单独保留待关闭实例。
* registry 由注入方按不可变定义合同共享,本函数不冻结或复制它;也不保存 token/密码等认证凭证。
* current 闭包绑定账号+authEpoch,拒绝旧身份结果;业务/存储实际副作用仍需 adapters 自行遵守该围栏。
* 后台退休关闭可与新身份运行并行;Host 不为 closeRetired 建单飞锁,workspace.close 应支持重复/并发调用。 */
export function createRuntimeHost(options: { registry: Registry; workspace(accountId: Id, workspaceId: Id, authEpoch: number, current: () => boolean): WorkspaceRuntime; modelLoader?(accountId:Id,workspaceId:Id,authEpoch:number,current:()=>boolean):LoadPort<CanonicalReadUpdate>; onError?: (error: unknown) => void; onObserverError?: (error: unknown) => void }) { export function createRuntimeHost(options: { registry: Registry; workspace(accountId: Id, workspaceId: Id, authEpoch: number, current: () => boolean): WorkspaceRuntime; modelLoader?(accountId:Id,workspaceId:Id,authEpoch:number,current:()=>boolean):LoadPort<CanonicalReadUpdate>; onError?: (error: unknown) => void; onObserverError?: (error: unknown) => void }) {
let accountId: Id | null = null, authEpoch = 0, disposed = false; let accountId: Id | null = null, authEpoch = 0, disposed = false;
// active 表只含当前身份,retired 持有尚未成功关闭的旧 runtime,不能因换账号就丢掉待保存内容。 // active 表只含当前身份,retired 持有尚未成功关闭的旧 runtime,不能因换账号就丢掉待保存内容。
@@ -25,7 +28,8 @@ export function createRuntimeHost(options: { registry: Registry; workspace(accou
}; };
/** 同步撤销当前身份:先换 epoch,再关闭读取句柄/移走 workspace;实际存储收尾另行等待。 */ /** 同步撤销当前身份:先换 epoch,再关闭读取句柄/移走 workspace;实际存储收尾另行等待。 */
function retire() { authEpoch++; accountId = null; for (const reader of readers.values()) reader.close();readers.clear();for (const runtime of workspaces.values()) retired.add(runtime); workspaces.clear(); } function retire() { authEpoch++; accountId = null; for (const reader of readers.values()) reader.close();readers.clear();for (const runtime of workspaces.values()) retired.add(runtime); workspaces.clear(); }
/** 账号变化才退休旧作用域;后台关闭错误走 onError,报告失败不能改变新账号的身份事实。 */ /** 相同账号复用 epoch,换账号先 retire 再同步启用新账号,不等待旧 workspace 关闭。
* 旧关闭失败通过 onError 报告且保留 retired,后续身份退休/close 可重试;异常报告也被隔离。 */
function activate(account: Id) { function activate(account: Id) {
if (disposed) throw fault('host_disposed'); if (disposed) throw fault('host_disposed');
if (accountId !== account) { if (accountId !== account) {
@@ -42,7 +46,10 @@ export function createRuntimeHost(options: { registry: Registry; workspace(accou
identity: () => Object.freeze({ accountId, authEpoch }), identity: () => Object.freeze({ accountId, authEpoch }),
/** 返回当前 workspace 表的安全只读快照,外部不能手动增删运行实例。 */ /** 返回当前 workspace 表的安全只读快照,外部不能手动增删运行实例。 */
getWorkspaces: () => readMap(workspaces), getWorkspaces: () => readMap(workspaces),
/** 通过共享 scoped loader 读 canonical 源;增量可产生只读预览,terminal 完整后才返回最终模型。 */ /** 只读加载不创建 workspace engine/storage;每 workspace 复用 scoped loader、每订阅维护独立 preview/seen。
* 导航代次属于订阅者,授权 epoch 才参与共享身份;已完成读取不作长期缓存(maxCacheEntries=0)。
* ready 后核对身份与终结完整标志,再返回 ready 模型或其它终态;不是在 Host 再做全量 Document/schema 校验。
* preview/observer 出错只停止此订阅预览并上报 onObserverError,不取消其它订阅或替换共享读取事实。 */
async loadModel(account:Id,workspace:Id,canvas:Id,settings:{signal?:AbortSignal;navigationGeneration?:number;onPreview?:(preview:LiveModelPreview)=>void}={}) { async loadModel(account:Id,workspace:Id,canvas:Id,settings:{signal?:AbortSignal;navigationGeneration?:number;onPreview?:(preview:LiveModelPreview)=>void}={}) {
activate(account); activate(account);
const readEpoch=authEpoch; const readEpoch=authEpoch;
@@ -60,7 +67,8 @@ export function createRuntimeHost(options: { registry: Registry; workspace(accou
}; };
// key 纳入账号、workspace、格式、查询与 authEpoch;view 导航代次不替代授权代次。 // key 纳入账号、workspace、格式、查询与 authEpoch;view 导航代次不替代授权代次。
const handle=reader.acquire({accountId:account,workspaceId:workspace,projectId:null,canvasId:canvas,viewKey:'canonical-project',format:'canonical-model-v1',queryFingerprint:'full-project',source:'foundry',authEpoch,navigationGeneration:settings.navigationGeneration??0},{signal:settings.signal,onUpdate}); const handle=reader.acquire({accountId:account,workspaceId:workspace,projectId:null,canvasId:canvas,viewKey:'canonical-project',format:'canonical-model-v1',queryFingerprint:'full-project',source:'foundry',authEpoch,navigationGeneration:settings.navigationGeneration??0},{signal:settings.signal,onUpdate});
// ready 后再查身份和 terminal/complete;finally 始终释放本订阅,不能泄漏 reader 引用。 // handle 成功取得后,ready 成功/拒绝都在 finally 释放订阅并 close preview;不关闭共享 reader/其它订阅。
// 端口返回模型的领域合法性仍由 canonical adapter/预览解释器和后续 engine 准入负责。
try{const snapshot=await handle.ready;if(disposed||accountId!==account||authEpoch!==readEpoch)throw fault('identity_changed');const last=snapshot.chunks.at(-1);if(!last||!last.complete||last.value.status==='receiving')throw fault('incomplete_load');return last.value.status==='ready'?Object.freeze({...last.value,sourceToken:last.snapshotToken}):last.value;}finally{handle.release();preview?.close();} try{const snapshot=await handle.ready;if(disposed||accountId!==account||authEpoch!==readEpoch)throw fault('identity_changed');const last=snapshot.chunks.at(-1);if(!last||!last.complete||last.value.status==='receiving')throw fault('incomplete_load');return last.value.status==='ready'?Object.freeze({...last.value,sourceToken:last.snapshotToken}):last.value;}finally{handle.release();preview?.close();}
}, },
/** 按当前账号激活并懒创建 workspace;导航到同工作区不会重新创建资源池或项目。 */ /** 按当前账号激活并懒创建 workspace;导航到同工作区不会重新创建资源池或项目。 */
@@ -74,9 +82,11 @@ export function createRuntimeHost(options: { registry: Registry; workspace(accou
} }
return runtime; return runtime;
}, },
/** 退出身份但保留 host 可再次绑定;返回旧 runtime 收尾 Promise,让宿主处理未保存错误。 */ /** 同步清账号并递增 epoch,关闭读句柄,再等待退休 runtime;不永久 disposed,之后可重新绑定。
* 不等待 close 成功也已使旧 current 失效;失败不会恢复旧身份或删除退休实例。 */
retireIdentity() { retire(); return closeRetired(); }, retireIdentity() { retire(); return closeRetired(); },
/** 永久关闭 host 并退休全部作用域;失败仍可再次调用 close 重试退休集合。 */ /** 永久 disposed 后再退休/等待关闭,后续 workspace/bind/load 准入拒绝。
* 失败可再次 close 重试;每次都会再次 retire/推进 epoch,返回值不是共享的单飞关闭 Promise。 */
close() { disposed = true; retire(); return closeRetired(); }, close() { disposed = true; retire(); return closeRetired(); },
}); });
} }
+12 -4
View File
@@ -15,17 +15,25 @@ export type ImageDecoderPort={decode(bytes:Blob,signal?:AbortSignal):Promise<Ima
/** 这里只验证正安全整数和乘积可安全表示,不等同于已应用产品的像素预算。 */ /** 这里只验证正安全整数和乘积可安全表示,不等同于已应用产品的像素预算。 */
export function validImageDimensions(value:unknown):value is ImageDimensions{const size=value as ImageDimensions;return !!size&&Number.isSafeInteger(size.width)&&size.width>0&&Number.isSafeInteger(size.height)&&size.height>0&&Number.isSafeInteger(size.width*size.height);} export function validImageDimensions(value:unknown):value is ImageDimensions{const size=value as ImageDimensions;return !!size&&Number.isSafeInteger(size.width)&&size.width>0&&Number.isSafeInteger(size.height)&&size.height>0&&Number.isSafeInteger(size.width*size.height);}
/** Bounded read-only compute. Cancellation rejects the caller; a slot remains occupied until execution settles. */ /** Bounded read-only compute. Cancellation rejects the caller; a slot remains occupied until execution settles. */
/** 默认2并发/64等待,最多8并发;取消结果与执行容量分开,防止无视 abort 的任务挤爆池。 */ /** 默认 2 并发/64 等待,最多 8 并发;waiting/active 各用按插入顺序的 Map,不是持久任务队列。
* maxQueued 只限制尚未进入 active 的任务;active 包括已取消但执行器仍未退出的任务。
* 池只检查 Blob 的 image/ MIME 前缀和尺寸数值,不限制文件字节/实际像素预算,也不缓存解码结果。 */
export function createImageDecodePool(options:{execute:(bytes:Blob,signal:AbortSignal)=>Promise<ImageDimensions>;concurrency?:number;maxQueued?:number}):ImageDecoderPort{ export function createImageDecodePool(options:{execute:(bytes:Blob,signal:AbortSignal)=>Promise<ImageDimensions>;concurrency?:number;maxQueued?:number}):ImageDecoderPort{
const concurrency=options.concurrency??2,maxQueued=options.maxQueued??64; const concurrency=options.concurrency??2,maxQueued=options.maxQueued??64;
if(!Number.isSafeInteger(concurrency)||concurrency<1||concurrency>8||!Number.isSafeInteger(maxQueued)||maxQueued<1)throw fault('invalid_image_decode_budget'); if(!Number.isSafeInteger(concurrency)||concurrency<1||concurrency>8||!Number.isSafeInteger(maxQueued)||maxQueued<1)throw fault('invalid_image_decode_budget');
/** 每个解码请求独立控制器/完成标记;cleanup 解除外部取消监听,迟到执行结果不再采纳。 */ /** 每个解码请求独立控制器/完成标记;cleanup 解除外部取消监听,迟到执行结果不再采纳。 */
type Task={id:number;bytes:Blob;controller:AbortController;resolve:(size:ImageDimensions)=>void;reject:(error:unknown)=>void;done:boolean;cleanup():void}; type Task={id:number;bytes:Blob;controller:AbortController;resolve:(size:ImageDimensions)=>void;reject:(error:unknown)=>void;done:boolean;cleanup():void};
const waiting=new Map<number,Task>(),active=new Map<number,Task>();let next=0,closed=false; const waiting=new Map<number,Task>(),active=new Map<number,Task>();let next=0,closed=false;
/** 幂等 settle 调用者,成功捕获冻结尺寸;不提前归还仍在执行的 active 槽。 */ /** 幂等 settle 调用者并清理等待项;成功浅复制/冻结执行器结果,扩展属性不会被过滤。
* error 用真值区分成败,执行器应以 Error 等真值拒绝;falsy rejection 不保证按失败解释。
* 不删除 active,取消后的名额仍要等 execute 链的 finally 才归还。 */
function finish(task:Task,error?:unknown,size?:ImageDimensions){if(task.done)return;task.done=true;task.cleanup();waiting.delete(task.id);if(error)task.reject(error);else task.resolve(freeze({...size!}));} function finish(task:Task,error?:unknown,size?:ImageDimensions){if(task.done)return;task.done=true;task.cleanup();waiting.delete(task.id);if(error)task.reject(error);else task.resolve(freeze({...size!}));}
/** FIFO 取任务并在微任务执行,校验返回尺寸,最后 finally 删除 active 再继续泵;异常不毒化后续队列。 */ /** 取 waiting 首项立即占 active,再在微任务调用 execute;启动前复查内部 abort。
* 成功尺寸先校验再 finish;正常错误交 finish,finally 负责释放 active 并继续 FIFO。
* execute 若不响应取消且永不 settle,名额会持续占用;池没有超时或强制终止通用执行器的能力。 */
function pump(){while(!closed&&active.size<concurrency&&waiting.size){const task=waiting.values().next().value!;waiting.delete(task.id);active.set(task.id,task);void Promise.resolve().then(()=>{if(task.controller.signal.aborted)throw fault('image_decode_aborted');return options.execute(task.bytes,task.controller.signal);}).then(size=>{if(!validImageDimensions(size))throw fault('invalid_image_dimensions');finish(task,undefined,size);}).catch(error=>finish(task,error)).finally(()=>{active.delete(task.id);pump();});}} function pump(){while(!closed&&active.size<concurrency&&waiting.size){const task=waiting.values().next().value!;waiting.delete(task.id);active.set(task.id,task);void Promise.resolve().then(()=>{if(task.controller.signal.aborted)throw fault('image_decode_aborted');return options.execute(task.bytes,task.controller.signal);}).then(size=>{if(!validImageDimensions(size))throw fault('invalid_image_dimensions');finish(task,undefined,size);}).catch(error=>finish(task,error)).finally(()=>{active.delete(task.id);pump();});}}
/** decode 校验图片 MIME/容量并建立任务;close 取消所有调用结果,active 实际结束仍由 finally 收尾。 */ /** decode 拒绝关闭/预先取消/非图片 Blob/等待容量已满,随后创建任务、绑定外部 abort 并入 waiting。
* abort 转发给内部 controller,立即拒绝调用者并移除 waiting;运行中的 active 留待 finally。
* close 幂等取消全部调用结果并禁止继续泵,不返回 drain Promise,也不证明所有 execute 已停止。 */
return Object.freeze({decode(bytes:Blob,signal?:AbortSignal){if(closed)return Promise.reject(fault('image_decoder_closed'));if(signal?.aborted)return Promise.reject(fault('image_decode_aborted'));if(!(bytes instanceof Blob)||!bytes.type.startsWith('image/'))return Promise.reject(fault('invalid_image_input'));if(active.size>=concurrency&&waiting.size>=maxQueued)return Promise.reject(fault('image_decode_capacity'));return new Promise<ImageDimensions>((resolve,reject)=>{const abort=()=>{task.controller.abort();finish(task,fault('image_decode_aborted'));pump();},task:Task={id:++next,bytes,controller:new AbortController(),resolve,reject,done:false,cleanup:()=>signal?.removeEventListener('abort',abort)};signal?.addEventListener('abort',abort,{once:true});waiting.set(task.id,task);pump();});},close(){if(closed)return;closed=true;for(const task of [...waiting.values(),...active.values()]){task.controller.abort();finish(task,fault('image_decode_aborted'));}}}); return Object.freeze({decode(bytes:Blob,signal?:AbortSignal){if(closed)return Promise.reject(fault('image_decoder_closed'));if(signal?.aborted)return Promise.reject(fault('image_decode_aborted'));if(!(bytes instanceof Blob)||!bytes.type.startsWith('image/'))return Promise.reject(fault('invalid_image_input'));if(active.size>=concurrency&&waiting.size>=maxQueued)return Promise.reject(fault('image_decode_capacity'));return new Promise<ImageDimensions>((resolve,reject)=>{const abort=()=>{task.controller.abort();finish(task,fault('image_decode_aborted'));pump();},task:Task={id:++next,bytes,controller:new AbortController(),resolve,reject,done:false,cleanup:()=>signal?.removeEventListener('abort',abort)};signal?.addEventListener('abort',abort,{once:true});waiting.set(task.id,task);pump();});},close(){if(closed)return;closed=true;for(const task of [...waiting.values(),...active.values()]){task.controller.abort();finish(task,fault('image_decode_aborted'));}}});
} }
+4 -1
View File
@@ -4,11 +4,14 @@
*/ */
/** 仅声明本入口需要的 Worker 消息能力,不导入 renderer 或 DOM 状态。 */ /** 仅声明本入口需要的 Worker 消息能力,不导入 renderer 或 DOM 状态。 */
const worker=globalThis as unknown as{onmessage:((event:MessageEvent)=>void)|null;postMessage(value:unknown):void}; const worker=globalThis as unknown as{onmessage:((event:MessageEvent)=>void)|null;postMessage(value:unknown):void};
/** 接受正确版本/id/Blob,按 from-image 选项解码;失败回稳定 image_decode_failed。 */ /** 仅接收 version=1、安全整数 id、Blob;非法请求直接忽略,不回错误消息。
* 不在此限制 id 为正数、MIME、字节或像素预算;单任务 Worker 的创建/并发/取消由宿主控制。
* 异步 handler 本身不排队,重复消息可并行解码;正常宿主每 Worker 只发一个请求。 */
worker.onmessage=async event=>{ worker.onmessage=async event=>{
const {version,id,bytes}=event.data??{};if(version!==1||!Number.isSafeInteger(id)||!(bytes instanceof Blob))return; const {version,id,bytes}=event.data??{};if(version!==1||!Number.isSafeInteger(id)||!(bytes instanceof Blob))return;
// bitmap 是临时原生资源,尺寸读取即使抛错也由 finally close;成功只发送 width/height 和输入长度。 // bitmap 是临时原生资源,尺寸读取即使抛错也由 finally close;成功只发送 width/height 和输入长度。
try{const bitmap=await createImageBitmap(bytes,{imageOrientation:'from-image'});let width:number,height:number;try{width=bitmap.width;height=bitmap.height;}finally{bitmap.close();}worker.postMessage({version:1,id,byteLength:bytes.size,width,height});} try{const bitmap=await createImageBitmap(bytes,{imageOrientation:'from-image'});let width:number,height:number;try{width=bitmap.width;height=bitmap.height;}finally{bitmap.close();}worker.postMessage({version:1,id,byteLength:bytes.size,width,height});}
// 解码/读取尺寸/close/成功回信失败均尝试发送稳定错误;若通信本身不可用,宿主仍需取消/错误事件收尾。
catch{worker.postMessage({version:1,id,byteLength:bytes.size,error:'image_decode_failed'});} catch{worker.postMessage({version:1,id,byteLength:bytes.size,error:'image_decode_failed'});}
}; };
// 标记为独立 TypeScript 模块,避免 Worker 全局声明与其它源码脚本命名空间合并。 // 标记为独立 TypeScript 模块,避免 Worker 全局声明与其它源码脚本命名空间合并。
+6 -2
View File
@@ -4,7 +4,9 @@
*/ */
/** Runner-owned feedback, not persisted job state. Probe bounded batches to /** Runner-owned feedback, not persisted job state. Probe bounded batches to
* distinguish fixed publication cost from the marginal cost of another unit. */ * distinguish fixed publication cost from the marginal cost of another unit. */
/** budget/max由runner给出,用实际已提交units和耗时更新下一批软上限。 */ /** runner 提供已验证的正 budget/max,并在尝试后反馈 units/elapsed;本函数不自行读取时钟或发布内容。
* 闭包只保留 limit 和 observed,重启 runner 会重新从 1 探测,不用持久化这些性能估计。
* 参数/样本未在此验证,调用方需传有限且合理的耗时/单位;它不是容量或源完整性校验器。 */
export function createImportBatches(budget:number,max:number){ export function createImportBatches(budget:number,max:number){
// 初始未观测时保守为1;observed只表示收到反馈,不是源校验或导入已完成。 // 初始未观测时保守为1;observed只表示收到反馈,不是源校验或导入已完成。
let limit=1,observed=false; let limit=1,observed=false;
@@ -13,7 +15,9 @@ export function createImportBatches(budget:number,max:number){
get limit(){return limit;}, get limit(){return limit;},
/** 是否已有至少一次发布成本样本,用于runner的调度决策。 */ /** 是否已有至少一次发布成本样本,用于runner的调度决策。 */
get observed(){return observed;}, get observed(){return observed;},
/** 超时按比例减小并至少降一档;不足当前limit的尾批不能证明更大批也便宜。 */ /** 首次反馈即 observed=true;elapsed > budget 按比例缩小,且尽量至少减 1,最低保留 1。
* 未超时且 units ≥ limit 才按约 1.5 倍增长并封顶 max;等于 budget 也算此分支。
* 不足当前 limit 的尾批不增长,避免将少量记录的固定发布成本误当成更大批仍便宜。 */
record(units:number,elapsed:number){ record(units:number,elapsed:number){
observed=true; observed=true;
if(elapsed>budget){limit=Math.max(1,Math.min(limit-1,Math.floor(units*budget/elapsed)));return;} if(elapsed>budget){limit=Math.max(1,Math.min(limit-1,Math.floor(units*budget/elapsed)));return;}
+8 -2
View File
@@ -13,7 +13,10 @@ export type ImportScheduling = {
/** A soft time slice with a work threshold, even when the clock is coarse. /** A soft time slice with a work threshold, even when the clock is coarse.
* The caller bounds each indivisible parse/decode operation separately. */ * The caller bounds each indivisible parse/decode operation separately. */
/** 默认6ms软目标;step 不一定产生 Promise,调用方须在返回等待值时真正 await/yield。 */ /** 默认 6ms 软目标,闭包保存本时间片 started/work/unchecked 三个计数,不存任务或文档。
* step 可能同步返回 undefined;只有返回 Promise 且调用方 await 时才形成有效背压,应串行使用同一预算。
* 自定义 yieldTask 决定让出到微任务还是后续任务;预算不保证浏览器一定在该点完成一帧绘制。
* 构造只校验 frameBudgetMs,不预先检查 signal;units 应由调用者提供非负有限工作量。 */
export function createImportBudget(signal: AbortSignal, options: ImportScheduling = {}) { export function createImportBudget(signal: AbortSignal, options: ImportScheduling = {}) {
const budget = options.frameBudgetMs ?? 6; const budget = options.frameBudgetMs ?? 6;
if (!Number.isFinite(budget) || budget <= 0) throw fault('invalid_import_budget'); if (!Number.isFinite(budget) || budget <= 0) throw fault('invalid_import_budget');
@@ -22,7 +25,10 @@ export function createImportBudget(signal: AbortSignal, options: ImportSchedulin
// 每128工作单位才读一次时钟,减少高频开销;总量16384强制让出,不能因时间测不准而无限执行。 // 每128工作单位才读一次时钟,减少高频开销;总量16384强制让出,不能因时间测不准而无限执行。
let started = now(), work = 0, unchecked = 0; let started = now(), work = 0, unchecked = 0;
return { return {
/** 记工作量并按阈值检查取消/时间;达到预算或时间异常时让出,回来再验取消并重置时间片。 */ /** 累加 units;未满 128 个未检查单位且总量未满 16384 时直接返回,连 signal 也尚未检查。
* 阈值到达才读时钟;未超时保留 work 累计,超时/时间倒退或非有限值/工作量封顶则 await yieldTask。
* yield 后复查取消,成功才重设 started/work;读取时钟或 yield 抛错直接传播,不隐式重试。
* units 无运行时校验,负数/NaN 会破坏计数假设;本函数不测量单次实际 CPU 成本或字节数。 */
step(units = 1): Promise<void> | undefined { step(units = 1): Promise<void> | undefined {
work += units; work += units;
unchecked += units; unchecked += units;
+6 -2
View File
@@ -6,13 +6,17 @@ import {fault} from '../canvas-core/values.ts';
/** 会破坏整个源读取可信性的错误集合,供上层区分单记录失败与源级暂停/失败。 */ /** 会破坏整个源读取可信性的错误集合,供上层区分单记录失败与源级暂停/失败。 */
const failures=new Set(['import_source_changed','invalid_import_byte_range','import_read_capacity','import_source_closed','import_source_read_failed','invalid_import_read_proof','import_read_proof_interpreter_unavailable']); const failures=new Set(['import_source_changed','invalid_import_byte_range','import_read_capacity','import_source_closed','import_source_read_failed','invalid_import_read_proof','import_read_proof_interpreter_unavailable']);
/** 仅按稳定领域错误码分类,不依赖展示 message 或错误文本包含某个词。 */ /** 只按 code 是否在固定集合分类,不校验错误对象的来源;其它码(含一般 import_aborted)不在集合内。
* 取消是否应优先由读取调用方看 signal;本分类器不是所有源错误/取消的通用判定器。 */
export const isImportByteFailure=(error:unknown)=>failures.has((error as {code?:string}|undefined)?.code??''); export const isImportByteFailure=(error:unknown)=>failures.has((error as {code?:string}|undefined)?.code??'');
/** Optional immutable-source port. Implementations return exactly the requested bytes. */ /** Optional immutable-source port. Implementations return exactly the requested bytes. */
/** 可选只读端口声明完整长度,必须返回所请求区间的精确字节,不能截断或换源。 */ /** 可选只读端口声明完整长度,必须返回所请求区间的精确字节,不能截断或换源。 */
export type ImportByteReader={readonly byteLength:number;read(start:number,end:number,signal:AbortSignal):Promise<Uint8Array>}; export type ImportByteReader={readonly byteLength:number;read(start:number,end:number,signal:AbortSignal):Promise<Uint8Array>};
/** 校验安全整数范围,end 可截至EOF;读取前后查取消,外来 reader 结果额外复制以隔离缓冲复用。 */ /** 校验安全整数范围,允许 end 超出 EOF 并截到 bytes.size;start 超出 EOF 则拒绝。
* reader 长度须等于 Blob 长度,但本函数不逐字节比较 Blob/reader,也不证明同长度源未被替换。
* 外来 Uint8Array 结果复制为自有缓冲,默认 Blob 路径返回此次新读缓冲;不缓存结果或做并发合并。
* 空范围仍会调用 reader/Blob 读取,是否优化零 I/O 由端口决定;bytes 依赖调用方已验证的 Blob 合同。 */
export async function readImportRange(bytes:Blob,reader:ImportByteReader|undefined,start:number,end:number,signal:AbortSignal):Promise<Uint8Array<ArrayBuffer>>{ export async function readImportRange(bytes:Blob,reader:ImportByteReader|undefined,start:number,end:number,signal:AbortSignal):Promise<Uint8Array<ArrayBuffer>>{
if(signal.aborted)throw fault('import_aborted'); if(signal.aborted)throw fault('import_aborted');
if(!Number.isSafeInteger(start)||!Number.isSafeInteger(end)||start<0||end<start||start>bytes.size||reader&&reader.byteLength!==bytes.size)throw fault('invalid_import_byte_range'); if(!Number.isSafeInteger(start)||!Number.isSafeInteger(end)||start<0||end<start||start>bytes.size||reader&&reader.byteLength!==bytes.size)throw fault('invalid_import_byte_range');
@@ -22,8 +22,10 @@ const allowed=new Set(['documentFormatVersion','projectId','canvases','elements'
/** JSON语法空白字节,不用于处理字符串内容。 */ /** JSON语法空白字节,不用于处理字符串内容。 */
const whitespace=(byte:number)=>byte===9||byte===10||byte===13||byte===32; const whitespace=(byte:number)=>byte===9||byte===10||byte===13||byte===32;
/** Each scan owns its cursor. An aborted scan cannot advance the resumable source. */ /** Each scan owns a private byte cursor; shared progress records completed boundaries. */
/** 每次扫描从指定位置建立私有cursor,完成边界后外层才更新可恢复源进度。 */ /** 从指定字节位置建立私有 cursor;仅外层 scanNext 将完整结构边界写回源级进度。
* 取消不发布业务结果,但已定位的完整 ranges 可保留用于重试,不承诺整次 read 的缓存/边界都回滚。
* options/缓存沿用内部引用;scanner 负责定位/单值解码,不验证完整文档引用关系。 */
function scanner(options:Options,signal:AbortSignal,start=0,cache:ScanCache={start:-1,bytes:new Uint8Array()}){ function scanner(options:Options,signal:AbortSignal,start=0,cache:ScanCache={start:-1,bytes:new Uint8Array()}){
const {bytes}=options,budget=createImportBudget(signal,options); const {bytes}=options,budget=createImportBudget(signal,options);
let cursor=start; let cursor=start;
@@ -41,7 +43,9 @@ function scanner(options:Options,signal:AbortSignal,start=0,cache:ScanCache={sta
async function spaces(){while(whitespace(await peek()))await step();} async function spaces(){while(whitespace(await peek()))await step();}
/** 严格匹配一个结构字节,不修补缺逗号/冒号等源损坏。 */ /** 严格匹配一个结构字节,不修补缺逗号/冒号等源损坏。 */
async function take(byte:number){await spaces();if(await peek()!==byte)throw fault('invalid_document_import');await step();} async function take(byte:number){await spaces();if(await peek()!==byte)throw fault('invalid_document_import');await step();}
/** 引号/转义/栈配对扫描一个完整值range,限制嵌套深度,避免把字符串里的分隔符当边界。 */ /** 用 quoted/escaped/显式 stack 定位一个值范围,最多 256 层;不收集 payload 字节。
* 此步只找词法/括号边界,primitive 或字符串内容仍可能是非法 JSON,稍后 decode 才完整解析。
* 超大值也要扫描完才得到范围,maxRecordBytes 仅约束后续解码分配,不限制扫描总工作量。 */
async function value():Promise<Range>{ async function value():Promise<Range>{
await spaces();const start=cursor,stack:number[]=[];let quoted=false,escaped=false; await spaces();const start=cursor,stack:number[]=[];let quoted=false,escaped=false;
while(true){ while(true){
@@ -57,7 +61,9 @@ function scanner(options:Options,signal:AbortSignal,start=0,cache:ScanCache={sta
} }
if(cursor===start)throw fault('invalid_document_import');return{start,end:cursor}; if(cursor===start)throw fault('invalid_document_import');return{start,end:cursor};
} }
/** 对单range校验大小、fatal UTF8、JSON语法和值域,返回原值,不修改业务ID。 */ /** 读取单 range,按指定上限控制分配,再 fatal UTF-8/JSON.parse/validJson;不重写业务 ID。
* 默认 payload 256KiB,header 调用可传独立上限;合法 JSON 不等同元素 schema 或引用关系合法。
* JSON.parse 本身为同步工作,扫描预算不抢占这一步;字节端口异常保持源级错误,不转成坏记录。 */
async function decode(range:Range,limit=options.maxRecordBytes??256*1024):Promise<any>{ async function decode(range:Range,limit=options.maxRecordBytes??256*1024):Promise<any>{
check();if(range.end-range.start>limit)throw fault('record_too_large'); check();if(range.end-range.start>limit)throw fault('record_too_large');
const data=await readImportRange(bytes,options.readBytes,range.start,range.end,signal);let text:string; const data=await readImportRange(bytes,options.readBytes,range.start,range.end,signal);let text:string;
@@ -75,7 +81,11 @@ function tombstones(value:unknown){
/** Parser 5: canonical headers open before scanning the elements tail. /** Parser 5: canonical headers open before scanning the elements tail.
* Out-of-order headers require the indexed interpreter to establish ownership first. * Out-of-order headers require the indexed interpreter to establish ownership first.
* Ordinals remain stable; ranges are retained only as records are requested. */ * Ordinals remain stable; ranges are retained only as records are requested. */
/** 打开standard header后即可返回源;真正elements/tail结构检查跟随read进行,未扫描尾部不冒称合法。 */ /** 浅捕获 options 后打开标准头:源最多 64MiB,单个元数据值上限 4MiB,字段名上限 1KiB。
* 先解析整份 canvases 并验证单根/父链,再返回 canvas-first 的 ordinal 接口;此时元素/尾部可尚未读取。
* 头顺序不满足拥有树前提时委托索引解释器,仍暴露 parserVersion=5,不能据版本推断一定渐进扫描。
* 指纹只是上层传入身份;源字节证明/完整 SHA 与最终元素/入口校验由读取端口和导入准备层负责。
* options.signal 用于打开阶段,返回后的每次 read 使用自己的 readSignal,不自动合并原 signal。 */
export async function openStreamingDocumentImportSource(input:Options):Promise<ImportSource>{ export async function openStreamingDocumentImportSource(input:Options):Promise<ImportSource>{
const options:Options=Object.freeze({...input}); const options:Options=Object.freeze({...input});
const {bytes,signal}=options,max=options.maxRecordBytes??256*1024; const {bytes,signal}=options,max=options.maxRecordBytes??256*1024;
@@ -120,7 +130,9 @@ export async function openStreamingDocumentImportSource(input:Options):Promise<I
for(const id of path){rooted.add(id);await budget.step();} for(const id of path){rooted.add(id);await budget.step();}
} }
const children=canvases.filter(canvas=>canvas.id!==rootId),ranges:Range[]=[]; const children=canvases.filter(canvas=>canvas.id!==rootId),ranges:Range[]=[];
// 源级进度只记录完整range边界/phase,serial只串行化延伸;failure最多输出一条终结错误。 // ranges 随请求积累元素字节范围,children 是已捕获子 canvas;ordinal 与原文件 byte offset 不同。
// 重开源需重建这些内存范围,持久 runner 保存的是稳定 ordinal/源身份,不是这里的 Promise 链。
// serial 只串行化范围延伸,failure 最多输出一条终结错误;请求很远的 ordinal 仍会扫描此前全部未定位项。
let cursor=header.position(),phase:'elements'|'tail'|'done'='elements',first=true,failure:string|undefined,serial=Promise.resolve(); let cursor=header.position(),phase:'elements'|'tail'|'done'='elements',first=true,failure:string|undefined,serial=Promise.resolve();
/** 延伸一个element range,或验证剩余envelope与EOF;只在结构边界确认后更新cursor/phase。 */ /** 延伸一个element range,或验证剩余envelope与EOF;只在结构边界确认后更新cursor/phase。 */
async function scanNext(readSignal:AbortSignal){ async function scanNext(readSignal:AbortSignal){
@@ -146,7 +158,10 @@ export async function openStreamingDocumentImportSource(input:Options):Promise<I
await scan.take(125);await scan.spaces();if(scan.position()!==bytes.size)throw fault('invalid_document_import'); await scan.take(125);await scan.spaces();if(scan.position()!==bytes.size)throw fault('invalid_document_import');
cursor=scan.position();phase='done'; cursor=scan.position();phase='done';
} }
/** ordinal read:子canvas直接返回,元素按需scan定位;已定位范围逐记录解码,后续恢复可重读。 */ /** ordinal read:先 children,后 ranges 中元素;源根只作为 sourceRootId 别名,不作为导入子项输出。
* 子 canvas 可直接返回,元素定位后按独立 range 重读/解码;一个 payload 错误不抹掉其它已定位项。
* 结构错误记终结 failure;取消/已知字节证明错误只拒绝本次,允许同 ordinal 重试。
* EOF 仅在有效末序号返回 null,越过末尾仍拒绝,不能把任意大序号当作已完成。 */
async function read(position:number,readSignal:AbortSignal):Promise<ImportLine|null>{ async function read(position:number,readSignal:AbortSignal):Promise<ImportLine|null>{
if(readSignal.aborted)throw fault('import_aborted'); if(readSignal.aborted)throw fault('import_aborted');
if(!Number.isSafeInteger(position)||position<0)throw fault('invalid_import_cursor'); if(!Number.isSafeInteger(position)||position<0)throw fault('invalid_import_cursor');
+12 -4
View File
@@ -11,7 +11,11 @@ import {fault,validJson} from '../canvas-core/values.ts';
type Range={start:number;end:number}; type Range={start:number;end:number};
/** Index the envelope without decoding element payloads. Original bytes remain the source. /** Index the envelope without decoding element payloads. Original bytes remain the source.
* Preparation scans O(bytes) and retains O(records) ranges; this is not constant memory. */ * Preparation scans O(bytes) and retains O(records) ranges; this is not constant memory. */
/** 在64MiB源上索引envelope/记录范围,验证头与树后返回可按ordinal重读的ImportSource。 */ /** 在最多 64MiB 源上完整扫描 envelope/元素 ranges,验证元数据与父链后才返回 parser 4。
* 字段名解码上限 1KiB、单元数据值 4MiB,元素默认 256KiB 且逐次 read 解码;坏 envelope 尾部会拒绝打开。
* bytes/signal/max 为入口局部值,readBytes 和返回 fingerprint 仍取原 options,调用方须保持选项稳定。
* 不生成完整源 SHA/读取证明或业务 ID;注入端口负责源字节一致性,runner 负责业务发布/恢复。
* 完整扫描至少访问全部源字节;父链校验另有树遍历成本,不能将全部准备工作都算作 O(bytes)。 */
export async function openDocumentImportSource(options:{bytes:Blob;fingerprint:string;readBytes?:ImportByteReader;signal:AbortSignal;maxRecordBytes?:number}&ImportScheduling):Promise<ImportSource>{ export async function openDocumentImportSource(options:{bytes:Blob;fingerprint:string;readBytes?:ImportByteReader;signal:AbortSignal;maxRecordBytes?:number}&ImportScheduling):Promise<ImportSource>{
const {bytes,signal}=options,max=options.maxRecordBytes??256*1024; const {bytes,signal}=options,max=options.maxRecordBytes??256*1024;
if(!(bytes instanceof Blob)||bytes.size>64*1024*1024||!options.fingerprint||!Number.isSafeInteger(max)||max<1)throw fault('invalid_import_source'); if(!(bytes instanceof Blob)||bytes.size>64*1024*1024||!options.fingerprint||!Number.isSafeInteger(max)||max<1)throw fault('invalid_import_source');
@@ -35,7 +39,8 @@ export async function openDocumentImportSource(options:{bytes:Blob;fingerprint:s
} }
/** 对限定range执行fatal UTF8和JSON值域校验;key/元数据/记录可使用不同字节上限。 */ /** 对限定range执行fatal UTF8和JSON值域校验;key/元数据/记录可使用不同字节上限。 */
async function decode(range:Range,limit=max,readSignal=signal){if(range.end-range.start>limit)throw fault('record_too_large');const data=await readImportRange(bytes,options.readBytes,range.start,range.end,readSignal);let text:string;try{text=new TextDecoder('utf-8',{fatal:true}).decode(data);}catch{throw fault('invalid_utf8');}try{const result=JSON.parse(text);if(!validJson(result))throw Error();return result;}catch{throw fault('invalid_json');}} async function decode(range:Range,limit=max,readSignal=signal){if(range.end-range.start>limit)throw fault('record_too_large');const data=await readImportRange(bytes,options.readBytes,range.start,range.end,readSignal);let text:string;try{text=new TextDecoder('utf-8',{fatal:true}).decode(data);}catch{throw fault('invalid_utf8');}try{const result=JSON.parse(text);if(!validJson(result))throw Error();return result;}catch{throw fault('invalid_json');}}
// envelope字段名去重;elements仅保留ranges,其它字段按有限metadata大小解码。 // fields 用 Map 去重/保存元数据,elements 数组保存 [start,end);扫描 payload 时不缓存其完整 JSON 对象。
// 范围定位只检查括号/分隔,元素内部的 JSON/UTF-8 内容错误仍可能直到 read 才发现。
const fields=new Map<string,unknown>(),elements:Range[]=[]; const fields=new Map<string,unknown>(),elements:Range[]=[];
await take(123);await spaces();if(await peek()===125)throw fault('invalid_document_import'); await take(123);await spaces();if(await peek()===125)throw fault('invalid_document_import');
while(true){const key=await decode(await value(),1024);if(typeof key!=='string'||fields.has(key))throw fault('invalid_document_import');await take(58); while(true){const key=await decode(await value(),1024);if(typeof key!=='string'||fields.has(key))throw fault('invalid_document_import');await take(58);
@@ -48,7 +53,8 @@ export async function openDocumentImportSource(options:{bytes:Blob;fingerprint:s
const canvases=fields.get('canvases') as {id:string;parent:null|{canvasId:string;entryElementId:string}}[],ids=new Set<string>(); const canvases=fields.get('canvases') as {id:string;parent:null|{canvasId:string;entryElementId:string}}[],ids=new Set<string>();
for(const canvas of canvases){if(!canvas||typeof canvas.id!=='string'||!canvas.id||ids.has(canvas.id)||canvas.parent!==null&&(!canvas.parent||typeof canvas.parent.canvasId!=='string'||typeof canvas.parent.entryElementId!=='string'))throw fault('invalid_document_import');ids.add(canvas.id);} for(const canvas of canvases){if(!canvas||typeof canvas.id!=='string'||!canvas.id||ids.has(canvas.id)||canvas.parent!==null&&(!canvas.parent||typeof canvas.parent.canvasId!=='string'||typeof canvas.parent.entryElementId!=='string'))throw fault('invalid_document_import');ids.add(canvas.id);}
const roots=canvases.filter(canvas=>canvas.parent===null);if(roots.length!==1)throw fault('invalid_document_import');const rootId=roots[0].id; const roots=canvases.filter(canvas=>canvas.parent===null);if(roots.length!==1)throw fault('invalid_document_import');const rootId=roots[0].id;
// parent链必须存在且无环;入口实际节点/引用闭合仍由后续原生转换与文档校验完成。 // 对每个 canvas 从头跟父链,用本路径 seen 检测环;不复用其它路径结果,深树可能反复遍历共同祖先。
// 这里只证明 canvas 单根/父链连通,不核对入口节点是否存在、组件引用是否闭合或 projectId 是否等于根 ID。
const byId=new Map(canvases.map(canvas=>[canvas.id,canvas])); const byId=new Map(canvases.map(canvas=>[canvas.id,canvas]));
for(const canvas of canvases){const seen=new Set<string>();let current=canvas;while(current.parent){if(seen.has(current.id))throw fault('invalid_document_import');seen.add(current.id);await budget.step();const parent=byId.get(current.parent.canvasId);if(!parent)throw fault('invalid_document_import');current=parent;}} for(const canvas of canvases){const seen=new Set<string>();let current=canvas;while(current.parent){if(seen.has(current.id))throw fault('invalid_document_import');seen.add(current.id);await budget.step();const parent=byId.get(current.parent.canvasId);if(!parent)throw fault('invalid_document_import');current=parent;}}
const tombstones=fields.get('tombstones');if(tombstones!==undefined&&(!tombstones||typeof tombstones!=='object'||Array.isArray(tombstones)||Object.values(tombstones).some(n=>!Number.isSafeInteger(n)||(n as number)<1)))throw fault('invalid_document_import'); const tombstones=fields.get('tombstones');if(tombstones!==undefined&&(!tombstones||typeof tombstones!=='object'||Array.isArray(tombstones)||Object.values(tombstones).some(n=>!Number.isSafeInteger(n)||(n as number)<1)))throw fault('invalid_document_import');
@@ -57,7 +63,9 @@ export async function openDocumentImportSource(options:{bytes:Blob;fingerprint:s
return Object.freeze({fingerprint:options.fingerprint,parserVersion:4,async read(position:number,readSignal:AbortSignal):Promise<ImportLine|null>{ return Object.freeze({fingerprint:options.fingerprint,parserVersion:4,async read(position:number,readSignal:AbortSignal):Promise<ImportLine|null>{
if(readSignal.aborted)throw fault('import_aborted');if(!Number.isSafeInteger(position)||position<0||position>count)throw fault('invalid_import_cursor');if(position===count)return null; if(readSignal.aborted)throw fault('import_aborted');if(!Number.isSafeInteger(position)||position<0||position>count)throw fault('invalid_import_cursor');if(position===count)return null;
const base={cursor:position,nextCursor:position+1,key:`record:${position}`};let record:any,kind:string; const base={cursor:position,nextCursor:position+1,key:`record:${position}`};let record:any,kind:string;
// 单记录payload失败返回error/next ordinal,保留后续合法记录;源级字节失败和取消仍抛出。 // children 返回已解析的元数据记录引用,元素则每次按 range 重读/新解析;消费者不应修改共享 children。
// 已完成索引的读取无需再移动扫描 cursor;本次 readSignal 独立于打开阶段 signal。
// 单记录 payload 失败返回 error/next ordinal,不影响其它项;源级字节错误/取消仍抛出,供同序号重试。
try{if(position<children.length){kind='canvas';record=children[position];}else{kind='element';record=await decode(elements[position-children.length],max,readSignal);}if(readSignal.aborted)throw fault('import_aborted');if(!record||typeof record!=='object'||Array.isArray(record)||typeof record.id!=='string'||!record.id)throw fault('invalid_native_import_record');return{...base,value:{kind,key:record.id,record,sourceRootId:rootId}};}catch(error){if(readSignal.aborted)throw fault('import_aborted');if(isImportByteFailure(error))throw error;return{...base,error:(error as {code?:string}).code??'invalid_document_import'};} try{if(position<children.length){kind='canvas';record=children[position];}else{kind='element';record=await decode(elements[position-children.length],max,readSignal);}if(readSignal.aborted)throw fault('import_aborted');if(!record||typeof record!=='object'||Array.isArray(record)||typeof record.id!=='string'||!record.id)throw fault('invalid_native_import_record');return{...base,value:{kind,key:record.id,record,sourceRootId:rootId}};}catch(error){if(readSignal.aborted)throw fault('import_aborted');if(isImportByteFailure(error))throw error;return{...base,error:(error as {code?:string}).code??'invalid_document_import'};}
}}); }});
} }
+22 -7
View File
@@ -20,7 +20,10 @@ type ExternalCanvas = {id: Id; parent: CanvasRecord['parent']; entryIncarnation?
type CopyLine = {key: string; change: Exclude<Change,{kind:'field'}>; dependencies: string[]; externalElements: ExternalElement[]; externalCanvases: ExternalCanvas[]}; type CopyLine = {key: string; change: Exclude<Change,{kind:'field'}>; dependencies: string[]; externalElements: ExternalElement[]; externalCanvases: ExternalCanvas[]};
/** Internal source: already mapped records, with explicit guards for every retained external reference. */ /** Internal source: already mapped records, with explicit guards for every retained external reference. */
/** 兼容完整计划路径:逐change生成有限记录NDJSON,单条≤256KiB、全源≤64MiB;仍返回完整Blob。 */ /** 完整计划兼容路径:先同步建立新 ID→源 key Map,再逐 change 编码 NDJSON,保留所有字符串直到生成 Blob。
* 单条含换行的 UTF-8 字节须 ≤256KiB,全源 ≤64MiB;这些是编码成本限制,不是进程峰值内存保证。
* 每条完成后按约每 KiB 一个工作单位调用 copy budget;单次映射建表/JSON.stringify 不被抢占。
* 成功前复查取消,不输出部分 Blob;不验证整个 plan 的 ID 唯一性/图合法性或计算源摘要。 */
export async function encodeDuplicateSource(document: Document, registry: Registry, plan: DuplicatePlan, signal: AbortSignal, scheduling:ImportScheduling={}): Promise<Blob> { export async function encodeDuplicateSource(document: Document, registry: Registry, plan: DuplicatePlan, signal: AbortSignal, scheduling:ImportScheduling={}): Promise<Blob> {
const keys = new Map<Id,string>(),parts: BlobPart[] = []; const keys = new Map<Id,string>(),parts: BlobPart[] = [];
for (const [id,mapped] of Object.entries(plan.elementIds)) keys.set(mapped,`element:${id}`); for (const [id,mapped] of Object.entries(plan.elementIds)) keys.set(mapped,`element:${id}`);
@@ -36,11 +39,15 @@ export async function encodeDuplicateSource(document: Document, registry: Regist
if(signal.aborted)throw fault('copy_aborted');return new Blob(parts,{type:'application/x-ndjson'}); if(signal.aborted)throw fault('copy_aborted');return new Blob(parts,{type:'application/x-ndjson'});
} }
/** 共享逐记录转换器:将新映射ID关联到source key,收集owner/端点/声明引用的必要外部闭包。 */ /** 将一条已映射创建变化封装成 CopyLine:keys 区分副本内部依赖与仍引用原图的外部身份。
* 收集 owner/端点/声明数据引用及外部 canvas 父链入口,去掉自身依赖;不重新映射字段或分配 ID。
* 返回的 change 沿用输入引用,数组/guard 未整体冻结;输入文档/映射须来自已验证的不可变计划。
* 这里只收集已知 copy.references,不独立调用 resolve 验组件版本,也不替代最终整图校验。 */
export function duplicateImportLine(document:Document,registry:Registry,change:Exclude<Change,{kind:'field'}>,keys:ReadonlyMap<Id,string>):CopyLine { export function duplicateImportLine(document:Document,registry:Registry,change:Exclude<Change,{kind:'field'}>,keys:ReadonlyMap<Id,string>):CopyLine {
if(!change.after||change.before!==null)throw fault('invalid_duplicate_plan'); if(!change.after||change.before!==null)throw fault('invalid_duplicate_plan');
const dependencies=new Set<string>(),elements=new Map<Id,ExternalElement>(),canvases=new Map<Id,ExternalCanvas>(); const dependencies=new Set<string>(),elements=new Map<Id,ExternalElement>(),canvases=new Map<Id,ExternalCanvas>();
/** 内部canvas转成依赖;外部canvas沿父链收集guard和入口节点身份,不猜其数据字段。 */ /** keys 命中即登记内部依赖;否则沿原 canvas 父链收集 parent 与存在的入口代次。
* canvases Map 同时去重/止循环,不在这里证明父链无环或每个入口都存在,依赖合法源文档合同。 */
const canvas=(id:Id)=>{ const canvas=(id:Id)=>{
const key=keys.get(id);if(key){dependencies.add(key);return;} const key=keys.get(id);if(key){dependencies.add(key);return;}
let next:Id|undefined=id; let next:Id|undefined=id;
@@ -65,7 +72,9 @@ export function duplicateImportLine(document:Document,registry:Registry,change:E
return {key,change,dependencies:[...dependencies],externalElements:[...elements.values()],externalCanvases:[...canvases.values()]}; return {key,change,dependencies:[...dependencies],externalElements:[...elements.values()],externalCanvases:[...canvases.values()]};
} }
/** 校验旧durable预映射行与当前job.stableIds一致,元素新incarnation必须为1,不重新分配ID。 */ /** 旧持久源的基础封装校验:允许源种类、JSON 值域、数组形状、创建 ID 与 stableIds/key 前缀对应。
* element 还要求 incarnation=1;不逐字段验证 guard/dependency schema,也不证明外部 guard 集合完整。
* 返回原行引用,不捕获/修复源,不重新分配 ID;完整源绑定、变化及图校验仍由 runner/发布边界承担。 */
function copyLine(line:ImportLine,job:ImportJob):CopyLine { function copyLine(line:ImportLine,job:ImportJob):CopyLine {
const value=line.value as unknown as CopyLine; const value=line.value as unknown as CopyLine;
if(!['duplicate-ndjson','duplicate-plan'].includes(job.source?.kind??'')||!value||!validJson(value)||typeof value.key!=='string'||!Array.isArray(value.dependencies)||!Array.isArray(value.externalElements)||!Array.isArray(value.externalCanvases)||!value.change||value.change.kind!=='element'&&value.change.kind!=='canvas'||value.change.before!==null||!value.change.after||!Object.hasOwn(job.stableIds,value.key)||job.stableIds[value.key]!==value.change.id||value.change.after.id!==value.change.id||!value.key.startsWith(`${value.change.kind}:`))throw fault('invalid_duplicate_source'); if(!['duplicate-ndjson','duplicate-plan'].includes(job.source?.kind??'')||!value||!validJson(value)||typeof value.key!=='string'||!Array.isArray(value.dependencies)||!Array.isArray(value.externalElements)||!Array.isArray(value.externalCanvases)||!value.change||value.change.kind!=='element'&&value.change.kind!=='canvas'||value.change.before!==null||!value.change.after||!Object.hasOwn(job.stableIds,value.key)||job.stableIds[value.key]!==value.change.id||value.change.after.id!==value.change.id||!value.key.startsWith(`${value.change.kind}:`))throw fault('invalid_duplicate_source');
@@ -73,7 +82,9 @@ function copyLine(line:ImportLine,job:ImportJob):CopyLine {
return value; return value;
} }
/** Re-check the current reference contract when decoding old durable prepared sources. */ /** Re-check the current reference contract when decoding old durable prepared sources. */
/** 对当前复制schema重验引用:内部映射须声明依赖,外部引用须explicit preserve且有对应guard。 */ /** 对 element 的当前 copy.references 重验:内部映射(自引用除外)须列依赖,外部须 preserve 且列 guard ID。
* 每个引用线性扫描 stableIds 找对应命名空间;这里只查声明数据引用,不重建 owner/端点的完整 guard 闭包。
* canvas 直接跳过;此步骤不是全部 CopyLine schema 校验或外部对象当前生命周期校验。 */
function assertCopyReferences(value:CopyLine,job:ImportJob,registry:Registry){ function assertCopyReferences(value:CopyLine,job:ImportJob,registry:Registry){
if(value.change.kind!=='element')return; if(value.change.kind!=='element')return;
const record=value.change.after!,schema=registry.components.get(record.componentId)?.copy; const record=value.change.after!,schema=registry.components.get(record.componentId)?.copy;
@@ -84,14 +95,18 @@ function assertCopyReferences(value:CopyLine,job:ImportJob,registry:Registry){
else if(ref.external!=='preserve'||!(ref.target==='element'?value.externalElements:value.externalCanvases).some(guard=>guard.id===id))throw fault('copy_external_reference',[record.id,id]); else if(ref.external!=='preserve'||!(ref.target==='element'?value.externalElements:value.externalCanvases).some(guard=>guard.id===id))throw fault('copy_external_reference',[record.id,id]);
} }
} }
/** 形成runner的纯ImportUnit并保留原行作为guard,reserve只核对已有映射,不修改原图。 */ /** 先校验预映射行/声明引用,再用 reserve 核对自身 ID,要求依赖键都在 job.stableIds 中。
* reserve 由调用方提供,本函数不回滚它的副作用;合法已准备源应命中现有映射而非再分配身份。
* dependencies/change 沿用原行引用,guard 用 JSON 克隆捕获;不检查依赖已提交,也不发布/持久化内容。 */
export function prepareDuplicateImport(line:ImportLine,job:ImportJob,reserve:(key:string)=>Id,registry:Registry):ImportUnit { export function prepareDuplicateImport(line:ImportLine,job:ImportJob,reserve:(key:string)=>Id,registry:Registry):ImportUnit {
const value=copyLine(line,job);assertCopyReferences(value,job,registry);if(reserve(value.key)!==value.change.id)throw fault('invalid_duplicate_source'); const value=copyLine(line,job);assertCopyReferences(value,job,registry);if(reserve(value.key)!==value.change.id)throw fault('invalid_duplicate_source');
for(const key of value.dependencies)if(!Object.hasOwn(job.stableIds,key))throw fault('invalid_duplicate_source'); for(const key of value.dependencies)if(!Object.hasOwn(job.stableIds,key))throw fault('invalid_duplicate_source');
return{key:value.key,dependencies:value.dependencies,changes:[value.change],guard:clone(line.value!)}; return{key:value.key,dependencies:value.dependencies,changes:[value.change],guard:clone(line.value!)};
} }
/** Check at the synchronous commit boundary, never only when the source was first parsed. */ /** Check at the synchronous commit boundary, never only when the source was first parsed. */
/** 提交前按当前文档核对外部节点代次/owner、canvas parent/入口代次;不符即拒绝这一单元/组。 */ /** 同步提交前重新解释行与声明引用,再逐个核对提供的外部 element 代次/owner、canvas parent/入口代次。
* 内容/样式可变化而通过;组件版本等未在此额外比较,guard 是否完整依赖受信源生成与恢复校验。
* 不锁住对象或发布 changes;应与真正提交处于同一同步边界,中间 await 会让该检查过期。 */
export function assertDuplicateExternal(line:ImportLine,job:ImportJob,document:Document,registry:Registry){ export function assertDuplicateExternal(line:ImportLine,job:ImportJob,document:Document,registry:Registry){
const value=copyLine(line,job);assertCopyReferences(value,job,registry); const value=copyLine(line,job);assertCopyReferences(value,job,registry);
for(const guard of value.externalElements){const record=document.elements.get(guard.id);if(!record||record.incarnation!==guard.incarnation||record.ownerCanvasId!==guard.ownerCanvasId)throw fault('copy_external_changed',[guard.id]);} for(const guard of value.externalElements){const record=document.elements.get(guard.id);if(!record||record.incarnation!==guard.incarnation||record.ownerCanvasId!==guard.ownerCanvasId)throw fault('copy_external_changed',[guard.id]);}
@@ -15,12 +15,15 @@ export type PreviousImportFile={readonly fileId:Id;readonly byteLength:number;re
export type ImportFile={readonly jobId:Id;readonly current:boolean;readonly file:PreviousImportFile}; export type ImportFile={readonly jobId:Id;readonly current:boolean;readonly file:PreviousImportFile};
/** completed/partial/cancelled是本任务终态;paused/preparing不代表源已经不再需要。 */ /** completed/partial/cancelled是本任务终态;paused/preparing不代表源已经不再需要。 */
export const terminalImport=(status:ImportJob['status'])=>['completed','partial','cancelled'].includes(status); export const terminalImport=(status:ImportJob['status'])=>['completed','partial','cancelled'].includes(status);
/** 检查退休协议与安全时间值;只是结构合法,不证明引用扫描已完整。 */ /** 检查版本及非负安全整数时间,purgedAt 不得早于 unreferencedAt。
* 不与当前时钟比较,也不证明保留期已满、引用已清空或删除已提交;这些由收集器/存储边界保证。 */
export function validFileRetirement(value:unknown):value is FileRetirement { export function validFileRetirement(value:unknown):value is FileRetirement {
const marker=value as FileRetirement; const marker=value as FileRetirement;
return !!marker&&marker.version===1&&Number.isSafeInteger(marker.unreferencedAt)&&marker.unreferencedAt>=0&&(marker.purgedAt===undefined||Number.isSafeInteger(marker.purgedAt)&&marker.purgedAt>=marker.unreferencedAt); return !!marker&&marker.version===1&&Number.isSafeInteger(marker.unreferencedAt)&&marker.unreferencedAt>=0&&(marker.purgedAt===undefined||Number.isSafeInteger(marker.purgedAt)&&marker.purgedAt>=marker.unreferencedAt);
} }
/** 汇总全部当前/旧源,验证job键与ID一致且同fileId不跨job重复,供原子回收检查使用。 */ /** 从有 source 的 job 汇总当前/previous 文件,检查 job 键与 ID 及全表 fileId 唯一性。
* 重复包括同 job 的 current/previous 重复;无 source 的 job 不贡献文件。
* 只派生索引,不完整校验文件元数据;返回实际可变 Map(类型只读),值中的 file 沿用输入引用。 */
export function importFiles(jobs:ReadonlyMap<Id,ImportJob>|undefined):ReadonlyMap<Id,ImportFile> { export function importFiles(jobs:ReadonlyMap<Id,ImportJob>|undefined):ReadonlyMap<Id,ImportFile> {
const files=new Map<Id,ImportFile>(); const files=new Map<Id,ImportFile>();
for(const [jobId,job]of jobs??[]){ for(const [jobId,job]of jobs??[]){
@@ -32,18 +35,22 @@ export function importFiles(jobs:ReadonlyMap<Id,ImportJob>|undefined):ReadonlyMa
} }
return files; return files;
} }
/** 当前源仅终态可退休;旧源可独立退休,且同job文件ID互异、元数据和摘要合法。 */ /** 当前源带任何退休标记时都要求终态;previous 文件可独立退休,逐项检查 ID/长度/可选摘要/标记。
* 最后检查 current+previous ID 互异;当前源自身的 fileId/长度/摘要等仍由导入源完整校验负责。 */
export function validateImportFileRetention(source:NativeImportSource,status:ImportJob['status']) { export function validateImportFileRetention(source:NativeImportSource,status:ImportJob['status']) {
if(source.retirement!==undefined&&(!validFileRetirement(source.retirement)||!terminalImport(status)))throw fault('invalid_import_file_retirement'); if(source.retirement!==undefined&&(!validFileRetirement(source.retirement)||!terminalImport(status)))throw fault('invalid_import_file_retirement');
if(source.previousFiles!==undefined&&(!Array.isArray(source.previousFiles)||source.previousFiles.some(file=>!file||typeof file.fileId!=='string'||!file.fileId||!Number.isSafeInteger(file.byteLength)||file.byteLength<0||file.sha256!==undefined&&!/^[a-f0-9]{64}$/.test(file.sha256)||file.retirement!==undefined&&!validFileRetirement(file.retirement))))throw fault('invalid_import_file_retirement'); if(source.previousFiles!==undefined&&(!Array.isArray(source.previousFiles)||source.previousFiles.some(file=>!file||typeof file.fileId!=='string'||!file.fileId||!Number.isSafeInteger(file.byteLength)||file.byteLength<0||file.sha256!==undefined&&!/^[a-f0-9]{64}$/.test(file.sha256)||file.retirement!==undefined&&!validFileRetirement(file.retirement))))throw fault('invalid_import_file_retirement');
const ids=[source.fileId,...(source.previousFiles??[]).map(file=>file.fileId)];if(new Set(ids).size!==ids.length)throw fault('import_file_identity_conflict'); const ids=[source.fileId,...(source.previousFiles??[]).map(file=>file.fileId)];if(new Set(ids).size!==ids.length)throw fault('import_file_identity_conflict');
} }
/** 只改退休标记并保留文件身份;相同标记复用原对象,清标记不删除文件记录。 */ /** 仅替换/移除 retirement;相等时沿用原对象,否则返回冻结记录。
* 不验证新标记或任务终态,也不删除文件;调用方仍需经过发布/源结构校验。 */
function mark<T extends PreviousImportFile>(file:T,retirement:FileRetirement|undefined):T { function mark<T extends PreviousImportFile>(file:T,retirement:FileRetirement|undefined):T {
if(equal(file.retirement,retirement))return file; if(equal(file.retirement,retirement))return file;
const {retirement:_,...rest}=file;return freeze(retirement?{...rest,retirement}:rest) as T; const {retirement:_,...rest}=file;return freeze(retirement?{...rest,retirement}:rest) as T;
} }
/** 按fileId批量更新当前/旧源标记,必要时copy-on-write并推进job.generation;不直接删字节。 */ /** 按 fileId 更新 current/previous 标记,首次修改才复制 imports,每个变化 job 的 generation 只加一次。
* 未命中任何文件的 marker 忽略,重复同值无变化时复用 runtime;这里只改元数据,不校验退休资格或删字节。
* 新 generation 溢出则拒绝,不能生成无法可靠恢复的任务版本。 */
export function markImportFiles(runtime:RuntimeState,markers:ReadonlyMap<Id,FileRetirement|undefined>):RuntimeState { export function markImportFiles(runtime:RuntimeState,markers:ReadonlyMap<Id,FileRetirement|undefined>):RuntimeState {
let imports=runtime.imports; let imports=runtime.imports;
for(const [id,job]of runtime.imports??[]){ for(const [id,job]of runtime.imports??[]){
@@ -56,7 +63,10 @@ export function markImportFiles(runtime:RuntimeState,markers:ReadonlyMap<Id,File
return imports===runtime.imports?runtime:Object.freeze({...runtime,imports:readMap(imports!)}); return imports===runtime.imports?runtime:Object.freeze({...runtime,imports:readMap(imports!)});
} }
/** A source file cannot disappear from its owning job, become an asset or regain execution after purge. */ /** A source file cannot disappear from its owning job, become an asset or regain execution after purge. */
/** 每次Root发布前校验源身份单调性并取消不安全软退休;已purge仍被pin明确拒绝。 */ /** 发布前校验文件归属/长度/已有摘要单调性,并清掉重 pin 或覆盖不完整的软退休。
* 已 purge 的 retirement 不得改写且不能再 pin;当前源若带标记回到非终态也会被结构校验拒绝。
* reserved 只让此函数绕过快路,不逐 ID 检查占用;统一 reservation 屏障由外围资源保留钩子执行。
* referencesChanged 默认 true;传 false 的调用方须确保引用变化已准确反映,不能仅凭 Map 相等猜完整性。 */
export function prepareImportFileRetention(previous:RuntimeState,next:RuntimeState,pins:()=>{complete:boolean;pins:ReadonlyMap<Id,{has(owner:string):boolean}>},reserved:ReadonlySet<Id>,referencesChanged=true):RuntimeState { export function prepareImportFileRetention(previous:RuntimeState,next:RuntimeState,pins:()=>{complete:boolean;pins:ReadonlyMap<Id,{has(owner:string):boolean}>},reserved:ReadonlySet<Id>,referencesChanged=true):RuntimeState {
const hasSoft=[...(next.imports?.values()??[])].some(job=>job.source&&[job.source,...(job.source.previousFiles??[])].some(file=>file.retirement&&file.retirement.purgedAt===undefined)); const hasSoft=[...(next.imports?.values()??[])].some(job=>job.source&&[job.source,...(job.source.previousFiles??[])].some(file=>file.retirement&&file.retirement.purgedAt===undefined));
if(previous.imports===next.imports&&previous.resources===next.resources&&previous.resourcePins===next.resourcePins&&!reserved.size&&!hasSoft&&!referencesChanged)return next; if(previous.imports===next.imports&&previous.resources===next.resources&&previous.resourcePins===next.resourcePins&&!reserved.size&&!hasSoft&&!referencesChanged)return next;
@@ -69,8 +79,10 @@ export function prepareImportFileRetention(previous:RuntimeState,next:RuntimeSta
if(!entry||entry.jobId!==old.jobId||entry.file.byteLength!==old.file.byteLength||old.file.sha256!==undefined&&entry.file.sha256!==old.file.sha256||!old.current&&entry.current)throw fault('import_file_identity_conflict',[id]); if(!entry||entry.jobId!==old.jobId||entry.file.byteLength!==old.file.byteLength||old.file.sha256!==undefined&&entry.file.sha256!==old.file.sha256||!old.current&&entry.current)throw fault('import_file_identity_conflict',[id]);
if(old.file.retirement?.purgedAt!==undefined&&!equal(old.file.retirement,entry.file.retirement))throw fault('import_source_retired',[id]); if(old.file.retirement?.purgedAt!==undefined&&!equal(old.file.retirement,entry.file.retirement))throw fault('import_source_retired',[id]);
} }
// 无软标记、reservation 或 purge 记录时不调用 pins;身份校验已完成,不等于执行了完整引用扫描。
if(!hasSoft&&!reserved.size&&![...after.values()].some(entry=>entry.file.retirement?.purgedAt!==undefined))return next; if(!hasSoft&&!reserved.size&&![...after.values()].some(entry=>entry.file.retirement?.purgedAt!==undefined))return next;
// 覆盖未知或重新引用都清软标记,下一次无引用须重新计保留期,不沿用旧时钟直接删除。 // 覆盖未知或重新引用都清软标记,下一次无引用须重新计保留期,不沿用旧时钟直接删除。
// 新摘要可在旧值缺失时补齐,但已有摘要不能替换;清标记通过 markImportFiles 同步推进 job generation。
const coverage=pins(),clear=new Map<Id,undefined>(); const coverage=pins(),clear=new Map<Id,undefined>();
for(const [id,entry]of after){ for(const [id,entry]of after){
if(entry.file.retirement?.purgedAt!==undefined){if(coverage.pins.has(id))throw fault('import_source_retired',[id]);continue;} if(entry.file.retirement?.purgedAt!==undefined){if(coverage.pins.has(id))throw fault('import_source_retired',[id]);continue;}
+12 -5
View File
@@ -5,10 +5,15 @@
import type { ImportLocator } from './imports.ts'; import type { ImportLocator } from './imports.ts';
import { fault } from '../canvas-core/values.ts'; import { fault } from '../canvas-core/values.ts';
/** Ready SCCs are indivisible. Ordinary records retain source-order FIFO selection. */ /** Ready SCCs are indivisible; source order guides each readiness scan. */
/** pending按源顺序输入;返回有序原子组,不实际提交,缺依赖的组继续等待而非直接丢失。 */ /** pending 由调用方按源顺序提供且 key 唯一;先找 SCC,再按组内/组间最早源索引扫描。
* 缺外部依赖的组会跳过,可选择其后的已就绪组;同轮后面补齐依赖也不回头重扫,留给下一次调用。
* 不修改 committed/pending,不读取源或实际提交;输出数组可变且 row 沿用输入引用。
* limits 与 locator 成本须先校验;本函数不核对真实编码字节/changes,也没有中途取消或异步让出。 */
export function selectImportGroups(pending: readonly ImportLocator[], committed: ReadonlySet<string>, limits: {units: number; bytes: number; changes: number}): readonly (readonly ImportLocator[])[] { export function selectImportGroups(pending: readonly ImportLocator[], committed: ReadonlySet<string>, limits: {units: number; bytes: number; changes: number}): readonly (readonly ImportLocator[])[] {
// 正向边仅含当前pending内依赖,反向表用于第二次DFS;原index保留稳定源顺序。 // byKey 用 key→原记录/源索引,edges 是依赖邻接表,reverse 是依赖→引用者表。
// 建图只含 pending 内边;外部 key 暂不入图,稍后用 committed 判定是否满足。
// 全段建图与排序同步完成,runner 应先限制 pending 大小,不能靠返回组数限制本次计算成本。
const byKey = new Map(pending.map((row, index) => [row.key, {row, index}])), edges = new Map<string, string[]>(), reverse = new Map<string, string[]>(); const byKey = new Map(pending.map((row, index) => [row.key, {row, index}])), edges = new Map<string, string[]>(), reverse = new Map<string, string[]>();
for (const row of pending) { for (const row of pending) {
const dependencies = row.dependencies.filter(key => byKey.has(key)); edges.set(row.key, dependencies); const dependencies = row.dependencies.filter(key => byKey.has(key)); edges.set(row.key, dependencies);
@@ -39,11 +44,13 @@ export function selectImportGroups(pending: readonly ImportLocator[], committed:
for (const group of components) { for (const group of components) {
const internal = new Set(group.map(row => row.key)); const internal = new Set(group.map(row => row.key));
if (!group.every(row => row.dependencies.every(key => available.has(key) || internal.has(key)))) continue; if (!group.every(row => row.dependencies.every(key => available.has(key) || internal.has(key)))) continue;
// SCC不可拆:单组字节/changes超安全上限必须报错,不能分批提交半个循环引用。 // 只有依赖就绪的组才检查硬成本;超限直接抛错,即使前面已选了组也不会返回部分结果。
// SCC 不可拆,依赖未就绪的超限组仍先等待,不能用拆组绕过循环引用完整性。
const size = group.reduce((sum, row) => sum + row.byteLength, 0), count = group.reduce((sum, row) => sum + row.changeCount, 0); const size = group.reduce((sum, row) => sum + row.byteLength, 0), count = group.reduce((sum, row) => sum + row.changeCount, 0);
if (size > limits.bytes || count > limits.changes) throw fault('import_dependency_group_capacity'); if (size > limits.bytes || count > limits.changes) throw fault('import_dependency_group_capacity');
// The frame's adaptive unit budget is soft for one SCC, byte/change safety limits are hard. // The frame's adaptive unit budget is soft for one SCC, byte/change safety limits are hard.
// 第一组可超过units软预算以闭合环;后续组和字节/changes总预算仍保守限制。 // 第一组可超过 units 软预算以闭合环,字节/changes 总量始终不得超限。
// 后续组放不下就继续找后面的较小可用组,不以 break 阻止其它独立组;available 只加入实际 chosen 的键。
if (units && units + group.length > limits.units || bytes + size > limits.bytes || changes + count > limits.changes) continue; if (units && units + group.length > limits.units || bytes + size > limits.bytes || changes + count > limits.changes) continue;
chosen.push(group); units += group.length; bytes += size; changes += count; for (const row of group) available.add(row.key); chosen.push(group); units += group.length; bytes += size; changes += count; for (const row of group) available.add(row.key);
} }
+10 -4
View File
@@ -8,10 +8,14 @@ import {fault} from '../canvas-core/values.ts';
/** 源记录/依赖键到目标ID的只读字典,element/canvas前缀由调用者约定。 */ /** 源记录/依赖键到目标ID的只读字典,element/canvas前缀由调用者约定。 */
type Ids=Readonly<Record<string,Id>>; type Ids=Readonly<Record<string,Id>>;
/** One runner's last immutable ID index. Copies are deferred until allocation. */ /** One runner's last immutable ID index. Copies are deferred until allocation. */
/** 返回带clear方法的捕获工厂,每次为一个源记录的准备提供reserve/has/finish私有上下文。 */ /** 一个 runner 的捕获工厂,缓存最近一张可复用表及其 used Set;每次 capture 创建独立分配上下文。
* 初始表的值域/空 ID/已有重复映射未在此完整校验,Set 只用于拦截后续 createId 与已知 ID 碰撞。
* 不查项目文档、墓碑、其它 runner 或服务身份空间;稳定映射须由 job 校验与发布层共同保证。 */
export function createImportIdentities(){ export function createImportIdentities(){
let cached:{ids:Ids;used:Set<Id>}|undefined; let cached:{ids:Ids;used:Set<Id>}|undefined;
/** 相同已冻结纯字符串属性表可复用used索引;其它输入先复制为无原型字典。 */ /** 同引用命中缓存时直接复用;否则仅将冻结且描述符为字符串数据值的输入视为可复用。
* 这是性能准入,不是完整 schema/普通对象证明;其余输入以 Object.assign 捕获到无原型字典,getter 会在此求值。
* 首次分配前共享基表/used;写时再分离,避免每条只查既有键的记录重扫整张映射。 */
const capture=(input:Ids,createId:()=>Id)=>{ const capture=(input:Ids,createId:()=>Id)=>{
let identity=cached?.ids===input?cached:undefined; let identity=cached?.ids===input?cached:undefined;
if(!identity){ if(!identity){
@@ -30,10 +34,12 @@ export function createImportIdentities(){
}, },
/** 判断目标ID是否已被本映射使用,不声称该ID已加入文档或已同步。 */ /** 判断目标ID是否已被本映射使用,不声称该ID已加入文档或已同步。 */
has:(id:Id)=>used.has(id), has:(id:Id)=>used.has(id),
/** 返回本次分配后的只读映射;没有新分配时保留输入身份便于下一记录复用索引。 */ /** owned 缓冲通过 dataRecord 再捕获不可变值,后续 reserve 不改变已经返回的结果。
* 可复用输入且无分配时沿用原表;可变/getter 输入即使未分配也返回捕获表,每次 finish 可重新捕获。
* 返回不代表映射已落盘;runner 须与导入进度/文档同次发布和持久化。 */
finish:():Ids=>owned?dataRecord(ids):ids, finish:():Ids=>owned?dataRecord(ids):ids,
}); });
}; };
/** clear只丢弃临时复用索引,不清除已持久化job的stableIds。 */ /** clear 只丢工厂最近索引;现有 capture 上下文仍能使用,不清已发布/持久 job.stableIds,也不回收 ID。 */
return Object.freeze(Object.assign(capture,{clear(){cached=undefined;}})); return Object.freeze(Object.assign(capture,{clear(){cached=undefined;}}));
} }
+6 -2
View File
@@ -5,13 +5,17 @@
import { fault } from '../canvas-core/values.ts'; import { fault } from '../canvas-core/values.ts';
import {createSha256} from './sha256.ts'; import {createSha256} from './sha256.ts';
/** Complete-source SHA-256, with bounded chunk memory and cooperative fallback scheduling. */ /** Complete-source SHA-256, with bounded chunk memory and cooperative fallback scheduling. */
/** 默认64KiB分块(配置上限4MiB),每块读前/读后检查取消,并在块间合作让出。 */ /** 顺序 slice 读取完整 Blob,默认 64KiB 分块(配置上限 4MiB),空文件也返回合法摘要。
* 每块读前/读后及 yield 后检查取消;原生 arrayBuffer/yield 本身不被信号强制终止。
* 默认块间 setTimeout(0) 让出,只有仍有下一块才 yield;自定义 yieldTask 决定实际调度公平性。
* 块内 hash.update 为同步 CPU 工作,不会中途取消;读/让出错误直接传播,不返回部分摘要。 */
export async function importSourceDigest(bytes:Blob,signal?:AbortSignal,options:{chunkBytes?:number;yieldTask?:()=>Promise<void>}={}):Promise<string>{ export async function importSourceDigest(bytes:Blob,signal?:AbortSignal,options:{chunkBytes?:number;yieldTask?:()=>Promise<void>}={}):Promise<string>{
const chunk=options.chunkBytes??64*1024,yieldTask=options.yieldTask??(()=>new Promise<void>(resolve=>setTimeout(resolve,0))); const chunk=options.chunkBytes??64*1024,yieldTask=options.yieldTask??(()=>new Promise<void>(resolve=>setTimeout(resolve,0)));
if(!(bytes instanceof Blob)||!Number.isSafeInteger(chunk)||chunk<1||chunk>4*1024*1024)throw fault('invalid_digest_input'); if(!(bytes instanceof Blob)||!Number.isSafeInteger(chunk)||chunk<1||chunk>4*1024*1024)throw fault('invalid_digest_input');
/** 读/计算/让出边界统一检查 signal;异步读取即使无法中止,迟到字节也不再参与有效结果。 */ /** 读/计算/让出边界统一检查 signal;异步读取即使无法中止,迟到字节也不再参与有效结果。 */
const check=()=>{if(signal?.aborted)throw fault('import_aborted');};check();const hash=createSha256(); const check=()=>{if(signal?.aborted)throw fault('import_aborted');};check();const hash=createSha256();
// 更新增量状态后释放对本块的局部引用;空文件直接 hex 得到标准空输入摘要。 // 只顺序持有当前块,hash 自身不累计输入;part 在本次迭代/yield 期间仍可存在,实际释放时机由 GC 决定。
// 空文件跳过读取/让出,最后 check 后直接 hex;有界分块不是整个应用进程的内存硬上限。
for(let cursor=0;cursor<bytes.size;cursor+=chunk){check();const part=await bytes.slice(cursor,cursor+chunk).arrayBuffer();check();hash.update(new Uint8Array(part));if(cursor+chunk<bytes.size){await yieldTask();check();}} for(let cursor=0;cursor<bytes.size;cursor+=chunk){check();const part=await bytes.slice(cursor,cursor+chunk).arrayBuffer();check();hash.update(new Uint8Array(part));if(cursor+chunk<bytes.size){await yieldTask();check();}}
check();return hash.hex(); check();return hash.hex();
} }
+11 -4
View File
@@ -9,7 +9,10 @@ import type {ImportSource,ImportLine} from './imports.ts';
import {fault,validJson} from '../canvas-core/values.ts'; import {fault,validJson} from '../canvas-core/values.ts';
/** Seekable JSON array reader. Cursors are byte offsets after '[' or a record comma. /** Seekable JSON array reader. Cursors are byte offsets after '[' or a record comma.
* Structure errors end the source; previously published records remain explicit. */ * Structure errors end the source; previously published records remain explicit. */
/** 捕获Blob/指纹与读取预算,缓存一块源字节;本解析器不分配业务ID、不发布history或发送请求。 */ /** 捕获 Blob/max/chunk 与返回指纹标量,按字节 offset 每次返回一条记录,缓存一个读取块。
* readBytes 与调度选项仍从原 options 读取,调用方应保持它们稳定;这里不建立源摘要/读取证明。
* 默认单条 256KiB/块 16KiB,未给 maxRecordBytes 设置总上限,也不限制源文件总长。
* 不分配业务 ID、不发布 history 或发送请求;缓存不是持久进度,合法边界由 runner 保存。 */
export function createJsonArrayImportSource(options:{bytes:Blob;fingerprint:string;readBytes?:ImportByteReader;maxRecordBytes?:number;chunkBytes?:number}&ImportScheduling):ImportSource{ export function createJsonArrayImportSource(options:{bytes:Blob;fingerprint:string;readBytes?:ImportByteReader;maxRecordBytes?:number;chunkBytes?:number}&ImportScheduling):ImportSource{
const bytes=options.bytes,max=options.maxRecordBytes??256*1024,chunk=options.chunkBytes??16384; const bytes=options.bytes,max=options.maxRecordBytes??256*1024,chunk=options.chunkBytes??16384;
if(!(bytes instanceof Blob)||!options.fingerprint||!Number.isSafeInteger(max)||max<1||!Number.isSafeInteger(chunk)||chunk<1||chunk>max)throw fault('invalid_import_source'); if(!(bytes instanceof Blob)||!options.fingerprint||!Number.isSafeInteger(max)||max<1||!Number.isSafeInteger(chunk)||chunk<1||chunk>max)throw fault('invalid_import_source');
@@ -18,6 +21,7 @@ export function createJsonArrayImportSource(options:{bytes:Blob;fingerprint:stri
/** read每次返回一个ImportLine及nextCursor;EOF返回null,源级I/O失败仍抛出而不伪装坏记录。 */ /** read每次返回一个ImportLine及nextCursor;EOF返回null,源级I/O失败仍抛出而不伪装坏记录。 */
return Object.freeze({fingerprint:options.fingerprint,parserVersion:3,async read(start:number,signal:AbortSignal):Promise<ImportLine|null>{ return Object.freeze({fingerprint:options.fingerprint,parserVersion:3,async read(start:number,signal:AbortSignal):Promise<ImportLine|null>{
if(!Number.isSafeInteger(start)||start<0||start>bytes.size)throw fault('invalid_import_cursor'); if(!Number.isSafeInteger(start)||start<0||start>bytes.size)throw fault('invalid_import_cursor');
// EOF offset 直接返回 null,不重扫先前数组结构;任意范围内 seek 也不证明是合法记录起点。
if(start>0&&start===bytes.size){if(signal.aborted)throw fault('import_aborted');return null;} if(start>0&&start===bytes.size){if(signal.aborted)throw fault('import_aborted');return null;}
if(bytes.size===0)throw fault('invalid_json_array'); if(bytes.size===0)throw fault('invalid_json_array');
let cursor=start;const budget=createImportBudget(signal,options); let cursor=start;const budget=createImportBudget(signal,options);
@@ -32,7 +36,8 @@ export function createJsonArrayImportSource(options:{bytes:Blob;fingerprint:stri
await spaces();if(start===0){if(await peek()!==91)return failure('invalid_json_array');await advance();await spaces();} await spaces();if(start===0){if(await peek()!==91)return failure('invalid_json_array');await advance();await spaces();}
if(await peek()===93){await advance();await spaces();if(cursor!==bytes.size)return failure('invalid_json_array');if(start!==0){/* Empty tail is valid only after a record, not a trailing comma. */return failure('invalid_json_array');}return null;} if(await peek()===93){await advance();await spaces();if(cursor!==bytes.size)return failure('invalid_json_array');if(start!==0){/* Empty tail is valid only after a record, not a trailing comma. */return failure('invalid_json_array');}return null;}
if(await peek()===-1)return failure('incomplete_json_array'); if(await peek()===-1)return failure('incomplete_json_array');
// quoted/escaped隔离字符串分隔符,stack配对{}与[];超大小后仍扫描至合法边界但不再保留字节。 // parts 是至多 max 个字节数值的普通数组(内存成本不等于 max 字节),stack 至多保留 256 层括号。
// quoted/escaped 只定位边界,不完整校验 JSON;超大小后仍扫描但不再收集,坏结构仍可能终结整个源。
const recordStart=cursor,parts:number[]=[],stack:number[]=[];let quoted=false,escaped=false,oversized=false; const recordStart=cursor,parts:number[]=[],stack:number[]=[];let quoted=false,escaped=false,oversized=false;
while(true){const byte=await peek();if(byte===-1)return failure('incomplete_json_array'); while(true){const byte=await peek();if(byte===-1)return failure('incomplete_json_array');
if(!quoted&&stack.length===0&&(byte===44||byte===93))break; if(!quoted&&stack.length===0&&(byte===44||byte===93))break;
@@ -41,13 +46,15 @@ export function createJsonArrayImportSource(options:{bytes:Blob;fingerprint:stri
else if(byte===34)quoted=true;else if(byte===123||byte===91){stack.push(byte);if(stack.length>256)return failure('json_nesting_limit');}else if(byte===125||byte===93){if(stack.pop()!==(byte===125?123:91))return failure('invalid_json_array');} else if(byte===34)quoted=true;else if(byte===123||byte===91){stack.push(byte);if(stack.length>256)return failure('json_nesting_limit');}else if(byte===125||byte===93){if(stack.pop()!==(byte===125?123:91))return failure('invalid_json_array');}
await advance(); await advance();
} }
// 消费外层逗号或]并检查尾部/尾逗号;内嵌数组的]已由stack处理,不会截断记录。 // 先确认记录后的外层分隔与尾部,再返回此记录;若尾逗号/垃圾在此处发现,本记录也返回结构失败。
// 数组内嵌 ] 已由 stack 处理;nextCursor 消费分隔和后续空白,最后一项则推进到真实 EOF。
const delimiter=await peek();await advance(); const delimiter=await peek();await advance();
if(delimiter===93){await spaces();if(cursor!==bytes.size)return failure('invalid_json_array');} if(delimiter===93){await spaces();if(cursor!==bytes.size)return failure('invalid_json_array');}
else {await spaces();if(await peek()===93||await peek()===-1)return failure('invalid_json_array');} else {await spaces();if(await peek()===93||await peek()===-1)return failure('invalid_json_array');}
const base={cursor:start,nextCursor:cursor,key:`byte:${start}`}; const base={cursor:start,nextCursor:cursor,key:`byte:${start}`};
if(oversized)return{...base,error:'record_too_large'}; if(oversized)return{...base,error:'record_too_large'};
// 只解码这一记录:fatal UTF8防替换字符掩盖损坏,再JSON.parse/validJson校验值域。 // fatal UTF-8 与 JSON.parse 在边界已确定后处理,失败可随 nextCursor 继续下一项;不做元素业务 schema 校验。
// JSON.parse 同步完成且无中途取消,记录扫描的预算不抢占解码/解析;调用方仍需在采纳结果前校验生命周期。
let text:string;try{text=new TextDecoder('utf-8',{fatal:true}).decode(new Uint8Array(parts));}catch{return{...base,error:'invalid_utf8'};} let text:string;try{text=new TextDecoder('utf-8',{fatal:true}).decode(new Uint8Array(parts));}catch{return{...base,error:'invalid_utf8'};}
try{const value=JSON.parse(text);return validJson(value)?{...base,value}:{...base,error:'invalid_json_value'};}catch{return{...base,error:'invalid_json'};} try{const value=JSON.parse(text);return validJson(value)?{...base,value}:{...base,error:'invalid_json_value'};}catch{return{...base,error:'invalid_json'};}
}}); }});
+19 -6
View File
@@ -1,6 +1,7 @@
/** /**
* 原生导入记录到纯ImportUnit的转换:源ID经reserve映射为新身份,依赖保留为source key等待闭合。 * 原生导入记录到纯ImportUnit的转换:源ID经reserve映射为新身份,依赖保留为source key等待闭合。
* $target或文档源根映射到显式选定的现有canvas;副本incarnation从1开始,不继承旧墓碑/任务执行。 * $target或文档源根映射到显式选定的现有canvas;副本incarnation从1开始,不继承旧墓碑。
* 任务字段只按组件copy.omit声明去除;转换不创建/启动生成任务,不能据此假定所有未知执行字段都已过滤。
* 本模块只规划changes,不写文档、不上传/授权;资源地址和授权事实由宿主/engine另行提供与校验。 * 本模块只规划changes,不写文档、不上传/授权;资源地址和授权事实由宿主/engine另行提供与校验。
*/ */
import type { Document, ElementRecord, Id, Json, Registry } from '../canvas-core/types.ts'; import type { Document, ElementRecord, Id, Json, Registry } from '../canvas-core/types.ts';
@@ -9,14 +10,18 @@ import { remapCopyData } from '../canvas-core/duplicate.ts';
import { clone, fault } from '../canvas-core/values.ts'; import { clone, fault } from '../canvas-core/values.ts';
import type { ImportJob, ImportLine, ImportUnit } from './imports.ts'; import type { ImportJob, ImportLine, ImportUnit } from './imports.ts';
/** 校验目标canvas仍存在且入口ID/代次或根身份未变,防旧任务把尾批导入后来替换的分区。 */ /** 按 source.targetCanvasId 定位当前 canvas;有 targetEntry 时核对入口 ID 与元素代次,否则要求根。
* 不比较整个祖先链/父 canvas、入口组件或 job.projectId;同入口移动后的目标仍可通过此局部身份检查。
* 返回原 source 引用,不生成新任务或锁住目标,后续提交前还需再检查当前文档。 */
export function assertNativeImportTarget(job:ImportJob,document:Document){ export function assertNativeImportTarget(job:ImportJob,document:Document){
const source=job.source,canvas=source?document.canvases.get(source.targetCanvasId):undefined; const source=job.source,canvas=source?document.canvases.get(source.targetCanvasId):undefined;
if(!source||!canvas)throw fault('import_target_missing'); if(!source||!canvas)throw fault('import_target_missing');
if(source.targetEntry?(canvas.parent?.entryElementId!==source.targetEntry.id||document.elements.get(source.targetEntry.id)?.incarnation!==source.targetEntry.incarnation):canvas.parent!==null)throw fault('import_target_changed'); if(source.targetEntry?(canvas.parent?.entryElementId!==source.targetEntry.id||document.elements.get(source.targetEntry.id)?.incarnation!==source.targetEntry.incarnation):canvas.parent!==null)throw fault('import_target_changed');
return source; return source;
} }
/** 已committed依赖必须仍是原绑定生命周期;用户删后重建同ID不能被后续导入误认领。 */ /** 仅检查已在 committedSourceKeys 的依赖:stableIds 定位目标,再以 job.bindings 核对生命周期。
* element 检查代次,canvas 检查仍存在、入口 ID 及已记录入口代次;不比较正文、几何、组件或全部 owner 链。
* 未提交/SCC 内部依赖不在这里猜成存在,留给分组选取与原子创建后的文档校验;本函数不改变绑定。 */
export function assertNativeImportDependencies(job:ImportJob,document:Document,dependencies:readonly string[]){ export function assertNativeImportDependencies(job:ImportJob,document:Document,dependencies:readonly string[]){
for(const key of dependencies)if(job.committedSourceKeys.includes(key)){ for(const key of dependencies)if(job.committedSourceKeys.includes(key)){
const id=job.stableIds[key],binding=job.bindings?.[id]; const id=job.stableIds[key],binding=job.bindings?.[id];
@@ -27,7 +32,11 @@ export function assertNativeImportDependencies(job:ImportJob,document:Document,d
} }
/** Native NDJSON records use source IDs; $target names the explicitly chosen existing canvas. */ /** Native NDJSON records use source IDs; $target names the explicitly chosen existing canvas. */
/** 校验记录封装/源ID,预留自身与引用ID,生成一条创建变化及去除自引用后的依赖列表。 */ /** 校验封装/源 ID 后克隆 record,reserve 自身与引用键,生成一条 before=null 的创建变化和依赖列表。
* reserve 可能先于后续类型校验调用;本函数不回滚自定义 reserve 的副作用,runner 应使用私有捕获上下文。
* registry.resolve 只确认已知类型/版本与 copy 声明,不等同完整数据/几何/引用校验;最终提交仍需验证文档。
* 原几何/其它字段按记录携带,不在此统一转换世界坐标,也不检查所有目标 ID 的文档/墓碑冲突。
* 返回的 ImportUnit/changes envelope 未整体冻结,交 runner 捕获、计成本、持久化和发布。 */
export function prepareNativeImport(line:ImportLine,job:ImportJob,reserve:(key:string)=>Id,document:Document,registry:Registry,resourceURL?:(id:Id)=>string):ImportUnit { export function prepareNativeImport(line:ImportLine,job:ImportJob,reserve:(key:string)=>Id,document:Document,registry:Registry,resourceURL?:(id:Id)=>string):ImportUnit {
const source=assertNativeImportTarget(job,document); const source=assertNativeImportTarget(job,document);
const raw=line.value as Record<string,Json>|undefined; const raw=line.value as Record<string,Json>|undefined;
@@ -39,7 +48,8 @@ export function prepareNativeImport(line:ImportLine,job:ImportJob,reserve:(key:s
/** 根/$target直接重定向目标,子canvas则分配稳定新ID并登记canvas依赖。 */ /** 根/$target直接重定向目标,子canvas则分配稳定新ID并登记canvas依赖。 */
const owner=(id:Json):Id=>{if(id==='$target'||source.kind==='native-document'&&id===raw.sourceRootId){canvasIds.set(id as string,source.targetCanvasId);return source.targetCanvasId;}if(typeof id!=='string'||!id)throw fault('invalid_import_reference');const key=`canvas:${id}`;dependencies.add(key);const mapped=reserve(key);canvasIds.set(id,mapped);return mapped;}; const owner=(id:Json):Id=>{if(id==='$target'||source.kind==='native-document'&&id===raw.sourceRootId){canvasIds.set(id as string,source.targetCanvasId);return source.targetCanvasId;}if(typeof id!=='string'||!id)throw fault('invalid_import_reference');const key=`canvas:${id}`;dependencies.add(key);const mapped=reserve(key);canvasIds.set(id,mapped);return mapped;};
const key=`${raw.kind}:${raw.key}`,id=reserve(key); const key=`${raw.kind}:${raw.key}`,id=reserve(key);
// 不把源根作为第二个根插入;这里只处理有parent的子canvas,parent与入口都使用新映射。 // 只接受带 parent 的子 canvas;源根由读取器排除,再通过 sourceRootId/$target alias 接到现有目标。
// parent/record 其它属性沿用克隆数据,额外 schema/树约束不会在此分支自动补齐。
if(raw.kind==='canvas'){ if(raw.kind==='canvas'){
const parent=record.parent as Record<string,Json>|null; const parent=record.parent as Record<string,Json>|null;
if(!parent||typeof parent!=='object'||Array.isArray(parent))throw fault('invalid_import_canvas'); if(!parent||typeof parent!=='object'||Array.isArray(parent))throw fault('invalid_import_canvas');
@@ -49,7 +59,8 @@ export function prepareNativeImport(line:ImportLine,job:ImportJob,reserve:(key:s
// record是脱离源的副本,original是其类型视图;未知组件/缺复制声明不猜字段含义。 // record是脱离源的副本,original是其类型视图;未知组件/缺复制声明不猜字段含义。
const original=record as unknown as ElementRecord,definition=registry.components.get(original.componentId); const original=record as unknown as ElementRecord,definition=registry.components.get(original.componentId);
if(registry.resolve(original)!=='known'||!definition?.copy||!original.data||typeof original.data!=='object'||Array.isArray(original.data))throw fault('import_component_unavailable'); if(registry.resolve(original)!=='known'||!definition?.copy||!original.data||typeof original.data!=='object'||Array.isArray(original.data))throw fault('import_component_unavailable');
// 文档图片需宿主提供规范资源URL;仅重建引用不代表完成授权,授权由运行时后续门禁证明。 // native-document 的非空资源字段要求宿主 resourceURL:值须为非空 ID,assetId 还重建 data.src。
// callback 只是地址提供者,此处不读取字节/校验权限;其它资源字段不自动改名,NDJSON 也不走此文档专用分支。
if(source.kind==='native-document')for(const key of definition.resourceFields??[]){const value=original.data[key];if(value===undefined||value===null)continue;if(!resourceURL)throw fault('import_resource_authorization_required');if(typeof value!=='string'||!value)throw fault('invalid_import_reference');if(key==='assetId')record.data={...original.data,src:resourceURL(value)};} if(source.kind==='native-document')for(const key of definition.resourceFields??[]){const value=original.data[key];if(value===undefined||value===null)continue;if(!resourceURL)throw fault('import_resource_authorization_required');if(typeof value!=='string'||!value)throw fault('invalid_import_reference');if(key==='assetId')record.data={...original.data,src:resourceURL(value)};}
record.data=original.data; record.data=original.data;
// 只遍历schema声明引用,避免重写普通文字里的ID;omit字段由共享remapCopyData处理。 // 只遍历schema声明引用,避免重写普通文字里的ID;omit字段由共享remapCopyData处理。
@@ -60,5 +71,7 @@ export function prepareNativeImport(line:ImportLine,job:ImportJob,reserve:(key:s
if(!original.source||!original.target)throw fault('invalid_import_endpoint'); if(!original.source||!original.target)throw fault('invalid_import_endpoint');
Object.assign(after,{source:{...original.source,elementId:element(original.source.elementId)},target:{...original.target,elementId:element(original.target.elementId)}}); Object.assign(after,{source:{...original.source,elementId:element(original.source.elementId)},target:{...original.target,elementId:element(original.target.elementId)}});
}else if(original.structure!=='node')throw fault('invalid_import_structure'); }else if(original.structure!=='node')throw fault('invalid_import_structure');
// Set 保留引用首次遇到的顺序并去重;删除自身依赖避免单节点自引用被误当外部等待。
// 尚未出现的定义仍有稳定 reserve ID/依赖键,缺失定义由 runner 后续报告,不直接连回源项目。
dependencies.delete(key);return{key,dependencies:[...dependencies],changes:[{kind:'element',id,before:null,after:after as ElementRecord}]}; dependencies.delete(key);return{key,dependencies:[...dependencies],changes:[{kind:'element',id,before:null,after:after as ElementRecord}]};
} }
+22 -7
View File
@@ -15,7 +15,9 @@ const MAX_SOURCE_BYTES=64*1024*1024;
/** version=1是块证明DTO版本,与source.integrityVersion=2的业务封存协议不是同一版本域。 */ /** version=1是块证明DTO版本,与source.integrityVersion=2的业务封存协议不是同一版本域。 */
export type ImportReadProof={readonly version:1;readonly fileId:string;readonly byteLength:number;readonly blockBytes:16384;readonly blocks:readonly {readonly index:number;readonly sha256:string}[]}; export type ImportReadProof={readonly version:1;readonly fileId:string;readonly byteLength:number;readonly blockBytes:16384;readonly blocks:readonly {readonly index:number;readonly sha256:string}[]};
/** This binds only read blocks. It is not a whole-source digest or permission to send mutations. */ /** This binds only read blocks. It is not a whole-source digest or permission to send mutations. */
/** 校验fileId/长度/固定块大小、严格递增的合法块索引及摘要;可表示只读过部分非连续块。 */ /** 校验源身份/长度、固定块宽、严格递增的合法块索引、摘要格式及允许字段,再捕获冻结 DTO。
* 可包含不连续的部分块,不要求完整前缀;这里不读 Blob 或验证摘要真实性,也不是整源 SHA。
* 缺记录/非法 JSON/未知版本报解释器不可用,其它身份/结构错误报 invalid_import_read_proof。 */
export function validateImportReadProof(value:unknown,fileId:string,byteLength:number):ImportReadProof{ export function validateImportReadProof(value:unknown,fileId:string,byteLength:number):ImportReadProof{
if(typeof fileId!=='string'||!fileId||!Number.isSafeInteger(byteLength)||byteLength<0||byteLength>MAX_SOURCE_BYTES)throw fault('invalid_import_read_proof'); if(typeof fileId!=='string'||!fileId||!Number.isSafeInteger(byteLength)||byteLength<0||byteLength>MAX_SOURCE_BYTES)throw fault('invalid_import_read_proof');
const proof=value as ImportReadProof; const proof=value as ImportReadProof;
@@ -29,7 +31,10 @@ export function validateImportReadProof(value:unknown,fileId:string,byteLength:n
/** Bounded, seekable verified reads for parsers; checkpoints contain hashes, never byte buffers. /** Bounded, seekable verified reads for parsers; checkpoints contain hashes, never byte buffers.
* Restored evidence must be checked with verify() before a runner resumes business changes. */ * Restored evidence must be checked with verify() before a runner resumes business changes. */
/** 创建可seek的验证reader;初始证明与源绑定,port.read成功后才把新块追加到本地证明。 */ /** 建立 seek reader:hashes 保存期望摘要,resident 是按 Map 顺序淘汰的 LRU,pending 合并同块在途读取。
* 初始 proof 仅结构校验即进入 hashes/getProof;没有 verified 标志,调用方须在恢复业务前主动 verify。
* 最多 4 个活跃非空 range、4 个不同块的原生读取;驻留默认 4 块(最多 64),单 range 默认/最多 4MiB。
* range 输出、在途块和 Blob 本身另占内存,residentLimit 不是整体内存硬上限;本实例不持久化证明。 */
export function createImportReadProof(input:{bytes:Blob;fileId:string;proof?:ImportReadProof;maxResidentBlocks?:number;maxReadBytes?:number}&ImportScheduling){ export function createImportReadProof(input:{bytes:Blob;fileId:string;proof?:ImportReadProof;maxResidentBlocks?:number;maxReadBytes?:number}&ImportScheduling){
const options=Object.freeze({...input}); const options=Object.freeze({...input});
const {bytes,fileId}=options,residentLimit=options.maxResidentBlocks??4,readLimit=options.maxReadBytes??4*1024*1024; const {bytes,fileId}=options,residentLimit=options.maxResidentBlocks??4,readLimit=options.maxReadBytes??4*1024*1024;
@@ -40,13 +45,17 @@ export function createImportReadProof(input:{bytes:Blob;fileId:string;proof?:Imp
type Block={index:number;sha256:string;data:Uint8Array}; type Block={index:number;sha256:string;data:Uint8Array};
const resident=new Map<number,Block>(),pending=new Map<number,Promise<Block>>(),lifecycle=new AbortController(); const resident=new Map<number,Block>(),pending=new Map<number,Promise<Block>>(),lifecycle=new AbortController();
let closed=false,activeReads=0,proof:ImportReadProof=initial; let closed=false,activeReads=0,proof:ImportReadProof=initial;
/** 每次等待前后检查生命周期/取消;close是源关闭,调用者abort是停止此订阅采纳。 */ /** 入口/成功采纳时检查 closed 与 signal,关闭优先报 import_source_closed,其次报 import_aborted。
* 单个订阅取消只停止等待,不终止共享原生块读取;已拒绝的底层 Promise 仍可能保留自己的原始错误。 */
const check=(signal:AbortSignal)=>{if(closed)throw fault('import_source_closed');if(signal.aborted)throw fault('import_aborted');}; const check=(signal:AbortSignal)=>{if(closed)throw fault('import_source_closed');if(signal.aborted)throw fault('import_aborted');};
/** 单调用可取消等待共享块Promise,不强制取消可能正被其它range复用的只读底层读取。 */ /** 单调用可取消等待共享块Promise,不强制取消可能正被其它range复用的只读底层读取。 */
function wait<T>(task:Promise<T>,signal:AbortSignal):Promise<T>{ function wait<T>(task:Promise<T>,signal:AbortSignal):Promise<T>{
check(signal);return new Promise((resolve,reject)=>{const abort=()=>{try{check(signal);}catch(error){reject(error);}};signal.addEventListener('abort',abort,{once:true});task.then(value=>{signal.removeEventListener('abort',abort);try{check(signal);resolve(value);}catch(error){reject(error);}},error=>{signal.removeEventListener('abort',abort);reject(error);});}); check(signal);return new Promise((resolve,reject)=>{const abort=()=>{try{check(signal);}catch(error){reject(error);}};signal.addEventListener('abort',abort,{once:true});task.then(value=>{signal.removeEventListener('abort',abort);try{check(signal);resolve(value);}catch(error){reject(error);}},error=>{signal.removeEventListener('abort',abort);reject(error);});});
} }
/** 先命中LRU或共享pending,再读块并对旧摘要;新原图与已证明块不符即源改变。 */ /** 命中 resident 时移动到 Map 尾部作为最近使用,命中 pending 时复用同一 Promise。
* 缺块就读完整对齐 16KiB(最后块截 EOF)并计算 SHA,可能覆盖请求 range 之外的相邻字节。
* 比对 hashes 中旧期望后放 resident,但不在此新增持久候选 proof;range 失败也可留下可复用缓存。
* 缓存命中不重新读 Blob/算摘要,依赖 Blob 不可变合同;淘汰只丢字节,不丢已提交的期望摘要。 */
function block(index:number):Promise<Block>{ function block(index:number):Promise<Block>{
const cached=resident.get(index);if(cached){resident.delete(index);resident.set(index,cached);return Promise.resolve(cached);} const cached=resident.get(index);if(cached){resident.delete(index);resident.set(index,cached);return Promise.resolve(cached);}
const active=pending.get(index);if(active)return active; const active=pending.get(index);if(active)return active;
@@ -61,7 +70,10 @@ export function createImportReadProof(input:{bytes:Blob;fileId:string;proof?:Imp
})(); })();
pending.set(index,task);void task.finally(()=>{if(pending.get(index)===task)pending.delete(index);}).catch(()=>{});return task; pending.set(index,task);void task.finally(()=>{if(pending.get(index)===task)pending.delete(index);}).catch(()=>{});return task;
} }
/** 精确range端口:只分配所需输出,逐块拼接并按软预算让出,最后成功才提交used块证明。 */ /** 精确 range 端口:要求 end ≤ EOF,不自行截断;空范围在容量检查前返回,不读块/新增证明。
* 非空范围分配独立输出并逐块拼接,每块向预算记 128 单位,最后检查取消与并发摘要一致性。
* 只有整次范围成功才将 used 中新块加入 hashes/proof;取消/失败不提交本次部分块,也不回退其它成功读取的证明。
* activeReads 随等待退出而归还,真实不可取消 I/O 仍占 pending 容量,不能用大量取消绕过底层上限。 */
const port:ImportByteReader=Object.freeze({byteLength:bytes.size,async read(start:number,end:number,signal:AbortSignal){ const port:ImportByteReader=Object.freeze({byteLength:bytes.size,async read(start:number,end:number,signal:AbortSignal){
const combined=AbortSignal.any([signal,lifecycle.signal]);check(combined); const combined=AbortSignal.any([signal,lifecycle.signal]);check(combined);
if(!Number.isSafeInteger(start)||!Number.isSafeInteger(end)||start<0||end<start||end>bytes.size||end-start>readLimit)throw fault('invalid_import_byte_range'); if(!Number.isSafeInteger(start)||!Number.isSafeInteger(end)||start<0||end<start||end>bytes.size||end-start>readLimit)throw fault('invalid_import_byte_range');
@@ -81,9 +93,12 @@ export function createImportReadProof(input:{bytes:Blob;fileId:string;proof?:Imp
}finally{activeReads--;} }finally{activeReads--;}
}}); }});
return Object.freeze({port,getProof:()=>proof, return Object.freeze({port,getProof:()=>proof,
/** 重开后验证所有已保存块;不扫描尚未读到的尾部,故不能代替完整SHA或已durable封存。 */ /** 对调用开始时 hashes 中的索引逐块核对,不增加新证明块、不产生完整源摘要或自动持久化。
* 重开实例缓存为空时实际重读旧块;同实例调用可命中 resident,不强制绕过缓存重新读物理字节。
* 不核对尚未证明的尾部,也不锁住并发 range;是否已安全恢复、何时封存由引擎决定。 */
async verify(signal:AbortSignal){const combined=AbortSignal.any([signal,lifecycle.signal]),budget=createImportBudget(combined,options);check(combined);for(const index of [...hashes.keys()]){await wait(block(index),combined);await budget.step(128);}check(combined);}, async verify(signal:AbortSignal){const combined=AbortSignal.any([signal,lifecycle.signal]),budget=createImportBudget(combined,options);check(combined);for(const index of [...hashes.keys()]){await wait(block(index),combined);await budget.step(128);}check(combined);},
/** 幂等关闭,清驻留字节并取消等待者;已开始的只读Blob读取可能迟到,由closed检查拒绝采纳。 */ /** 幂等关闭并清 resident/取消等待者,不清 hashes/proof,getProof 仍可读取最后不可变快照。
* 不等待原生 Blob 读取退出;pending 由各 Promise finally 清理,迟到读取经 closed 检查拒绝进入缓存。 */
close(){if(closed)return;closed=true;resident.clear();lifecycle.abort();}, close(){if(closed)return;closed=true;resident.clear();lifecycle.abort();},
/** 只读诊断:驻留/活range/pending块/证明块分别计数,不当作导入完成百分比。 */ /** 只读诊断:驻留/活range/pending块/证明块分别计数,不当作导入完成百分比。 */
stats:()=>Object.freeze({residentBlocks:resident.size,activeReads,pendingBlocks:pending.size,proofBlocks:hashes.size,closed}), stats:()=>Object.freeze({residentBlocks:resident.size,activeReads,pendingBlocks:pending.size,proofBlocks:hashes.size,closed}),
+12 -4
View File
@@ -8,20 +8,28 @@ import {importNeedsSeal,validateImportJob,type ImportJob} from './imports.ts';
import {validateImportReadProof} from './import-read-proof.ts'; import {validateImportReadProof} from './import-read-proof.ts';
/** A derived durable-cut index, never an in-memory permission boolean. */ /** A derived durable-cut index, never an in-memory permission boolean. */
/** 从给定job表派生fileId→SHA索引;调用者须传已可靠保存的cut,本函数不执行落盘。 */ /** 从 integrity-v2、带 SHA 且无 integrityError 的 job 派生 fileId→SHA Map,不扫描字节或读取存储。
* 调用方必须传已持久 cut;函数不证明其来源可靠,也不独立调用 validateImportJob 验摘要。
* 返回实际可变 Map,若重复 fileId 后项覆盖前项;文件身份唯一性由外围约束保证。 */
export function importSeals(jobs:ReadonlyMap<Id,ImportJob>|undefined){ export function importSeals(jobs:ReadonlyMap<Id,ImportJob>|undefined){
const seals=new Map<Id,string>();for(const job of jobs?.values()??[])if(job.source?.integrityVersion===2&&job.source.sha256&&!job.source.integrityError)seals.set(job.source.fileId,job.source.sha256);return seals; const seals=new Map<Id,string>();for(const job of jobs?.values()??[])if(job.source?.integrityVersion===2&&job.source.sha256&&!job.source.integrityError)seals.set(job.source.fileId,job.source.sha256);return seals;
} }
/** integrity-v2任务需自身已完整验证且durable seals匹配;取消且零已提交前缀的任务可不阻塞。 */ /** 整个 job 表的发送闸门:每个非豁免 integrity-v2 源都须无需 seal 且与 seals 中 SHA 相等。
* 取消且 committedSourceKeys 为空者豁免;不按本次请求挑选 job,也不重验块证明/实际存储事务。
* 其它 integrityVersion 在此跳过,是否支持/合法由 job 解释器先校验,不能把 true 当所有源都已验证。 */
export function importsMaySend(jobs:ReadonlyMap<Id,ImportJob>|undefined,seals:ReadonlyMap<Id,string>){ export function importsMaySend(jobs:ReadonlyMap<Id,ImportJob>|undefined,seals:ReadonlyMap<Id,string>){
for(const job of jobs?.values()??[])if(job.source?.integrityVersion===2&&!(job.status==='cancelled'&&!job.committedSourceKeys.length)&&(importNeedsSeal(job)||seals.get(job.source.fileId)!==job.source.sha256))return false;return true; for(const job of jobs?.values()??[])if(job.source?.integrityVersion===2&&!(job.status==='cancelled'&&!job.committedSourceKeys.length)&&(importNeedsSeal(job)||seals.get(job.source.fileId)!==job.source.sha256))return false;return true;
} }
/** Only engine-owned publication paths may advance read evidence or source verification. */ /** Only engine-owned publication paths may advance read evidence or source verification. */
/** 发布前保护引擎拥有的readProof/SHA/执行进度,未持owner任务ID的路径不能伪造或删除它们。 */ /** 比较前后 job 表保护 integrity-v2 证据,owner 是引擎传入的当前任务 ID,不是外部权限令牌。
* 同一表引用直接返回,依赖不可变状态合同;v2 job 不得删除或降级,即使 owner 匹配也不例外。
* 每个 next v2 job 验结构且要求 proof 索引为从 0 开始的连续前缀,比通用 seek reader 更严格。
* owner 可推进证据/执行事实,仍不能改 fileId/长度/已有 SHA 或丢改旧块;此处不重算字节或检查持久水位。 */
export function assertImportEvidence(previous:ReadonlyMap<Id,ImportJob>|undefined,next:ReadonlyMap<Id,ImportJob>|undefined,owner:Id|undefined){ export function assertImportEvidence(previous:ReadonlyMap<Id,ImportJob>|undefined,next:ReadonlyMap<Id,ImportJob>|undefined,owner:Id|undefined){
if(previous===next)return; if(previous===next)return;
for(const[id,job]of previous??[])if(job.source?.integrityVersion===2&&!next?.has(id))throw fault('import_integrity_owned',[id]); for(const[id,job]of previous??[])if(job.source?.integrityVersion===2&&!next?.has(id))throw fault('import_integrity_owned',[id]);
/** 对比执行事实时剔除generation和源retirement生命周期字段;其它进度/身份仍要求合法owner。 */ /** 比较执行事实时仅剔除 job.generation 和当前 source.retirement;previousFiles 等内容仍参与比较。
* 允许普通保留流程调整这些排除字段,不在这里证明 generation/退休时钟单调,交其它发布校验处理。 */
const execution=(job:ImportJob|undefined)=>{if(!job)return job;const{generation:_,source,...body}=job;if(!source)return body;const{retirement:__,...identity}=source;return {...body,source:identity};}; const execution=(job:ImportJob|undefined)=>{if(!job)return job;const{generation:_,source,...body}=job;if(!source)return body;const{retirement:__,...identity}=source;return {...body,source:identity};};
for(const[id,job]of next??[]){const source=job.source,old=previous?.get(id)?.source;if(source?.integrityVersion!==2){if(old?.integrityVersion===2)throw fault('import_integrity_owned',[id]);continue;} for(const[id,job]of next??[]){const source=job.source,old=previous?.get(id)?.source;if(source?.integrityVersion!==2){if(old?.integrityVersion===2)throw fault('import_integrity_owned',[id]);continue;}
validateImportJob(job);const proof=validateImportReadProof(source.readProof,source.fileId,source.byteLength); validateImportJob(job);const proof=validateImportReadProof(source.readProof,source.fileId,source.byteLength);
+25 -13
View File
@@ -26,14 +26,17 @@ export type FrozenRequest = {
}; };
/** 意图及其本地解析状态;blocked 不等于丢弃,elided/retired 不等于收到服务器 applied 回执。 */ /** 意图及其本地解析状态;blocked 不等于丢弃,elided/retired 不等于收到服务器 applied 回执。 */
export type OperationRecord = { readonly intent: OperationIntent; readonly phase: 'pending' | 'frozen' | 'resolved' | 'blocked'; readonly requestId?: Id; readonly blockers: readonly string[]; readonly resolution?: 'applied' | 'not-applied' | 'superseded' | 'discarded' | 'satisfied' | 'elided'; readonly elisionId?: Id; readonly elision?: ElisionProof; readonly recoveryId?: Id; readonly replacementId?: Id; readonly basePresence: Readonly<Record<Id, Presence>> }; export type OperationRecord = { readonly intent: OperationIntent; readonly phase: 'pending' | 'frozen' | 'resolved' | 'blocked'; readonly requestId?: Id; readonly blockers: readonly string[]; readonly resolution?: 'applied' | 'not-applied' | 'superseded' | 'discarded' | 'satisfied' | 'elided'; readonly elisionId?: Id; readonly elision?: ElisionProof; readonly recoveryId?: Id; readonly replacementId?: Id; readonly basePresence: Readonly<Record<Id, Presence>> };
/** generation 随账本事实改变递增,durableGeneration 仅确认已实际存储的前缀。 */ /** generation 随账本事实改变递增;durableGeneration 是宿主报告的已存前缀。
* markDurable 只检查水位范围,本模块不访问存储或证明 fsync/事务真的完成。 */
export type LedgerSnapshot = { readonly generation: number; readonly durableGeneration: number; readonly operations: ReadonlyMap<Id, OperationRecord>; readonly requests: ReadonlyMap<Id, FrozenRequest> }; export type LedgerSnapshot = { readonly generation: number; readonly durableGeneration: number; readonly operations: ReadonlyMap<Id, OperationRecord>; readonly requests: ReadonlyMap<Id, FrozenRequest> };
/** A field-safe compressor for a wholly unsent segment. Frozen records never enter here. */ /** A field-safe compressor for a wholly unsent segment. Frozen records never enter here. */
/** 仅压缩完全未冻结的变化段:create 后字段修改并入新对象,同字段连续变化合并。 /** 用于调用方已确认完全未冻结的变化段:合并 create 后字段修改及同字段链。
* preserveLifecycle 保留创建/删除,preserveChecks 保留条件 no-op;不能用最后整节点覆盖替代字段链。 */ * 本函数不读 ledger、basePresence 或 possiblyApplied,不能单独证明可抵消已发送/未知结果的生命周期。
* preserveLifecycle 只阻止 create→delete 抵消,preserveChecks 只保留合并后字段 no-op;不是停用所有压缩。 */
export function compressChanges(changes: readonly Change[], options: { preserveLifecycle?: boolean; preserveChecks?: boolean } = {}): readonly Change[] { export function compressChanges(changes: readonly Change[], options: { preserveLifecycle?: boolean; preserveChecks?: boolean } = {}): readonly Change[] {
// positions 按元素/canvas ID 保存结果索引栈,只尝试合并同对象最近变化,保留原条件顺序。 // positions 按元素/canvas ID 保存索引栈,只尝试同对象最近一项;其它对象的变化可以夹在两项之间。
// 若同对象中间出现不同字段,则不跨过它继续向前找同字段,因此不是任意字段的全段最后值覆盖。
const result: (Change | undefined)[] = [], positions = new Map<string, number[]>(); const result: (Change | undefined)[] = [], positions = new Map<string, number[]>();
for (const change of changes) { for (const change of changes) {
const id = change.kind === 'field' ? change.target.id : change.id; const id = change.kind === 'field' ? change.target.id : change.id;
@@ -57,7 +60,8 @@ export function compressChanges(changes: readonly Change[], options: { preserveL
return freeze(result.filter((change): change is Change => change !== undefined)); return freeze(result.filter((change): change is Change => change !== undefined));
} }
/** 从已有快照复制可变表,所有有效修改后重新捕获只读快照;旧 Snapshot 的成员不会改变。 */ /** 从已有快照复制 Map 成员,修改后发布新的只读表视图;不在此验证完整恢复 DTO。
* 初始记录沿用引用,宿主须先解码捕获为不可变记录;构造器不是任意外部对象的深隔离入口。 */
export function createLedger(initial?: LedgerSnapshot) { export function createLedger(initial?: LedgerSnapshot) {
let generation = initial?.generation ?? 0, durableGeneration = initial?.durableGeneration ?? 0; let generation = initial?.generation ?? 0, durableGeneration = initial?.durableGeneration ?? 0;
const operations = new Map(initial?.operations), requests = new Map(initial?.requests); const operations = new Map(initial?.operations), requests = new Map(initial?.requests);
@@ -84,7 +88,8 @@ export function createLedger(initial?: LedgerSnapshot) {
return Object.freeze({ return Object.freeze({
/** 读取最新捕获的只读事实,不暴露内部可变表。 */ /** 读取最新捕获的只读事实,不暴露内部可变表。 */
getSnapshot: () => cached, getSnapshot: () => cached,
/** 校验身份/序列/基线与依赖,再添加 pending;同 ID 同意图幂等,改意图则拒绝。 */ /** 检查单条身份、正安全整数序列、基线枚举和依赖,再添加 pending。
* 不在此证明全账本 sequence 唯一/递增;同 ID 同意图直接返回,也不会更新第一次的 basePresence。 */
add(intent: OperationIntent, basePresence: Readonly<Record<Id, Presence>>) { add(intent: OperationIntent, basePresence: Readonly<Record<Id, Presence>>) {
if (!intent.id || !Number.isSafeInteger(intent.sequence) || intent.sequence < 1 || new Set(intent.dependencies).size !== intent.dependencies.length || Object.values(basePresence).some(value => !['absent', 'exists', 'unknown'].includes(value))) throw fault('invalid_operation'); if (!intent.id || !Number.isSafeInteger(intent.sequence) || intent.sequence < 1 || new Set(intent.dependencies).size !== intent.dependencies.length || Object.values(basePresence).some(value => !['absent', 'exists', 'unknown'].includes(value))) throw fault('invalid_operation');
const existing = operations.get(intent.id); const existing = operations.get(intent.id);
@@ -99,8 +104,9 @@ export function createLedger(initial?: LedgerSnapshot) {
}, },
/** 给可变意图设置/清除 blocker;有原因变 blocked,无原因回 pending,不改冻结请求。 */ /** 给可变意图设置/清除 blocker;有原因变 blocked,无原因回 pending,不改冻结请求。 */
block(id: Id, reasons: readonly string[]) { const operation = operations.get(id); if (!operation || !['pending', 'blocked'].includes(operation.phase)) return; operations.set(id, freeze({ ...operation, phase: reasons.length ? 'blocked' : 'pending', blockers: [...reasons] })); changed(); }, block(id: Id, reasons: readonly string[]) { const operation = operations.get(id); if (!operation || !['pending', 'blocked'].includes(operation.phase)) return; operations.set(id, freeze({ ...operation, phase: reasons.length ? 'blocked' : 'pending', blockers: [...reasons] })); changed(); },
/** 原子绑定多个 ready 意图到同一请求;参与者版本精确覆盖、类型一致、依赖已解决或在同批。 /** 完成输入/ready 检查后,同次更新请求和所有操作的绑定:参与版本覆盖、类型一致,依赖已解决或在同批。
* 只在这里序列化 payload 一次,后续 submit/query 都使用保存的 body 和请求 ID。 */ * payload 在这里 JSON 序列化一次,后续沿用 body/ID;wire 语义、意图到参与者的推导由编译/调度层核对。
* 这次内存更新本身不等于一次持久事务,也不自动取得 scheduler claims。 */
freeze(input: { id: Id; kind?: FrozenRequest['kind']; ownerCanvasId?: Id; runId?: Id; protocolVersion?: number; operationIds: readonly Id[]; canvasIds: readonly Id[]; expectedVersions: Readonly<Record<Id, number>>; payload: Json }) { freeze(input: { id: Id; kind?: FrozenRequest['kind']; ownerCanvasId?: Id; runId?: Id; protocolVersion?: number; operationIds: readonly Id[]; canvasIds: readonly Id[]; expectedVersions: Readonly<Record<Id, number>>; payload: Json }) {
if (!input.id || requests.has(input.id) || !Number.isSafeInteger(input.protocolVersion ?? 1) || (input.protocolVersion ?? 1) < 1 || !input.operationIds.length || (!input.canvasIds.length && input.kind!=='asset.upload' && input.kind!=='run.cancel') || !validJson(input.payload) || new Set(input.operationIds).size !== input.operationIds.length || new Set(input.canvasIds).size !== input.canvasIds.length || Object.keys(input.expectedVersions).length !== input.canvasIds.length || input.canvasIds.some(id => !id || !Number.isSafeInteger(input.expectedVersions[id]) || input.expectedVersions[id] < 0)) throw fault('invalid_frozen_request'); if (!input.id || requests.has(input.id) || !Number.isSafeInteger(input.protocolVersion ?? 1) || (input.protocolVersion ?? 1) < 1 || !input.operationIds.length || (!input.canvasIds.length && input.kind!=='asset.upload' && input.kind!=='run.cancel') || !validJson(input.payload) || new Set(input.operationIds).size !== input.operationIds.length || new Set(input.canvasIds).size !== input.canvasIds.length || Object.keys(input.expectedVersions).length !== input.canvasIds.length || input.canvasIds.some(id => !id || !Number.isSafeInteger(input.expectedVersions[id]) || input.expectedVersions[id] < 0)) throw fault('invalid_frozen_request');
if (input.kind === 'run.start' && (!input.ownerCanvasId || !input.canvasIds.includes(input.ownerCanvasId))) throw fault('invalid_run_request'); if (input.kind === 'run.start' && (!input.ownerCanvasId || !input.canvasIds.includes(input.ownerCanvasId))) throw fault('invalid_run_request');
@@ -122,11 +128,13 @@ export function createLedger(initial?: LedgerSnapshot) {
if (request.phase === 'resolved') throw fault('request_resolved', [id]); if (request.phase === 'resolved') throw fault('request_resolved', [id]);
const next: FrozenRequest = freeze({ ...request, phase: 'dispatch-marked', attempt: request.attempt + 1, retryAt: undefined, retryAction: undefined }); requests.set(id, next); changed(); return next; const next: FrozenRequest = freeze({ ...request, phase: 'dispatch-marked', attempt: request.attempt + 1, retryAt: undefined, retryAction: undefined }); requests.set(id, next); changed(); return next;
}, },
/** 记录执行结果;applied 且 reconciled 才解除逻辑操作,受理/未协调成功仍 waiting。 /** 接受上层已解释的 Outcome;本函数不验证协议身份、canonical 模型或 not-applied 的零写入证据。
* applied 且 reconciled 才解除逻辑操作,受理/未协调成功仍 waiting;reconciled 默认 true,调用方需按协调状态传入。
* not-applied 结束旧请求但阻塞原意图,不能自动换 key 当作用户已重新授权执行。 */ * not-applied 结束旧请求但阻塞原意图,不能自动换 key 当作用户已重新授权执行。 */
result(id: Id, outcome: Outcome, error?: string, reconciled = true) { result(id: Id, outcome: Outcome, error?: string, reconciled = true) {
const request = requireRequest(id); const request = requireRequest(id);
if ((request.phase === 'resolved' || request.outcome === 'applied') && request.outcome !== outcome) throw fault('receipt_conflict', [id]); if ((request.phase === 'resolved' || request.outcome === 'applied') && request.outcome !== outcome) throw fault('receipt_conflict', [id]);
// 这里只拒绝与既有 applied/resolved outcome 不同的回执;相同 outcome 的阶段准入仍由调用链维持。
const phase = (outcome === 'applied' && reconciled) || outcome === 'not-applied' ? 'resolved' : 'waiting'; const phase = (outcome === 'applied' && reconciled) || outcome === 'not-applied' ? 'resolved' : 'waiting';
requests.set(id, freeze({ ...request, phase, outcome, error, retryAt: undefined, retryAction: undefined })); requests.set(id, freeze({ ...request, phase, outcome, error, retryAt: undefined, retryAction: undefined }));
if (outcome === 'applied' && reconciled) for (const operationId of request.operationIds) operations.set(operationId, freeze({ ...operations.get(operationId)!, phase: 'resolved', resolution: 'applied' })); if (outcome === 'applied' && reconciled) for (const operationId of request.operationIds) operations.set(operationId, freeze({ ...operations.get(operationId)!, phase: 'resolved', resolution: 'applied' }));
@@ -161,7 +169,8 @@ export function createLedger(initial?: LedgerSnapshot) {
}, },
/** Local retirement is not an applied receipt. Validate the complete closure before mutating. */ /** Local retirement is not an applied receipt. Validate the complete closure before mutating. */
/** 用户恢复决策的本地退休:先验证整组与依赖闭包,再标记 superseded/discarded/satisfied。 /** 用户恢复决策的本地退休:先验证整组与依赖闭包,再标记 superseded/discarded/satisfied。
* 上传和执行结果未知的请求不能在这里被静默丢弃;replacement ID 必须是全新的。 */ * 上传和执行结果未知的请求不能在这里被静默丢弃;replacement ID 不能撞已有/本批操作。
* 只标记原操作退休,不创建 replacement,不改工作文档/history,须由 engine 在同一 Root 组织其它修改。 */
retire(recoveryId:Id,decisions:readonly {operationId:Id;resolution:'superseded'|'discarded'|'satisfied';replacementId?:Id}[]) { retire(recoveryId:Id,decisions:readonly {operationId:Id;resolution:'superseded'|'discarded'|'satisfied';replacementId?:Id}[]) {
const ids=new Set(decisions.map(decision=>decision.operationId)); const ids=new Set(decisions.map(decision=>decision.operationId));
if(!recoveryId||ids.size!==decisions.length||new Set(decisions.flatMap(decision=>decision.replacementId?[decision.replacementId]:[])).size!==decisions.filter(decision=>decision.replacementId).length)throw fault('invalid_recovery_decisions'); if(!recoveryId||ids.size!==decisions.length||new Set(decisions.flatMap(decision=>decision.replacementId?[decision.replacementId]:[])).size!==decisions.filter(decision=>decision.replacementId).length)throw fault('invalid_recovery_decisions');
@@ -175,17 +184,20 @@ export function createLedger(initial?: LedgerSnapshot) {
for(const decision of decisions)operations.set(decision.operationId,freeze({...operations.get(decision.operationId)!,phase:'resolved',resolution:decision.resolution,recoveryId,...(decision.replacementId?{replacementId:decision.replacementId}:{})})); for(const decision of decisions)operations.set(decision.operationId,freeze({...operations.get(decision.operationId)!,phase:'resolved',resolution:decision.resolution,recoveryId,...(decision.replacementId?{replacementId:decision.replacementId}:{})}));
if(decisions.length)changed(); if(decisions.length)changed();
}, },
/** 保存原请求的 dueAt/action,重试计划也是可恢复事实;实际 heap 是 scheduler 的派生索引。 */ /** 保存原请求的 dueAt/action;这里只要求 dueAt 有限,不要求大于当前时间。
* 重试计划是可恢复事实,实际 heap/timer 是 scheduler 的派生索引;这里不启动 timer。 */
scheduleRetry(id: Id, dueAt: number, action: 'submit' | 'query') { scheduleRetry(id: Id, dueAt: number, action: 'submit' | 'query') {
const request = requireRequest(id); const request = requireRequest(id);
if (request.phase === 'resolved' || !Number.isFinite(dueAt)) throw fault('invalid_retry', [id]); if (request.phase === 'resolved' || !Number.isFinite(dueAt)) throw fault('invalid_retry', [id]);
requests.set(id, freeze({ ...request, retryAt: dueAt, retryAction: action })); changed(); requests.set(id, freeze({ ...request, retryAt: dueAt, retryAction: action })); changed();
}, },
/** 为未解决请求记录解释器/权限等阻碍,并清重试计划;保留 body/key 和 claims 的恢复依据。 */ /** 为未解决请求记录解释器/权限等阻碍,发生写入时清 retry 元数据,保留 body/key。
* 已 resolved 或 error 相同直接 no-op;相同 error 的早返回不会再清已有 retry 元数据。 */
blockRequest(id: Id, error: string) { const request = requireRequest(id); if (request.phase === 'resolved' || request.error === error) return; requests.set(id, freeze({ ...request, error, retryAt: undefined, retryAction: undefined })); changed(); }, blockRequest(id: Id, error: string) { const request = requireRequest(id); if (request.phase === 'resolved' || request.error === error) return; requests.set(id, freeze({ ...request, error, retryAt: undefined, retryAction: undefined })); changed(); },
/** 仅允许 durable 水位单调前进到当前 generation 内;不新增业务 generation。 */ /** 仅允许 durable 水位单调前进到当前 generation 内;不新增业务 generation。 */
markDurable(through: number) { if (!Number.isSafeInteger(through) || through < durableGeneration || through > generation) throw fault('invalid_durable_generation'); durableGeneration = through; refresh(); }, markDurable(through: number) { if (!Number.isSafeInteger(through) || through < durableGeneration || through > generation) throw fault('invalid_durable_generation'); durableGeneration = through; refresh(); },
/** 发送标记后且未确定 not-applied 均按可能已执行处理;这是保守风险依据,不是成功证明。 */ /** 精确条件为 phase!=frozen 且 outcome!=not-applied,按可能已执行处理;applied/resolved 也可为 true。
* 这是基于账本标记的保守风险读取,不查询网络,不证明成功,也不取代实际发送前持久化屏障。 */
possiblyApplied(id: Id) { const request = requireRequest(id); return request.phase !== 'frozen' && request.outcome !== 'not-applied'; }, possiblyApplied(id: Id) { const request = requireRequest(id); return request.phase !== 'frozen' && request.outcome !== 'not-applied'; },
}); });
} }
+29 -9
View File
@@ -19,7 +19,8 @@ export type LoadChunk<T> = {
readonly sequence: number; readonly coverage: readonly string[]; readonly complete: boolean; readonly sequence: number; readonly coverage: readonly string[]; readonly complete: boolean;
readonly byteLength: number; readonly value: T; readonly byteLength: number; readonly value: T;
}; };
/** 加载状态及已收到块;initial-ready/partial 只说明有内容,ready Promise 要到 terminal+EOF 才完成。 */ /** 加载状态及已收到块;initial-ready 表示收到首个合法块,可能只是空模型头,并不证明画面已有元素。
* partial 继续累积前缀;ready Promise 要等 terminal+EOF,失败前缀仍保留在 failed-with-partial 快照中。 */
export type LoadSnapshot<T> = { export type LoadSnapshot<T> = {
readonly scope: LoadScope; readonly generation: number; readonly scope: LoadScope; readonly generation: number;
readonly cacheStatus: 'bypassed' | 'miss' | 'invalid' | 'hit'; readonly cacheStatus: 'bypassed' | 'miss' | 'invalid' | 'hit';
@@ -44,7 +45,10 @@ export function loadQueryKey(scope: LoadScope): string {
/** Read copies only. This coordinator never publishes a document or a server ACK. /** Read copies only. This coordinator never publishes a document or a server ACK.
* Adapters validate payloads and declare their measured byte cost before yielding. * Adapters validate payloads and declare their measured byte cost before yielding.
*/ */
/** 装配有界加载池;默认缓存年龄0不保留空闲结果,启用缓存也要求宿主 acceptCached 明确同意。 */ /** 建立共享 query 调度器:inflight 去重、queue 按 generation FIFO、active 计真实未退出源,cache 按 Map LRU。
* 默认并发 4/等待 128、单响应声明 64MiB/4096 块;上限依赖 adapter 准确测量 byteLength,不测 T 或内存峰值。
* 默认缓存年龄 0 不插入终态缓存;启用后仍须订阅 allow-validated 且 acceptCached 同意,不把缓存当授权事实。
* 不重试、无读取超时,不深克隆/解释 T;current 应表达共享查询的授权生命周期,导航取消由各订阅管理。 */
export function createScopedLoader<T>(options: { export function createScopedLoader<T>(options: {
load: LoadPort<T>; current(scope: LoadScope): boolean; concurrency?: number; maxQueued?: number; load: LoadPort<T>; current(scope: LoadScope): boolean; concurrency?: number; maxQueued?: number;
maxResponseBytes?: number; maxChunks?: number; maxCacheEntries?: number; maxCacheBytes?: number; maxResponseBytes?: number; maxChunks?: number; maxCacheEntries?: number; maxCacheBytes?: number;
@@ -54,7 +58,8 @@ export function createScopedLoader<T>(options: {
}) { }) {
/** 订阅自己的 scope/取消/通知状态,不把旧导航的取消写成整个 job 失败。 */ /** 订阅自己的 scope/取消/通知状态,不把旧导航的取消写成整个 job 失败。 */
type Subscriber = { readonly scope: LoadScope; active: boolean; cancelled?: LoadSnapshot<T>; lastState?: LoadSnapshot<T>; snapshot?: LoadSnapshot<T>;resolve(value: LoadSnapshot<T>): void; reject(error: unknown): void; cleanup(): void; notify?(snapshot: LoadSnapshot<T>): void }; type Subscriber = { readonly scope: LoadScope; active: boolean; cancelled?: LoadSnapshot<T>; lastState?: LoadSnapshot<T>; snapshot?: LoadSnapshot<T>;resolve(value: LoadSnapshot<T>): void; reject(error: unknown): void; cleanup(): void; notify?(snapshot: LoadSnapshot<T>): void };
/** 共享 query 的执行句柄;generation 防重开混淆,controller 只在最后订阅释放时 abort。 */ /** 共享执行句柄,scope 来自首个订阅;view 会将导航信息换成各订阅自己的 scope。
* generation 区分重开,controller 在 retire(最后释放或 close)时 abort,不以订阅数为持久任务事实。 */
type Job = { readonly key: string; readonly scope: LoadScope; readonly generation: number; readonly controller: AbortController; readonly subscribers: Set<Subscriber>; state: LoadSnapshot<T>; retired: boolean }; type Job = { readonly key: string; readonly scope: LoadScope; readonly generation: number; readonly controller: AbortController; readonly subscribers: Set<Subscriber>; state: LoadSnapshot<T>; retired: boolean };
const concurrency = options.concurrency ?? 4, maxQueued = options.maxQueued ?? 128, maxBytes = options.maxResponseBytes ?? 64 * 1024 * 1024, maxChunks = options.maxChunks ?? 4096; const concurrency = options.concurrency ?? 4, maxQueued = options.maxQueued ?? 128, maxBytes = options.maxResponseBytes ?? 64 * 1024 * 1024, maxChunks = options.maxChunks ?? 4096;
const maxCacheEntries = options.maxCacheEntries ?? 16, maxCacheBytes = options.maxCacheBytes ?? 16 * 1024 * 1024, cacheMaxAge = options.cacheMaxAgeMs ?? 0; const maxCacheEntries = options.maxCacheEntries ?? 16, maxCacheBytes = options.maxCacheBytes ?? 16 * 1024 * 1024, cacheMaxAge = options.cacheMaxAgeMs ?? 0;
@@ -74,7 +79,8 @@ export function createScopedLoader<T>(options: {
const notify = (job: Job) => { for (const subscriber of [...job.subscribers]) notifyOne(job, subscriber); }; const notify = (job: Job) => { for (const subscriber of [...job.subscribers]) notifyOne(job, subscriber); };
/** 淘汰同时扣除字节计数,避免缓存键删了但容量永远不归还。 */ /** 淘汰同时扣除字节计数,避免缓存键删了但容量永远不归还。 */
const removeCache = (key: string) => { const entry = cache.get(key); if (entry) { cacheBytes -= entry.state.byteLength; cache.delete(key); } }; const removeCache = (key: string) => { const entry = cache.get(key); if (entry) { cacheBytes -= entry.state.byteLength; cache.delete(key); } };
/** 幂等废弃 job:abort/出等待队列,仅当 inflight 仍指向自己时删除,旧任务不能删新代次。 */ /** 幂等 retire:abort、移除排队/仍指向自己的 inflight,不删除缓存、不清前缀或提早扣 active。
* 成功/失败后仍保留的句柄可独立 release;本函数不把已兑现的 ready Promise 改成拒绝。 */
function retire(job: Job) { function retire(job: Job) {
if (job.retired) return; job.retired = true; job.controller.abort(); queue.delete(job.generation); if (job.retired) return; job.retired = true; job.controller.abort(); queue.delete(job.generation);
if (inflight.get(job.key) === job) inflight.delete(job.key); if (inflight.get(job.key) === job) inflight.delete(job.key);
@@ -88,7 +94,9 @@ export function createScopedLoader<T>(options: {
void (async () => { void (async () => {
try { try {
current(job.scope); let terminal = false, projectId: Id | undefined, token: string | undefined, versions: Readonly<Record<Id, number>> | undefined; current(job.scope); let terminal = false, projectId: Id | undefined, token: string | undefined, versions: Readonly<Record<Id, number>> | undefined;
// 每块重新检查身份并核对连续序号、稳定 token/项目/版本向量、权限代次和声明预算。 // 端口可收到已取消信号(例如 loading 通知期间最后订阅释放);它仍须在实际 I/O 前检查 signal。
// 每块重新检查身份,核对连续序号、稳定 token/项目/版本、权限代次和声明预算;coverage 只是合法字符串集合。
// complete/coverage 的领域含义、实际请求范围和 T 的 schema 由 adapter 负责,本层不从它们推断文档闭合。
for await (const chunk of options.load(job.scope, job.controller.signal)) { for await (const chunk of options.load(job.scope, job.controller.signal)) {
if (job.retired) return; current(job.scope); if (job.retired) return; current(job.scope);
if (!chunk || terminal || chunk.queryKey !== job.key || chunk.permissionEpoch !== job.scope.authEpoch || typeof chunk.projectId !== 'string' || !chunk.projectId if (!chunk || terminal || chunk.queryKey !== job.key || chunk.permissionEpoch !== job.scope.authEpoch || typeof chunk.projectId !== 'string' || !chunk.projectId
@@ -103,7 +111,8 @@ export function createScopedLoader<T>(options: {
if (projectId !== undefined && (chunk.projectId !== projectId || chunk.snapshotToken !== token || !equal(orderedVersions, versions))) throw fault('load_snapshot_changed'); if (projectId !== undefined && (chunk.projectId !== projectId || chunk.snapshotToken !== token || !equal(orderedVersions, versions))) throw fault('load_snapshot_changed');
if (job.state.chunks.length >= maxChunks || chunk.byteLength > maxBytes - job.state.byteLength) throw fault('load_response_budget'); if (job.state.chunks.length >= maxChunks || chunk.byteLength > maxBytes - job.state.byteLength) throw fault('load_response_budget');
projectId = chunk.projectId; token = chunk.snapshotToken; versions = orderedVersions; terminal = chunk.complete; projectId = chunk.projectId; token = chunk.snapshotToken; versions = orderedVersions; terminal = chunk.complete;
// Own metadata; the adapter supplies an immutable/owned payload value. // 捕获标准元数据表/coverage,value 与其它嵌套扩展字段仍沿用端口引用,不在此深冻结任意 T。
// adapter 应在 yield 前完成 payload 校验并交出不可变值;外层 Object.freeze 不证明内部所有权。
const owned = Object.freeze({ ...chunk, serverVersions: freeze(orderedVersions), coverage: freeze([...chunk.coverage]) }); const owned = Object.freeze({ ...chunk, serverVersions: freeze(orderedVersions), coverage: freeze([...chunk.coverage]) });
// 只追加合法块并通知可显示前缀;即使当前块 complete=true,也要等源 EOF 才能成功 ready。 // 只追加合法块并通知可显示前缀;即使当前块 complete=true,也要等源 EOF 才能成功 ready。
job.state = Object.freeze({ ...job.state, status: job.state.chunks.length ? 'partial' : 'initial-ready', chunks: Object.freeze([...job.state.chunks, owned]), job.state = Object.freeze({ ...job.state, status: job.state.chunks.length ? 'partial' : 'initial-ready', chunks: Object.freeze([...job.state.chunks, owned]),
@@ -116,6 +125,8 @@ export function createScopedLoader<T>(options: {
removeCache(job.key); cache.set(job.key, { state: job.state, at: now() }); cacheBytes += job.state.byteLength; removeCache(job.key); cache.set(job.key, { state: job.state, at: now() }); cacheBytes += job.state.byteLength;
while (cache.size > maxCacheEntries || cacheBytes > maxCacheBytes) removeCache(cache.keys().next().value!); while (cache.size > maxCacheEntries || cacheBytes > maxCacheBytes) removeCache(cache.keys().next().value!);
} }
// 成功清理外部 abort 监听并兑现 ready,但不自动 release 句柄;后续 signal.abort 不再改变其快照。
// subscriber 仍持有共享状态,调用方应在不再使用时显式 release;已发出的快照继续描述原切点。
notify(job); for (const subscriber of job.subscribers) if (subscriber.active) { subscriber.cleanup(); subscriber.resolve(view(job, subscriber)); } notify(job); for (const subscriber of job.subscribers) if (subscriber.active) { subscriber.cleanup(); subscriber.resolve(view(job, subscriber)); }
} catch (error) { } catch (error) {
if (job.retired) return; if (job.retired) return;
@@ -132,7 +143,10 @@ export function createScopedLoader<T>(options: {
} }
} }
return Object.freeze({ return Object.freeze({
/** 捕获 scope→检查缓存→共享/新建 job→建立独立订阅;本调用不直接打开/关闭 ProjectEngine。 */ /** 捕获声明 scope 字段,校验身份/取消,再检查缓存并共享或新建 job;不打开 ProjectEngine。
* fresh 会淘汰该 key 的旧缓存,但仍共享已有 inflight,并不保证为每次调用发新请求。
* 缓存验证/时钟/容量等错误可同步抛出,成功才返回 ready/getSnapshot/release 句柄。
* cacheStatus 属于共享 job 建立时的读取事实,复用在途 job 不会按新订阅的缓存偏好重写它。 */
acquire(scopeInput: LoadScope, settings: { signal?: AbortSignal; onUpdate?: (snapshot: LoadSnapshot<T>) => void; cache?: 'fresh' | 'allow-validated' } = {}): LoadHandle<T> { acquire(scopeInput: LoadScope, settings: { signal?: AbortSignal; onUpdate?: (snapshot: LoadSnapshot<T>) => void; cache?: 'fresh' | 'allow-validated' } = {}): LoadHandle<T> {
if(!scopeInput)throw fault('invalid_load_scope'); if(!scopeInput)throw fault('invalid_load_scope');
const scope:LoadScope=Object.freeze({accountId:scopeInput.accountId,workspaceId:scopeInput.workspaceId,projectId:scopeInput.projectId,canvasId:scopeInput.canvasId,viewKey:scopeInput.viewKey,format:scopeInput.format,queryFingerprint:scopeInput.queryFingerprint,source:scopeInput.source,authEpoch:scopeInput.authEpoch,navigationGeneration:scopeInput.navigationGeneration}); const scope:LoadScope=Object.freeze({accountId:scopeInput.accountId,workspaceId:scopeInput.workspaceId,projectId:scopeInput.projectId,canvasId:scopeInput.canvasId,viewKey:scopeInput.viewKey,format:scopeInput.format,queryFingerprint:scopeInput.queryFingerprint,source:scopeInput.source,authEpoch:scopeInput.authEpoch,navigationGeneration:scopeInput.navigationGeneration});
@@ -158,13 +172,19 @@ export function createScopedLoader<T>(options: {
} }
const shared = job; let subscriber: Subscriber; const shared = job; let subscriber: Subscriber;
const ready = new Promise<LoadSnapshot<T>>((resolve, reject) => { subscriber = { scope, active: true, resolve, reject, notify: settings.onUpdate, cleanup: () => settings.signal?.removeEventListener('abort', release) }; }); const ready = new Promise<LoadSnapshot<T>>((resolve, reject) => { subscriber = { scope, active: true, resolve, reject, notify: settings.onUpdate, cleanup: () => settings.signal?.removeEventListener('abort', release) }; });
/** 冻结本订阅 cancelled 视图、清监听并拒绝等待;只有最后借用者离开才 retire 共享源。 */ /** 为本订阅记录 cancelled 快照、移除监听/借用,尝试以 load_aborted 拒绝尚未兑现的 ready。
* ready 已成功/失败时 Promise 结果不变;getSnapshot 则改读此 cancelled 切点,保留已有 chunks。
* 最后借用者才 retire 共享源;取消一个订阅不会撤回其它订阅的前缀或删除成功缓存。 */
const release = () => { if (!subscriber.active) return; subscriber.cancelled=Object.freeze({...view(shared,subscriber),status:'cancelled'});subscriber.active = false; subscriber.cleanup(); shared.subscribers.delete(subscriber); subscriber.reject(fault('load_aborted')); if (!shared.subscribers.size) retire(shared); }; const release = () => { if (!subscriber.active) return; subscriber.cancelled=Object.freeze({...view(shared,subscriber),status:'cancelled'});subscriber.active = false; subscriber.cleanup(); shared.subscribers.delete(subscriber); subscriber.reject(fault('load_aborted')); if (!shared.subscribers.size) retire(shared); };
shared.subscribers.add(subscriber!); settings.signal?.addEventListener('abort', release, { once: true }); shared.subscribers.add(subscriber!); settings.signal?.addEventListener('abort', release, { once: true });
// 缓存命中的句柄不登记进 inflight,ready 立即兑现,外部 abort 监听仍保留到 release。
// 因此 close 负责清共享缓存,却不追溯取消这种已返回句柄;调用方仍拥有其只读快照。
if (cached) { subscriber!.resolve(view(shared, subscriber!)); notifyOne(shared, subscriber!); } else { notifyOne(shared, subscriber!); pump(); } if (cached) { subscriber!.resolve(view(shared, subscriber!)); notifyOne(shared, subscriber!); } else { notifyOne(shared, subscriber!); pump(); }
return Object.freeze({ ready, getSnapshot: () => subscriber.cancelled??view(shared,subscriber), release }); return Object.freeze({ ready, getSnapshot: () => subscriber.cancelled??view(shared,subscriber), release });
}, },
/** 永久关闭并拒绝所有活订阅,清等待/缓存;底层迟到结果由 retired/current 围栏拒绝采纳。 */ /** 永久关闭:只对当前 inflight 的订阅记录 cancelled 并以 loader_closed 拒绝,随后清 queue/cache。
* 不追溯修改已从 inflight 退出的终态句柄或缓存命中句柄;其旧快照仍可读,显式 release 仍可调用。
* 不等待底层迭代器退出,active 在各 finally 才归还;无视 abort 的源仍可能占着资源,但迟到块不被采纳。 */
close() { close() {
if (closed) return; closed = true; if (closed) return; closed = true;
for (const job of [...inflight.values()]) { retire(job); for (const subscriber of job.subscribers) if (subscriber.active) { subscriber.cancelled=Object.freeze({...view(job,subscriber),status:'cancelled'});subscriber.active = false; subscriber.cleanup(); subscriber.reject(fault('loader_closed')); } job.subscribers.clear(); } for (const job of [...inflight.values()]) { retire(job); for (const subscriber of job.subscribers) if (subscriber.active) { subscriber.cancelled=Object.freeze({...view(job,subscriber),status:'cancelled'});subscriber.active = false; subscriber.cleanup(); subscriber.reject(fault('loader_closed')); } job.subscribers.clear(); }
+7 -2
View File
@@ -7,7 +7,9 @@ import { fault } from '../canvas-core/values.ts';
import { createDeque } from './structures.ts'; import { createDeque } from './structures.ts';
/** Shared bounded transport capacity for writes, uploads and run reads. */ /** Shared bounded transport capacity for writes, uploads and run reads. */
/** 建立正整数容量的 gate,beforeRun 在实际执行 callback 前检查宿主身份等条件。 */ /** 建立正整数容量的 gate,beforeRun 在实际执行 callback 前同步检查宿主身份等条件。
* 每个实例独立计数,等待 FIFO 无长度上限;这里没有跨实例锁、重试或持久任务记录。
* beforeRun 是同步钩子;异步身份校验应由 callback 自己 await,不能依赖 void 钩子等待。 */
export function createNetworkGate(limit = 2, beforeRun?:()=>void) { export function createNetworkGate(limit = 2, beforeRun?:()=>void) {
if(!Number.isSafeInteger(limit)||limit<1)throw fault('invalid_concurrency'); if(!Number.isSafeInteger(limit)||limit<1)throw fault('invalid_concurrency');
let active = 0; let active = 0;
@@ -15,6 +17,7 @@ export function createNetworkGate(limit = 2, beforeRun?:()=>void) {
/** 有空位直接占用,否则等待 grant;已取消的等待项标记保留,释放时惰性跳过。 */ /** 有空位直接占用,否则等待 grant;已取消的等待项标记保留,释放时惰性跳过。 */
async function enter(signal: AbortSignal) { async function enter(signal: AbortSignal) {
signal.throwIfAborted(); signal.throwIfAborted();
// enter 自己推进 active,或等 release 直接转交名额;等待者获准时不再重复 active++。
if (active < limit) { active++; return; } if (active < limit) { active++; return; }
await new Promise<void>((resolve,reject) => { await new Promise<void>((resolve,reject) => {
// grant 解除 abort 监听再唤醒;被唤醒后 run 还会再次检查 signal,避免取消竞态执行请求。 // grant 解除 abort 监听再唤醒;被唤醒后 run 还会再次检查 signal,避免取消竞态执行请求。
@@ -29,7 +32,9 @@ export function createNetworkGate(limit = 2, beforeRun?:()=>void) {
let item=waiting.pop();while(item?.cancelled)item=waiting.pop(); let item=waiting.pop();while(item?.cancelled)item=waiting.pop();
if(item)item.grant();else active--; if(item)item.grant();else active--;
} }
/** 统一入口:enter 后即使 signal/beforeRun/callback 抛错,finally 也会归还名额。 */ /** enter 成功后即使 signal/beforeRun/callback 抛错,finally 也会归还名额。
* 排队取消会拒绝 enter;执行中取消不自动结束 callback 或提早释放,仍等其 Promise 结束。
* 身份只在准入时检查一次;任务运行中的失效/响应采纳校验由 callback 和上层协调。 */
return Object.freeze({ async run<T>(signal: AbortSignal, callback: () => Promise<T>): Promise<T> { return Object.freeze({ async run<T>(signal: AbortSignal, callback: () => Promise<T>): Promise<T> {
await enter(signal); await enter(signal);
try { signal.throwIfAborted();beforeRun?.();return await callback(); } finally { release(); } try { signal.throwIfAborted();beforeRun?.();return await callback(); } finally { release(); }
+13 -4
View File
@@ -10,12 +10,15 @@ import {resourceRetentionPins} from './resource-retention.ts';
/** 盘点确认的文件身份/摘要与退休状态,不是上传资源元数据,也不是 DOM 图片缓存。 */ /** 盘点确认的文件身份/摘要与退休状态,不是上传资源元数据,也不是 DOM 图片缓存。 */
export type OrphanFile={readonly version:1;readonly id:Id;readonly byteLength:number;readonly mimeType:string;readonly digest:string;readonly retirement?:FileRetirement}; export type OrphanFile={readonly version:1;readonly id:Id;readonly byteLength:number;readonly mimeType:string;readonly digest:string;readonly retirement?:FileRetirement};
/** 检查 ID、字节数、MIME、SHA256 和退休解释器;失败不默默忽略损坏文件记录。 */ /** 检查记录版本、键与 ID 相同、安全字节数、MIME 字符串、64 位小写十六进制摘要和退休标记。
* 不读取 Blob 或重算 SHA;MIME 只要求字符串,不在此证明真实内容类型或元数据来自可信盘点。 */
export function validateOrphanFile(value:unknown,id:Id):asserts value is OrphanFile { export function validateOrphanFile(value:unknown,id:Id):asserts value is OrphanFile {
const file=value as OrphanFile; const file=value as OrphanFile;
if(!file||file.version!==1||file.id!==id||!id||!Number.isSafeInteger(file.byteLength)||file.byteLength<0||typeof file.mimeType!=='string'||typeof file.digest!=='string'||!/^[a-f0-9]{64}$/.test(file.digest)||file.retirement!==undefined&&!validFileRetirement(file.retirement))throw fault('invalid_orphan_file'); if(!file||file.version!==1||file.id!==id||!id||!Number.isSafeInteger(file.byteLength)||file.byteLength<0||typeof file.mimeType!=='string'||typeof file.digest!=='string'||!/^[a-f0-9]{64}$/.test(file.digest)||file.retirement!==undefined&&!validFileRetirement(file.retirement))throw fault('invalid_orphan_file');
} }
/** 结合 pin 与所有未解释任务/审计证据给出回收阻碍;blockers 非空即 complete=false。 */ /** 在 resourceRetentionPins 上叠加孤立文件专用阻碍,返回已知 pins 与 blockers。
* 任意 run/recovery、非终态导入、copy-admission 或未解决的其它动作都阻止孤儿回收。
* complete 只表示本函数列出的引用/生产者条件通过;不代表盘点到末页、文件散列完成或删除已持久化。 */
export function orphanCoverage(snapshot:EngineSnapshot,registry:Registry,displays:ReadonlyMap<Id,number>,temporary:ReadonlyMap<Id,readonly Id[]>) { export function orphanCoverage(snapshot:EngineSnapshot,registry:Registry,displays:ReadonlyMap<Id,number>,temporary:ReadonlyMap<Id,readonly Id[]>) {
const pins=resourceRetentionPins(snapshot,registry,displays,temporary),blockers:string[]=[]; const pins=resourceRetentionPins(snapshot,registry,displays,temporary),blockers:string[]=[];
if(!pins.complete)blockers.push('opaque_references'); if(!pins.complete)blockers.push('opaque_references');
@@ -27,7 +30,10 @@ export function orphanCoverage(snapshot:EngineSnapshot,registry:Registry,display
return Object.freeze({...pins,complete:blockers.length===0,blockers:Object.freeze(blockers)}); return Object.freeze({...pins,complete:blockers.length===0,blockers:Object.freeze(blockers)});
} }
/** Registered orphan identities survive repinning. Purged identities cannot be forgotten or reassigned. */ /** Registered orphan identities survive repinning. Purged identities cannot be forgotten or reassigned. */
/** 在发布前维护孤儿事实与回收 reservations:身份不得与资源/导入源冲突,已 purge 不得改写。 */ /** 在发布前维护孤儿记录:禁止与资源/源文件重名,旧记录不得消失,已 purge 记录必须完整相等。
* 未 purge 记录允许盘点方更新摘要/长度等事实,本函数不读取字节来证明这种更新。
* referencesChanged 和 Map 引用用于快路判断;调用方须准确报告文档/历史/账本等引用变化。
* reservation 仅检查 next.orphanFiles 中对应 ID;跨文件类别的统一阻拦由外围发布钩子负责。 */
export function prepareOrphanFiles(previous:RuntimeState,next:RuntimeState,coverage:()=>{complete:boolean;pins:ReadonlyMap<Id,unknown>},reserved:ReadonlySet<Id>,referencesChanged:boolean):RuntimeState { export function prepareOrphanFiles(previous:RuntimeState,next:RuntimeState,coverage:()=>{complete:boolean;pins:ReadonlyMap<Id,unknown>},reserved:ReadonlySet<Id>,referencesChanged:boolean):RuntimeState {
if(!previous.orphanFiles?.size&&!next.orphanFiles?.size)return next; if(!previous.orphanFiles?.size&&!next.orphanFiles?.size)return next;
const hasSoft=[...(next.orphanFiles?.values()??[])].some(file=>file.retirement&&file.retirement.purgedAt===undefined); const hasSoft=[...(next.orphanFiles?.values()??[])].some(file=>file.retirement&&file.retirement.purgedAt===undefined);
@@ -37,6 +43,7 @@ export function prepareOrphanFiles(previous:RuntimeState,next:RuntimeState,cover
for(const [id,file]of next.orphanFiles??[]){validateOrphanFile(file,id);if(next.resources.has(id)||sources.has(id))throw fault('local_file_identity_conflict',[id]);} for(const [id,file]of next.orphanFiles??[]){validateOrphanFile(file,id);if(next.resources.has(id)||sources.has(id))throw fault('local_file_identity_conflict',[id]);}
for(const [id,file]of previous.orphanFiles??[]){const after=next.orphanFiles?.get(id);if(!after||file.retirement?.purgedAt!==undefined&&!equal(file,after))throw fault('local_file_retired',[id]);} for(const [id,file]of previous.orphanFiles??[]){const after=next.orphanFiles?.get(id);if(!after||file.retirement?.purgedAt!==undefined&&!equal(file,after))throw fault('local_file_retired',[id]);}
// 覆盖不足/重 pin 取消软退休;永久删除后重 pin 明确拒绝,不能让模型引用失去字节的 ID。 // 覆盖不足/重 pin 取消软退休;永久删除后重 pin 明确拒绝,不能让模型引用失去字节的 ID。
// 需要慢路才调用覆盖回调;即使引用覆盖未知,已 purge 项也仅在有明确 pin 时拒绝。
const pins=coverage(),clear:Id[]=[]; const pins=coverage(),clear:Id[]=[];
for(const [id,file]of next.orphanFiles??[]){ for(const [id,file]of next.orphanFiles??[]){
if(reserved.has(id)&&(!pins.complete||pins.pins.has(id)))throw fault('resource_collection_in_progress',[id]); if(reserved.has(id)&&(!pins.complete||pins.pins.has(id)))throw fault('resource_collection_in_progress',[id]);
@@ -48,7 +55,9 @@ export function prepareOrphanFiles(previous:RuntimeState,next:RuntimeState,cover
} }
/** Read-only I/O may finish late; cancellation detaches its result without publishing it. */ /** Read-only I/O may finish late; cancellation detaches its result without publishing it. */
/** 取消只让调用者停止采纳读取结果,不保证底层只读 I/O 被终止;迟到成功/失败均已接住。 */ /** 包装已启动的 Promise:取消以 file_inventory_aborted 拒绝,不沿用 signal.reason。
* 不终止底层只读 I/O;仍安装成功/失败处理器接住迟到结果,并在完成/取消时移除监听。
* 不持有 Root,也不校验结果是否过期;盘点调用方还需在 await 后核对当前快照身份。 */
export function abortableFileRead<T>(task:Promise<T>,signal:AbortSignal):Promise<T>{ export function abortableFileRead<T>(task:Promise<T>,signal:AbortSignal):Promise<T>{
return new Promise((resolve,reject)=>{const abort=()=>{signal.removeEventListener('abort',abort);reject(fault('file_inventory_aborted'));};signal.addEventListener('abort',abort,{once:true});if(signal.aborted)abort();task.then(value=>{signal.removeEventListener('abort',abort);if(!signal.aborted)resolve(value);},error=>{signal.removeEventListener('abort',abort);if(!signal.aborted)reject(error);});}); return new Promise((resolve,reject)=>{const abort=()=>{signal.removeEventListener('abort',abort);reject(fault('file_inventory_aborted'));};signal.addEventListener('abort',abort,{once:true});if(signal.aborted)abort();task.then(value=>{signal.removeEventListener('abort',abort);if(!signal.aborted)resolve(value);},error=>{signal.removeEventListener('abort',abort);if(!signal.aborted)reject(error);});});
} }
+11 -4
View File
@@ -12,11 +12,13 @@ import type { LedgerSnapshot } from './ledger.ts';
/** A complete read cannot rewind known scopes or change an unchanged-version canvas. */ /** A complete read cannot rewind known scopes or change an unchanged-version canvas. */
/** 校验完整 fresh read:同项目、合法文档、活 canvas 版本齐全、已知版本和墓碑均不可倒退。 /** 校验完整 fresh read:同项目、合法文档、活 canvas 版本齐全、已知版本和墓碑均不可倒退。
* 某 canvas 版本没变时,其记录/直属元素必须相同;全向量没变时墓碑集合也不能偷偷改变。 */ * 某 canvas 版本没变时,其记录/直属元素必须相同;全向量没变时墓碑集合也不能偷偷改变。
* 只校验模型一致性/单调性,不证明响应身份或读取来自何服务;传输认证与实际完整覆盖由端口保证。 */
export function validateCanonicalRead(previous: Document, previousVersions: ReadonlyMap<Id,number>, next: Document, versions: ReadonlyMap<Id,number>, registry: Registry) { export function validateCanonicalRead(previous: Document, previousVersions: ReadonlyMap<Id,number>, next: Document, versions: ReadonlyMap<Id,number>, registry: Registry) {
if(next.projectId!==previous.projectId)throw fault('project_mismatch'); if(next.projectId!==previous.projectId)throw fault('project_mismatch');
validateDocument(next,registry); validateDocument(next,registry);
for(const [id,version]of versions)if(!Number.isSafeInteger(version)||version<1)throw fault('invalid_canonical_version',[id]); for(const [id,version]of versions)if(!Number.isSafeInteger(version)||version<1)throw fault('invalid_canonical_version',[id]);
// 活 canvas 必须有版本,但不要求版本表只含活项;已移除 scope 的已知水位也可继续保留。
for(const canvas of next.canvases.keys())if(!versions.has(canvas))throw fault('missing_server_version',[canvas]); for(const canvas of next.canvases.keys())if(!versions.has(canvas))throw fault('missing_server_version',[canvas]);
// 记录服务声明“版本未变”的 owner,随后双向检查元素,既防遗漏删除也防夹带新增。 // 记录服务声明“版本未变”的 owner,随后双向检查元素,既防遗漏删除也防夹带新增。
const unchanged=new Set<Id>(); const unchanged=new Set<Id>();
@@ -34,10 +36,12 @@ export function validateCanonicalRead(previous: Document, previousVersions: Read
} }
/** The footprint follows ownership; geometry does not decide which canvas owns an object. */ /** The footprint follows ownership; geometry does not decide which canvas owns an object. */
/** 计算跨 canvas 操作 footprint:有已冻结意图范围时沿用,否则收集新旧 owner/父级/端点归属。 /** 计算跨 canvas footprint:声明了非空 intent.canvasIds 就直接沿用,不以是否已冻结为条件。
* 上传和 run.cancel 不占文档参与者;空间位置或画框相交不决定 owner。 */ * 否则从变化收集新旧 owner/父级/端点归属;上传和 run.cancel 总是返回空文档参与者。
* 不根据画框相交或 run 的 boardId/action 自动猜参与者;ID 有效性与编译覆盖另由边界核对。 */
export function operationCanvases(intent: OperationIntent, document: Document, after:Document=document): readonly Id[] { export function operationCanvases(intent: OperationIntent, document: Document, after:Document=document): readonly Id[] {
if(intent.operationType==='asset.upload'||intent.operationType==='run.cancel')return[]; if(intent.operationType==='asset.upload'||intent.operationType==='run.cancel')return[];
// 已声明范围按原引用/顺序返回,不在此排序去重;仅下方推导路径用 Set 并排序。
if (intent.canvasIds?.length) return intent.canvasIds; if (intent.canvasIds?.length) return intent.canvasIds;
// 字段变化不能只查 after;重挂/改端点时旧 owner 也参与版本条件和占用。 // 字段变化不能只查 after;重挂/改端点时旧 owner 也参与版本条件和占用。
const ids = new Set<Id>(); const ids = new Set<Id>();
@@ -64,18 +68,21 @@ export function operationCanvases(intent: OperationIntent, document: Document, a
/** Replay once, by logical sequence. A receipt can already contain a desired field value. */ /** Replay once, by logical sequence. A receipt can already contain a desired field value. */
/** 将本地生命周期水位叠到 ACK 副本,再按 sequence 回放 document.change/run.apply。 /** 将本地生命周期水位叠到 ACK 副本,再按 sequence 回放 document.change/run.apply。
* 只有已满足的 after 可安全跳过,条件冲突仍拒绝;不会自动改写原意图或冻结请求。 */ * 底层按同 incarnation/字段存在性判断已满足 after,并检查 field owner 范围;条件冲突仍拒绝。
* 不改写意图/冻结请求,不移动 history;historyEffect 只在此验证形状,不重新执行尺寸历史规划。 */
export function replayPending(base: Document, ledger: LedgerSnapshot, registry: Registry): Document { export function replayPending(base: Document, ledger: LedgerSnapshot, registry: Registry): Document {
/** 已 resolved 项不重放;未冻结、冻结或 blocked 的未解决文档意图仍按原事实参与重建。 */ /** 已 resolved 项不重放;未冻结、冻结或 blocked 的未解决文档意图仍按原事实参与重建。 */
function* pending(){ function* pending(){
for(const record of [...ledger.operations.values()].sort((a,b)=>a.intent.sequence-b.intent.sequence)){ for(const record of [...ledger.operations.values()].sort((a,b)=>a.intent.sequence-b.intent.sequence)){
if(record.phase==='resolved'||!['document.change','run.apply'].includes(record.intent.operationType))continue; if(record.phase==='resolved'||!['document.change','run.apply'].includes(record.intent.operationType))continue;
// 只筛 document.change/run.apply 的变化;上传、run.start/cancel 不因存在 changes 字段就在这里执行。
if(record.intent.historyEffect)validateCreationSizeEffect(record.intent.historyEffect); if(record.intent.historyEffect)validateCreationSizeEffect(record.intent.historyEffect);
yield {changes:record.intent.changes,canvasIds:record.intent.canvasIds}; yield {changes:record.intent.changes,canvasIds:record.intent.canvasIds};
} }
} }
// 本地抵消可能从未进入服务器,水位只加到 working 候选,不能冒充服务器已确认墓碑。 // 本地抵消可能从未进入服务器,水位只加到 working 候选,不能冒充服务器已确认墓碑。
const document=replayChanges(withLocalLifecycles(base,ledger),pending()); const document=replayChanges(withLocalLifecycles(base,ledger),pending());
// 全部回放成功后检查最终工作文档;任何异常让调用者丢弃这份候选,不在此处理冲突 UI 或覆盖 ACK。
validateDocument(document,registry); validateDocument(document,registry);
return document; return document;
} }
+11 -2
View File
@@ -10,7 +10,10 @@ import type {EngineSnapshot,RuntimeState} from './contracts.ts';
import {createResourcePinScan} from './resources.ts'; import {createResourcePinScan} from './resources.ts';
/** Complete local-byte protection for one project scope; no DOM/viewport filtering. */ /** Complete local-byte protection for one project scope; no DOM/viewport filtering. */
/** 同时扫描 working 与 acknowledged;ACK 只额外取元素引用,避免把历史重复当另一套业务事实。 */ /** 派生 resource ID → 保留理由 Set,同时扫描 working 与 acknowledged。
* 复用同一 history/ledger 解释器,但 ACK 只额外合入 element: 理由;两次扫描完整性仍都参与结果。
* displays 是资源 ID→租用数,temporary/resourcePins 是 owner→资源 ID 列表,不是同方向的表。
* 返回捕获的只读表/集合;不写 runtime、不增减持久引用计数,也不盘点文件是否实际存在。 */
export function resourceRetentionPins(snapshot:EngineSnapshot,registry:Registry,displays:ReadonlyMap<Id,number>=new Map(),temporary:ReadonlyMap<Id,readonly Id[]>=new Map()) { export function resourceRetentionPins(snapshot:EngineSnapshot,registry:Registry,displays:ReadonlyMap<Id,number>=new Map(),temporary:ReadonlyMap<Id,readonly Id[]>=new Map()) {
const {runtime}=snapshot; const {runtime}=snapshot;
const scan=createResourcePinScan(snapshot.history,runtime.ledger,registry); const scan=createResourcePinScan(snapshot.history,runtime.ledger,registry);
@@ -26,20 +29,26 @@ export function resourceRetentionPins(snapshot:EngineSnapshot,registry:Registry,
// 未结束/未封存导入和 opaque run 保留已知资源仓库,不假设尚未解释的 envelope 没有引用。 // 未结束/未封存导入和 opaque run 保留已知资源仓库,不假设尚未解释的 envelope 没有引用。
for(const job of runtime.imports?.values()??[])if(!['completed','partial','cancelled'].includes(job.status)||importNeedsSeal(job)){if(job.source)add(job.source.fileId,`import-source:${job.id}`);for(const [id,resource]of runtime.resources)if(resource.retirement?.purgedAt===undefined)add(id,`import:${job.id}`);} for(const job of runtime.imports?.values()??[])if(!['completed','partial','cancelled'].includes(job.status)||importNeedsSeal(job)){if(job.source)add(job.source.fileId,`import-source:${job.id}`);for(const [id,resource]of runtime.resources)if(resource.retirement?.purgedAt===undefined)add(id,`import:${job.id}`);}
for(const run of runtime.runs.values())for(const [id,resource]of runtime.resources)if(resource.retirement?.purgedAt===undefined)add(id,`run:${run.id}`); for(const run of runtime.runs.values())for(const [id,resource]of runtime.resources)if(resource.retirement?.purgedAt===undefined)add(id,`run:${run.id}`);
// imports 只在非终态或仍需 seal 时 pin,runs 则不按 status 过滤;它们保留已知且未 purge 的资源。
// 这种保守 pin 不会自行把 complete 改为 false,也无法列出未知仓库 ID;全局/孤儿回收还需更强覆盖门槛。
// 等待解码或没有解释器的状态也 pin;显示 URL 租用数与临时任务另外合并。 // 等待解码或没有解释器的状态也 pin;显示 URL 租用数与临时任务另外合并。
for(const resource of runtime.resources.values())if(resource.image&&(resource.image.version!==1||resource.image.status==='pending'))add(resource.id,resource.image.version!==1?'image-decoder-unavailable':'image-decode'); for(const resource of runtime.resources.values())if(resource.image&&(resource.image.version!==1||resource.image.status==='pending'))add(resource.id,resource.image.version!==1?'image-decoder-unavailable':'image-decode');
for(const [owner,ids]of temporary)for(const id of ids)add(id,owner); for(const [owner,ids]of temporary)for(const id of ids)add(id,owner);
for(const [id,count]of displays)if(count>0)add(id,'display'); for(const [id,count]of displays)if(count>0)add(id,'display');
// sizing 未知版本也降低覆盖完整性,即使本轮没有形成额外 pin;complete 不是文件可读/可删证明。
return Object.freeze({complete:current.complete&&base.complete&&![...runtime.resources.values()].some(resource=>resource.image&&resource.image.version!==1||resource.sizing&&resource.sizing.version!==1),pins:readMap(new Map([...pins].map(([id,owners])=>[id,readSet(owners)])))}); return Object.freeze({complete:current.complete&&base.complete&&![...runtime.resources.values()].some(resource=>resource.image&&resource.image.version!==1||resource.sizing&&resource.sizing.version!==1),pins:readMap(new Map([...pins].map(([id,owners])=>[id,readSet(owners)])))});
} }
/** Runs inside the editor's single publication boundary, including reconcile and metadata commits. */ /** Runs inside the editor's single publication boundary, including reconcile and metadata commits. */
/** 所有 Root 发布路径共同检查回收:reserved 正在原子删除时,不允许新 pin 或不完整覆盖闯入。 */ /** 由 engine 的 Root 发布钩子调用:检查全局 reserved 集合,清除失效的软退休标记。
* reserved 非空时,任一保留 ID 相交或扫描不完整都会拒绝发布;它本身不获取/释放 reservation。
* 只按 next 检查保留状态,不比较 previous 来证明退休时钟单调,也不执行磁盘删除或等待保留期。 */
export function prepareResourceRetention(next:EngineSnapshot,registry:Registry,displays:ReadonlyMap<Id,number>,reserved:ReadonlySet<Id>,temporary:ReadonlyMap<Id,readonly Id[]>=new Map()):RuntimeState { export function prepareResourceRetention(next:EngineSnapshot,registry:Registry,displays:ReadonlyMap<Id,number>,reserved:ReadonlySet<Id>,temporary:ReadonlyMap<Id,readonly Id[]>=new Map()):RuntimeState {
// 没有待回收工作/软标记时快速复用 runtime,避免每次普通编辑重扫整个资源集合。 // 没有待回收工作/软标记时快速复用 runtime,避免每次普通编辑重扫整个资源集合。
if(!reserved.size&&![...next.runtime.resources.values()].some(resource=>resource.retirement&&(resource.retirement.purgedAt===undefined||!resource.stored)))return next.runtime; if(!reserved.size&&![...next.runtime.resources.values()].some(resource=>resource.retirement&&(resource.retirement.purgedAt===undefined||!resource.stored)))return next.runtime;
const pins=resourceRetentionPins(next,registry,displays,temporary); const pins=resourceRetentionPins(next,registry,displays,temporary);
if(reserved.size&&(!pins.complete||[...reserved].some(id=>pins.pins.has(id))))throw fault('resource_collection_in_progress',[...reserved]); if(reserved.size&&(!pins.complete||[...reserved].some(id=>pins.pins.has(id))))throw fault('resource_collection_in_progress',[...reserved]);
// 没有标记需清除时沿用 runtime;首次真正修改才复制 resources 表,其余资源记录继续共享。
let resources=next.runtime.resources; let resources=next.runtime.resources;
for(const [id,resource]of next.runtime.resources){ for(const [id,resource]of next.runtime.resources){
if(!resource.retirement)continue; if(!resource.retirement)continue;
+39 -14
View File
@@ -1,7 +1,8 @@
/** /**
* 从 ledger 派生执行顺序的调度器:ready FIFO+queued 去重、依赖索引、canvas claims 与重试 min-heap。 * 从 ledger 派生执行顺序的调度器:ready FIFO+queued 去重、依赖索引、canvas claims 与重试 min-heap。
* 所有权威变化通过 options.mutate 写回 Root;队列本身既不是历史,也不保存另一套任务状态。 * 所有权威变化通过 options.mutate 写回 Root;队列本身既不是历史,也不保存另一套任务状态。
* 发送前先持久化精确 body/key 和 dispatch 标记,结果协调及持久化成功后才释放占用。 * 发送前等待宿主持久化精确 body/key 和 dispatch 标记,结果协调及持久化成功后才释放占用。
* claims/holds 只约束本 scheduler;跨标签、进程或服务端的 writer/身份隔离由 engine 与 storage 端口负责。
*/ */
import type { Id, Json, OperationIntent } from '../canvas-core/types.ts'; import type { Id, Json, OperationIntent } from '../canvas-core/types.ts';
import { fault, readMap } from '../canvas-core/values.ts'; import { fault, readMap } from '../canvas-core/values.ts';
@@ -21,27 +22,40 @@ type Ledger = ReturnType<typeof createLedger>;
/** engine 提供准入、纯编译、原子账本更新、持久屏障、外部端口和完成协调。 /** engine 提供准入、纯编译、原子账本更新、持久屏障、外部端口和完成协调。
* complete 必须连回执/基线/working/ledger 一起处理,不允许仅把请求先标成功。 */ * complete 必须连回执/基线/working/ledger 一起处理,不允许仅把请求先标成功。 */
export type SchedulerOptions = { export type SchedulerOptions = {
/** false 进入准入等待集合,由 refreshAdmission 唤醒;不在此把逻辑操作改为 blocked。 */
admit?: (intent:OperationIntent)=>boolean; admit?: (intent:OperationIntent)=>boolean;
/** 每次准入/唤醒读取当前权威快照,不信任入队时捕获的旧状态。 */ /** 每次准入/唤醒读取当前权威快照,不信任入队时捕获的旧状态。 */
getLedger(): LedgerSnapshot; getLedger(): LedgerSnapshot;
/** 在 engine 的受控发布路径内修改账本,失败不能留下半份持久事实。 */ /** 在 engine 的受控发布路径内修改账本,失败不能留下半份持久事实。 */
mutate(reduce: (ledger: Ledger) => void): void; mutate(reduce: (ledger: Ledger) => void): void;
/** 等待当前 Root 可靠存储;失败保持请求身份与占用,不退回生成新请求。 */ /** 等待当前 Root 可靠存储;失败保持请求身份与占用,不退回生成新请求。
* scheduler 信任此 Promise 的持久承诺,本身不核对 durableGeneration/底层事务。 */
persist(): Promise<void>; persist(): Promise<void>;
/** 为新冻结请求分配 ID;恢复/重试沿用原 ID,不再次调用此工厂。 */
createId(): Id; createId(): Id;
/** 从当前意图推导需要 claim 的 canvas;编译结果必须覆盖同一集合。 */
participants(intent: OperationIntent): readonly Id[]; participants(intent: OperationIntent): readonly Id[];
/** 同步编译一个意图;不在这里发送,payload/版本随后由 ledger.freeze 捕获。 */
compile(intent: OperationIntent, requestId: Id): CompiledRequest; compile(intent: OperationIntent, requestId: Id): CompiledRequest;
/** 将选中的连续候选缩为非空前缀,返回值必须是 1..intents.length 的整数。 */
batchLimit?: (intents: readonly OperationIntent[]) => number; batchLimit?: (intents: readonly OperationIntent[]) => number;
/** 可选抵消协调钩子:先实际标记 ledger,再返回已抵消的候选前缀 ID;调度器不自行构造证明。 */
elide?: (intents: readonly OperationIntent[]) => readonly Id[]; elide?: (intents: readonly OperationIntent[]) => readonly Id[];
/** 多意图编译器仅在最终候选数 >1 时使用,单项仍走 compile。 */
compileBatch?: (intents: readonly OperationIntent[], requestId: Id) => CompiledRequest; compileBatch?: (intents: readonly OperationIntent[], requestId: Id) => CompiledRequest;
/** 使用保存的冻结请求发写端口;端口负责协议/权限与回执解释,不得重编 payload。 */
submit(request: FrozenRequest, signal: AbortSignal): Promise<OperationStatus>; submit(request: FrozenRequest, signal: AbortSignal): Promise<OperationStatus>;
/** 按原请求身份查结果,端口解释回执;端口抛错时 scheduler 统一记为 unresolved/transport_unknown。 */
query(request: FrozenRequest, signal: AbortSignal): Promise<OperationStatus>; query(request: FrozenRequest, signal: AbortSignal): Promise<OperationStatus>;
/** 恢复时确认冻结格式是否有解释器;省略钩子只默认支持 protocolVersion=1。 */
supportsRequest?: (request: FrozenRequest) => boolean; supportsRequest?: (request: FrozenRequest) => boolean;
/** Atomically reconcile the receipt/base/working document and the ledger outcome. */ /** 同步协调回执/base/working 与 ledger 的宿主边界;不得返回异步准备结果。 */
complete(request: FrozenRequest, status: OperationStatus): { readonly blocked: true } | void; complete(request: FrozenRequest, status: OperationStatus): { readonly blocked: true } | void;
/** 报告存储/协调/编译错误,scheduler 隔离此回调抛错,不能用它改写请求结果。 */
onError(error: unknown): void; onError(error: unknown): void;
clock?: Clock; clock?: Clock;
concurrency?: number; concurrency?: number;
/** 按 submit attempt 提供有限退避毫秒;正常值取至少 1ms,端口 retryAfterMs 可覆盖此次值。 */
retryDelay?: (attempt: number) => number; retryDelay?: (attempt: number) => number;
}; };
@@ -58,7 +72,8 @@ export function createScheduler(options: SchedulerOptions) {
const waiters = new Map<Id, Set<Id>>(), dependents = new Map<Id, Set<Id>>(), indexed = new Set<Id>(); const waiters = new Map<Id, Set<Id>>(), dependents = new Map<Id, Set<Id>>(), indexed = new Set<Id>();
// 最早 dueAt 优先;同到期按入堆 sequence 稳定排序,token 让旧 timer/旧重试条目惰性失效。 // 最早 dueAt 优先;同到期按入堆 sequence 稳定排序,token 让旧 timer/旧重试条目惰性失效。
const retry = createMinHeap<{ id: Id; dueAt: number; sequence: number; token: number }>((a, b) => a.dueAt - b.dueAt || a.sequence - b.sequence); const retry = createMinHeap<{ id: Id; dueAt: number; sequence: number; token: number }>((a, b) => a.dueAt - b.dueAt || a.sequence - b.sequence);
// active 只跟踪当前异步句柄/AbortController;任务完成事实仍必须回到 ledger。 // active 跟踪整条异步流水线(包含发送前/后的持久化等待),不是仅统计正在进行的 HTTP 请求。
// 句柄/AbortController 属于调度器,任务完成事实仍必须回到 ledger。
const tokens = new Map<Id, number>(), active = new Map<Id, { controller: AbortController; promise: Promise<void>; kind: FrozenRequest['kind'] }>(); const tokens = new Map<Id, number>(), active = new Map<Id, { controller: AbortController; promise: Promise<void>; kind: FrozenRequest['kind'] }>();
// 多个恢复/关闭流程可独立 hold;只有最后一个 token 释放才继续准入,已启动工作仍正常收尾。 // 多个恢复/关闭流程可独立 hold;只有最后一个 token 释放才继续准入,已启动工作仍正常收尾。
const holds = new Set<symbol>(); const holds = new Set<symbol>();
@@ -73,7 +88,7 @@ export function createScheduler(options: SchedulerOptions) {
const report = (error: unknown) => { try { options.onError(error); } catch { /* Reporting cannot change a receipt. */ } }; const report = (error: unknown) => { try { options.onError(error); } catch { /* Reporting cannot change a receipt. */ } };
/** 旧冻结协议必须有解释器才重试;缺钩子时仅默认支持协议 1。 */ /** 旧冻结协议必须有解释器才重试;缺钩子时仅默认支持协议 1。 */
const supported = (request: FrozenRequest) => options.supportsRequest?.(request) ?? request.protocolVersion === 1; const supported = (request: FrozenRequest) => options.supportsRequest?.(request) ?? request.protocolVersion === 1;
/** 总并发上限之外,上传独占一个 lane;其它类别仍可使用剩余网络执行位。 */ /** 总 active 上限之外,asset.upload 至多占一个 lane;其它类别可用剩余流水线执行位。 */
const canRun = (kind: FrozenRequest['kind']) => active.size < concurrency && (kind !== 'asset.upload' || ![...active.values()].some(task => task.kind === 'asset.upload')); const canRun = (kind: FrozenRequest['kind']) => active.size < concurrency && (kind !== 'asset.upload' || ![...active.values()].some(task => task.kind === 'asset.upload'));
/** 先读 ledger 检查 pending/依赖/门禁,再用 queued 去重入 FIFO;不是通知一次就盲发一次。 */ /** 先读 ledger 检查 pending/依赖/门禁,再用 queued 去重入 FIFO;不是通知一次就盲发一次。 */
@@ -100,7 +115,8 @@ export function createScheduler(options: SchedulerOptions) {
enqueue(id); enqueue(id);
} }
/** Only a contiguous mutable document prefix may share a request. Task barriers stay visible. */ /** Only a contiguous mutable document prefix may share a request. Task barriers stay visible. */
/** 只选连续未冻结 document.change 前缀,最多 64 意图/10000 changes,遇任务屏障或占用即停。 /** 只选连续未冻结 document.change 前缀,最多 64 意图;追加候选时不让累计 changes 超过 10000。
* 首意图本身超过 10000 时仍可单独返回,不把它拆成字段请求;遇任务屏障/占用即停。
* 仅合并有相交参与者的 lane;独立 canvas 保留各自失败/重试边界。 */ * 仅合并有相交参与者的 lane;独立 canvas 保留各自失败/重试边界。 */
function batch(first: OperationIntent, canvases: Set<Id>): readonly OperationIntent[] { function batch(first: OperationIntent, canvases: Set<Id>): readonly OperationIntent[] {
const selected = [first], ids = new Set([first.id]); const selected = [first], ids = new Set([first.id]);
@@ -143,7 +159,8 @@ export function createScheduler(options: SchedulerOptions) {
} }
for (const id of request.operationIds) for (const dependent of dependents.get(id) ?? []) enqueue(dependent); for (const id of request.operationIds) for (const dependent of dependents.get(id) ?? []) enqueue(dependent);
} }
/** 可选写回可恢复 retry 元数据,再用新 token 入 heap;重建索引时 persist=false 避免重复写事实。 */ /** 可选 mutate 写回 retry 元数据,再用新 token 入 heap;persist=false 仅表示不重复写账本。
* 此处没有 await 存储屏障,真正可靠落盘仍由 launch/resume 的 options.persist 完成。 */
function schedule(id: Id, dueAt: number, action: 'submit' | 'query', persist = true) { function schedule(id: Id, dueAt: number, action: 'submit' | 'query', persist = true) {
if (persist) options.mutate(ledger => ledger.scheduleRetry(id, dueAt, action)); if (persist) options.mutate(ledger => ledger.scheduleRetry(id, dueAt, action));
const token = (tokens.get(id) ?? 0) + 1; tokens.set(id, token); const token = (tokens.get(id) ?? 0) + 1; tokens.set(id, token);
@@ -160,7 +177,8 @@ export function createScheduler(options: SchedulerOptions) {
if (stopped || holds.size || active.size >= concurrency || !retry.peek()) return; if (stopped || holds.size || active.size >= concurrency || !retry.peek()) return;
timer = clock.setTimer(() => { timer = undefined; pump(); }, Math.max(0, retry.peek()!.dueAt - clock.now())); timer = clock.setTimer(() => { timer = undefined; pump(); }, Math.max(0, retry.peek()!.dueAt - clock.now()));
} }
/** 安装 active 句柄后在微任务中执行:dispatch 标记→持久化→submit/query→原子协调→持久化。 */ /** 安装 active 句柄后在微任务中执行:submit 先标 dispatch;query 保留现有发送风险,再共同等待持久化。
* 调端口→同步 complete→再次持久化;hold 不撤回已启动流水线,stop 则让晚到结果不再采纳。 */
function launch(id: Id, action: 'submit' | 'query') { function launch(id: Id, action: 'submit' | 'query') {
if (active.has(id) || stopped || holds.size) return; if (active.has(id) || stopped || holds.size) return;
const controller = new AbortController(); const controller = new AbortController();
@@ -186,6 +204,7 @@ export function createScheduler(options: SchedulerOptions) {
else { else {
// A missing receipt is unknown, so the only write retry reuses this frozen request. // A missing receipt is unknown, so the only write retry reuses this frozen request.
// 首次 submit 未知先 query;query 仍未知才原样重试 submit;accepted 一直查,不换请求身份。 // 首次 submit 未知先 query;query 仍未知才原样重试 submit;accepted 一直查,不换请求身份。
// accepted 的查询/submit 都继续 query;其它 submit 结果先 query,非 accepted 的 query 结果才允许同身份 submit。
const nextAction = status.outcome === 'accepted' || action === 'submit' ? 'query' : 'submit'; const nextAction = status.outcome === 'accepted' || action === 'submit' ? 'query' : 'submit';
schedule(id, clock.now() + Math.max(1, status.retryAfterMs ?? delay(current.attempt)), nextAction); schedule(id, clock.now() + Math.max(1, status.retryAfterMs ?? delay(current.attempt)), nextAction);
await options.persist(); await options.persist();
@@ -203,7 +222,8 @@ export function createScheduler(options: SchedulerOptions) {
}); });
active.set(id, { controller, promise, kind: options.getLedger().requests.get(id)?.kind }); active.set(id, { controller, promise, kind: options.getLedger().requests.get(id)?.kind });
} }
/** 可重入保护下消费到期请求和最多 64 个 ready 项;等待者移到索引,不在 FIFO 无界反复轮询。 */ /** 防同步重入地处理到期请求,再从本轮 ready 前缀最多取 64 项;retry 循环没有同样的 64 项上限。
* 等待者移到索引,ready 尚有余量且有容量时安排下一轮 timer,不代表总队列大小有上限。 */
function pump() { function pump() {
if (stopped || holds.size || pumping) return; if (stopped || holds.size || pumping) return;
pumping = true; pumping = true;
@@ -250,7 +270,8 @@ export function createScheduler(options: SchedulerOptions) {
} }
const intents = options.compileBatch ? candidates : [record.intent]; const intents = options.compileBatch ? candidates : [record.intent];
const participants = [...new Set(intents.flatMap(intent => options.participants(intent)))].sort(); const participants = [...new Set(intents.flatMap(intent => options.participants(intent)))].sort();
// 编译参与者必须与规划一致,随后 freeze/own/launch;失败阻塞原意图而非偷偷重新编另一请求。 // 编译参与者必须与实际 intents 推导的集合一致,随后 freeze/own/launch;没有 compileBatch 时只编译首意图。
// 异常走原逻辑 ID 的 block;若已 frozen,ledger.block 不会把它退回 pending,冻结身份仍保留。
const requestId = options.createId(), compiled = intents.length > 1 ? options.compileBatch!(intents, requestId) : options.compile(record.intent, requestId); const requestId = options.createId(), compiled = intents.length > 1 ? options.compileBatch!(intents, requestId) : options.compile(record.intent, requestId);
if (participants.length !== compiled.canvasIds.length || participants.some(canvas => !compiled.canvasIds.includes(canvas))) throw fault('participant_mismatch'); if (participants.length !== compiled.canvasIds.length || participants.some(canvas => !compiled.canvasIds.includes(canvas))) throw fault('participant_mismatch');
options.mutate(ledger => ledger.freeze({ ...compiled, id: requestId, operationIds: intents.map(intent => intent.id) })); options.mutate(ledger => ledger.freeze({ ...compiled, id: requestId, operationIds: intents.map(intent => intent.id) }));
@@ -267,7 +288,8 @@ export function createScheduler(options: SchedulerOptions) {
} }
} }
return Object.freeze({ return Object.freeze({
/** 首次启动(已运行时幂等):先恢复全部未解决 claims,按原 retry/body/key 继续;解释器缺失仍保留占用。 */ /** 首次启动(已运行时幂等):先恢复全部未解决 claims,之后才开始端口工作;claim 冲突会在仍 stopped 时抛错。
* 解释器缺失保留占用并写 blocker;此步骤不做完整恢复 DTO 校验,也不从单个错误推断请求未执行。 */
start() { start() {
if (closed) throw fault('scheduler_closed'); if (closed) throw fault('scheduler_closed');
if (!stopped) return; if (!stopped) return;
@@ -284,7 +306,8 @@ export function createScheduler(options: SchedulerOptions) {
}, },
/** 门禁事实变化后显式重试准入,未满足者继续等待,不制造定时器忙轮询。 */ /** 门禁事实变化后显式重试准入,未满足者继续等待,不制造定时器忙轮询。 */
refreshAdmission(){for(const id of [...admissionWaiters])enqueue(id);if(ready.size)requestPump();}, refreshAdmission(){for(const id of [...admissionWaiters])enqueue(id);if(ready.size)requestPump();},
/** 新逻辑操作通知只推进派生索引,重复 notify 不重复注册依赖或入队。 */ /** 新逻辑操作通知推进派生索引;正在 queued 时不重复入队,indexed 让依赖只注册一次。
* 已出队的 pending 操作后续 notify 可再次尝试准入;通知本身不新增 ledger 意图。 */
notify(operationIds: readonly Id[]) { for (const id of operationIds) index(id); requestPump(); }, notify(operationIds: readonly Id[]) { for (const id of operationIds) index(id); requestPump(); },
/** Acquire synchronously, then settle(): already admitted work completes with its original identity. */ /** Acquire synchronously, then settle(): already admitted work completes with its original identity. */
/** 同步阻止新准入并清唤醒 timer;返回幂等释放函数,已准入异步工作不被撤销或换身份。 */ /** 同步阻止新准入并清唤醒 timer;返回幂等释放函数,已准入异步工作不被撤销或换身份。 */
@@ -316,7 +339,8 @@ export function createScheduler(options: SchedulerOptions) {
}, },
/** 返回当前占用表的只读副本,调用者不能手改 claim 绕过未知请求。 */ /** 返回当前占用表的只读副本,调用者不能手改 claim 绕过未知请求。 */
getClaims: () => readMap(claims), getClaims: () => readMap(claims),
/** 等待当前微任务/pump/active 收尾,不主动等待未来 retry timer,因此不是“全部已同步”保证。 */ /** 等待当前微任务/pump/active 收尾,不主动等待未来 retry timer,因此不是“全部已同步”保证。
* 端口若长期不完成(包括忽略 abort),active Promise 仍可让 settle 一直等待。 */
async settle() { async settle() {
do { do {
if (scheduledPump) await scheduledPump; if (scheduledPump) await scheduledPump;
@@ -324,7 +348,8 @@ export function createScheduler(options: SchedulerOptions) {
if (!stopped && !holds.size && pumpTimer !== undefined) { clock.clearTimer(pumpTimer); pumpTimer = undefined; pump(); } if (!stopped && !holds.size && pumpTimer !== undefined) { clock.clearTimer(pumpTimer); pumpTimer = undefined; pump(); }
} while (scheduledPump || active.size || (!stopped && !holds.size && pumpTimer !== undefined)); } while (scheduledPump || active.size || (!stopped && !holds.size && pumpTimer !== undefined));
}, },
/** 永久关闭本 scheduler、取消 timer 并 abort active;晚到响应因 stopped 不再采纳,不能再 start。 */ /** 永久关闭本 scheduler、取消 timer 并请求 abort active;晚到响应因 stopped 不再采纳,不能再 start。
* abort 不证明外部未执行,也不清 ledger/claims/派生容器或替 engine 关闭存储;活动句柄在 finally 收尾。 */
stop() { closed = true; stopped = true; if (timer !== undefined) clock.clearTimer(timer); if (pumpTimer !== undefined) clock.clearTimer(pumpTimer); timer = pumpTimer = undefined; for (const item of active.values()) item.controller.abort(); }, stop() { closed = true; stopped = true; if (timer !== undefined) clock.clearTimer(timer); if (pumpTimer !== undefined) clock.clearTimer(pumpTimer); timer = pumpTimer = undefined; for (const item of active.values()) item.controller.abort(); },
}); });
} }
+10 -4
View File
@@ -8,7 +8,9 @@ import {fault} from '../canvas-core/values.ts';
/** Snapshot-bound direct membership counts, including nodes and connections. */ /** Snapshot-bound direct membership counts, including nodes and connections. */
/** 当前捕获快照的直属数量,包含节点和边;不是递归子树大小或 DOM 已挂载数量。 */ /** 当前捕获快照的直属数量,包含节点和边;不是递归子树大小或 DOM 已挂载数量。 */
export type CanvasElementCounts=Readonly<Record<Id,number>>; export type CanvasElementCounts=Readonly<Record<Id,number>>;
/** 要求 counts 精确覆盖 canvasIds、每项非负安全整数,且总和等于声明 total。 */ /** 检查自有可枚举键数量及 canvasIds 的每个键存在,计数为非负安全整数,总和安全且等于 total。
* canvasIds 须由调用方保证唯一;此函数不单独去重,重复声明会破坏“键数量相同即覆盖相同”的前提。
* 只验证声明内部一致,不读取真实元素、认证服务或证明后续 payload 已齐;输入也不在此捕获/冻结。 */
export function validateCanvasElementCounts(value:unknown,canvasIds:readonly Id[],total:number):asserts value is CanvasElementCounts { export function validateCanvasElementCounts(value:unknown,canvasIds:readonly Id[],total:number):asserts value is CanvasElementCounts {
if(!value||typeof value!=='object'||Array.isArray(value))throw fault('invalid_model_coverage'); if(!value||typeof value!=='object'||Array.isArray(value))throw fault('invalid_model_coverage');
const counts=value as Record<Id,unknown>,keys=Object.keys(counts);let sum=0; const counts=value as Record<Id,unknown>,keys=Object.keys(counts);let sum=0;
@@ -20,7 +22,9 @@ export function validateCanvasElementCounts(value:unknown,canvasIds:readonly Id[
/** All active IDs in the capture, including records not yet received. */ /** All active IDs in the capture, including records not yet received. */
/** 完整活 ID 命名空间,即使部分记录尚未传到客户端,也预先知道其直接 owner。 */ /** 完整活 ID 命名空间,即使部分记录尚未传到客户端,也预先知道其直接 owner。 */
export type ElementOwners=Readonly<Record<Id,Id>>; export type ElementOwners=Readonly<Record<Id,Id>>;
/** 核验每个活 ID 的 owner 合法,并按 owner 反算数量与 counts 一致,不靠已读前缀猜完整性。 */ /** 要求声明恰有 total 个自有可枚举活 ID,每个非空 ID 的 owner 是 counts 中已有键,再反算数量。
* counts 应先经计数校验;此处不独立验证其值域,也不与已读 document 比较 ID/owner 是否对应。
* 只做同步声明一致性检查,不捕获/冻结输入;实际 payload 到达后还需匹配这份源快照身份表。 */
export function validateElementOwners(value:unknown,counts:CanvasElementCounts,total:number):asserts value is ElementOwners { export function validateElementOwners(value:unknown,counts:CanvasElementCounts,total:number):asserts value is ElementOwners {
if(!value||typeof value!=='object'||Array.isArray(value)||Object.keys(value).length!==total)throw fault('invalid_model_identity'); if(!value||typeof value!=='object'||Array.isArray(value)||Object.keys(value).length!==total)throw fault('invalid_model_identity');
// 无原型计数表避免特殊 owner 键污染计数;空 canvas 由缺省 0 与 counts 比较。 // 无原型计数表避免特殊 owner 键污染计数;空 canvas 由缺省 0 与 counts 比较。
@@ -30,8 +34,10 @@ export function validateElementOwners(value:unknown,counts:CanvasElementCounts,t
} }
/** Full captured topology, independent of which entry payloads have arrived. */ /** Full captured topology, independent of which entry payloads have arrived. */
/** 用完整 topology+owner 证明验证单根、父存在、入口唯一/归属正确和无环。 /** 用源 canvas 表和已验证 owners 声明检查唯一根=projectId、父存在、入口唯一/归属与无环。
* 此时入口记录可能尚未收到,入口是否确为 node 仍需 payload 到达后的文档校验。 */ * 不是完整 CanvasRecord/payload schema 校验;未收到入口时不能证明其为 node、组件可用或实际拥有关系一致。
* 每个子 canvas 重新走父链,深树会反复访问共享祖先;同步检查无预算/取消,应由捕获规模约束成本。
* 不捕获输入或标记文档已完整;加载器另需核对已收到树/元素与此声明,再建立 owner 的覆盖状态。 */
export function validateCanvasTopology(canvases:readonly import('../canvas-core/types.ts').CanvasRecord[],owners:ElementOwners,projectId:Id):void { export function validateCanvasTopology(canvases:readonly import('../canvas-core/types.ts').CanvasRecord[],owners:ElementOwners,projectId:Id):void {
if(!Array.isArray(canvases)||!canvases.length)throw fault('invalid_model_topology'); if(!Array.isArray(canvases)||!canvases.length)throw fault('invalid_model_topology');
const table=new Map(canvases.map(canvas=>[canvas?.id,canvas]));if(table.size!==canvases.length||!table.has(projectId))throw fault('invalid_model_topology'); const table=new Map(canvases.map(canvas=>[canvas?.id,canvas]));if(table.size!==canvases.length||!table.has(projectId))throw fault('invalid_model_topology');
+6 -2
View File
@@ -20,7 +20,9 @@ const K=new Uint32Array([
/** 32位循环右移;区别于简单逻辑右移,低位从左侧回填。 */ /** 32位循环右移;区别于简单逻辑右移,低位从左侧回填。 */
const rotate=(n:number,bits:number)=>(n>>>bits)|(n<<(32-bits)); const rotate=(n:number,bits:number)=>(n>>>bits)|(n<<(32-bits));
/** Incremental bytes only; owns at most a partial block, state and message schedule. */ /** Incremental bytes only; owns at most a partial block, state and message schedule. */
/** 新建单次摘要状态;允许多次 update,hex 终结后只可重复读结果,不能继续追加。 */ /** 新建单次摘要状态,闭包拥有可变 state/block/words;冻结 API 不代表内部计算状态不变。
* update 可多次追加,hex 首次终结后缓存结果,不能继续追加;不支持复制状态或导出恢复游标。
* 纯同步计算,无异步让出/取消;分块大小与调度由读取 Blob 的调用方控制。 */
export function createSha256(){ export function createSha256(){
// length 计原输入字节;pending 计尾块已占字节;result 缓存最终十六进制并标记终结。 // length 计原输入字节;pending 计尾块已占字节;result 缓存最终十六进制并标记终结。
const state=new Uint32Array([0x6a09e667,0xbb67ae85,0x3c6ef372,0xa54ff53a,0x510e527f,0x9b05688c,0x1f83d9ab,0x5be0cd19]),block=new Uint8Array(64),words=new Uint32Array(64);let pending=0,length=0,result:string|undefined; const state=new Uint32Array([0x6a09e667,0xbb67ae85,0x3c6ef372,0xa54ff53a,0x510e527f,0x9b05688c,0x1f83d9ab,0x5be0cd19]),block=new Uint8Array(64),words=new Uint32Array(64);let pending=0,length=0,result:string|undefined;
@@ -35,7 +37,9 @@ export function createSha256(){
const values=[a,b,c,d,e,f,g,h];for(let i=0;i<8;i++)state[i]=(state[i]+values[i])>>>0; const values=[a,b,c,d,e,f,g,h];for(let i=0;i<8;i++)state[i]=(state[i]+values[i])>>>0;
} }
return Object.freeze({ return Object.freeze({
/** 先补上次尾块,再就地压缩输入完整块,最后只复制不足64字节的尾部。 */ /** 先补尾块,再直接读取输入完整块压缩,最后只复制不足 64 字节的尾部,不保留输入引用。
* 不修改调用方字节;累计长度必须保持安全整数,终结后即使追加空数组也拒绝。
* 这里的就地指同步消费输入,不代表每次 update 只处理固定工作量或自动让出主线程。 */
update(bytes:Uint8Array){ update(bytes:Uint8Array){
if(result!==undefined)throw fault('digest_finalized');if(!(bytes instanceof Uint8Array)||!Number.isSafeInteger(length+bytes.byteLength))throw fault('invalid_digest_input');length+=bytes.byteLength;let offset=0; if(result!==undefined)throw fault('digest_finalized');if(!(bytes instanceof Uint8Array)||!Number.isSafeInteger(length+bytes.byteLength))throw fault('invalid_digest_input');length+=bytes.byteLength;let offset=0;
if(pending){const count=Math.min(64-pending,bytes.byteLength);block.set(bytes.subarray(0,count),pending);pending+=count;offset=count;if(pending===64){compress(block,0);pending=0;}} if(pending){const count=Math.min(64-pending,bytes.byteLength);block.set(bytes.subarray(0,count),pending);pending+=count;offset=count;if(pending===64){compress(block,0);pending=0;}}
+4 -2
View File
@@ -1,6 +1,7 @@
/** /**
* runtime 使用的基础调度容器,不包含任务状态事实:FIFO 存可执行顺序,min-heap 存优先项。 * runtime 使用的基础调度容器,不包含任务状态事实:FIFO 存可执行顺序,min-heap 存优先项。
* 两者都是内存结构,ledger/checkpoint 才负责可恢复事实;容器本身不去重、不校验依赖或执行网络。 * 两者都是内存结构,ledger/checkpoint 才负责可恢复事实;容器本身不去重、不校验依赖或执行网络。
* T 值按引用保存/返回,不复制冻结任务对象,也不以 size/clear 修改外部任务事实。
*/ */
/** FIFO with amortized O(1) removal, without Array.shift on each item. */ /** FIFO with amortized O(1) removal, without Array.shift on each item. */
/** 当前接口是先进先出队列:push 入尾、pop 出头,并非 history 的后进先出 stack。 /** 当前接口是先进先出队列:push 入尾、pop 出头,并非 history 的后进先出 stack。
@@ -24,12 +25,13 @@ export function createDeque<T>() {
}, },
/** 同时丢弃底层数组和游标,释放旧任务引用。 */ /** 同时丢弃底层数组和游标,释放旧任务引用。 */
clear() { items = []; head = 0; }, clear() { items = []; head = 0; },
/** 返回有效区间的数组副本,外部改副本不会更改队列成员。 */ /** 返回有效区间的浅数组副本,改副本成员位置不改队列;对象成员仍共享引用。 */
values() { return items.slice(head); }, values() { return items.slice(head); },
}; };
} }
/** 数组实现二叉最小堆,compare<0 表示左项优先;peek O(1),push/pop O(log n)。 /** 数组实现二叉最小堆,compare<0 表示左项优先;peek O(1),push/pop O(log n)。
* 相等优先级不保证插入稳定性,上层若需要 FIFO tie-break 必须把序列写进 comparator。 */ * 相等优先级不保证插入稳定性,上层若需要 FIFO tie-break 必须把序列写进 comparator。
* compare 应稳定、同步且不抛错;容器不校验 comparator,比较中途异常也不提供事务回滚。 */
export function createMinHeap<T>(compare: (left: T, right: T) => number) { export function createMinHeap<T>(compare: (left: T, right: T) => number) {
// 父下标 (i-1)>>1,左右孩子 2i+1/2i+2;堆序只要求父不大于孩子,不是全数组排序。 // 父下标 (i-1)>>1,左右孩子 2i+1/2i+2;堆序只要求父不大于孩子,不是全数组排序。
const items: T[] = []; const items: T[] = [];
+20 -6
View File
@@ -23,7 +23,10 @@ export type WorkspaceOptions = {
}; };
/** Workspace owns persistent projects; a view only borrows one and never closes it. */ /** Workspace owns persistent projects; a view only borrows one and never closes it. */
/** 创建工作区容器,不立刻打开所有项目,只有 open/openPartial 才取得项目 engine。 */ /** 建立 workspace 共享池和 projectId→打开 Promise 表,不在构造时 claim 项目存储或恢复文档。
* 注入的 digests/images/storage 也由本容器在最终 close 时关闭,跨 workspace 共享它们需宿主另行管理所有权。
* options 仍由闭包读取,身份/端口应保持稳定;公开标量与 assetDepot scope 并非每次随外部 options 重写。
* getProjects 捕获只读表成员,但 Promise 兑现的 engine 仍是业务 API,不是不可操作的纯数据快照。 */
export function createWorkspaceRuntime(options: WorkspaceOptions) { export function createWorkspaceRuntime(options: WorkspaceOptions) {
const digests=options.digests??createDigestPool(); const digests=options.digests??createDigestPool();
const displays=createDisplayCache({onError:options.onError}); const displays=createDisplayCache({onError:options.onError});
@@ -33,7 +36,9 @@ export function createWorkspaceRuntime(options: WorkspaceOptions) {
/** 准入同时要求工作区仍接受任务和宿主身份仍有效,关闭一开始就让旧调用失效。 */ /** 准入同时要求工作区仍接受任务和宿主身份仍有效,关闭一开始就让旧调用失效。 */
const current = () => accepting && !closed && options.isCurrentIdentity(); const current = () => accepting && !closed && options.isCurrentIdentity();
const assetDepot=options.storage.assetDepot?createAssetDepot({scope:options,port:options.storage.assetDepot,current,digest:(bytes,signal)=>digests.digest(bytes,signal)}):undefined; const assetDepot=options.storage.assetDepot?createAssetDepot({scope:options,port:options.storage.assetDepot,current,digest:(bytes,signal)=>digests.digest(bytes,signal)}):undefined;
/** 取得/创建共享项目;打开失败只移除同一个 Promise,迟到失败不能删掉后来重开的实例。 */ /** current 通过后按 document.projectId 复用在途/已打开 Promise,避免同 workspace 同项目重复 claim leader。
* 命中时不以新 document/versions/loading 替换初始参数;open/openPartial 随后再协调新输入。
* 打开拒绝仅移除仍对应自己的 Promise,准入有效时下次可重试;adapters 的同步抛错直接传出。 */
function acquire(document:Document,versions:ReadonlyMap<Id,number>,loading?:LoadingState){ function acquire(document:Document,versions:ReadonlyMap<Id,number>,loading?:LoadingState){
if(!current())return Promise.reject(fault('identity_changed')); if(!current())return Promise.reject(fault('identity_changed'));
const existing=projects.get(document.projectId);if(existing)return existing; const existing=projects.get(document.projectId);if(existing)return existing;
@@ -46,20 +51,29 @@ export function createWorkspaceRuntime(options: WorkspaceOptions) {
assetDepot, assetDepot,
/** 查看项目打开句柄的只读表,不授予外部关闭/替换内部 ownership 的能力。 */ /** 查看项目打开句柄的只读表,不授予外部关闭/替换内部 ownership 的能力。 */
getProjects: () => readMap(projects), getProjects: () => readMap(projects),
/** 完整源与已有 engine 协调:同捕获完成走 loading.complete,旧版本不覆盖较新的本地基线。 */ /** 取得 engine 后复查身份:同 sourceToken 的 loading 用 complete,否则尝试普通 refresh。
* 任一传入版本低于当前已知值就跳过整个 refresh;其余一致性由 engine 校验,不在这里合并版本向量。
* complete 拒绝总会抛错;refresh 拒绝仅在原 engine 仍 loading 时抛错,非 loading 可保留原 engine 返回。
* 因此返回 engine 表示复用成功,不等于传入 document 已被采纳或服务器写入已完成。 */
open(document: Document, versions: ReadonlyMap<Id, number>,settings:{sourceToken?:string}={}) { open(document: Document, versions: ReadonlyMap<Id, number>,settings:{sourceToken?:string}={}) {
return acquire(document,versions).then(async engine=>{if(!current())throw fault('identity_changed');const loading=engine.loading.getState();if(loading&&settings.sourceToken===loading.sourceToken){const result=await engine.loading.complete(document,versions,{sourceToken:settings.sourceToken});if(result.status==='rejected')throw fault(result.code,result.targets);}else{const previous=engine.getSnapshot().runtime.versions;if(![...versions].some(([id,value])=>(previous.get(id)??0)>value)){const result=engine.refresh(document,versions);if(result.status==='rejected'&&loading)throw fault(result.code,result.targets);}}return engine;}); return acquire(document,versions).then(async engine=>{if(!current())throw fault('identity_changed');const loading=engine.loading.getState();if(loading&&settings.sourceToken===loading.sourceToken){const result=await engine.loading.complete(document,versions,{sourceToken:settings.sourceToken});if(result.status==='rejected')throw fault(result.code,result.targets);}else{const previous=engine.getSnapshot().runtime.versions;if(![...versions].some(([id,value])=>(previous.get(id)??0)>value)){const result=engine.refresh(document,versions);if(result.status==='rejected'&&loading)throw fault(result.code,result.targets);}}return engine;});
}, },
/** 接入部分源并复用项目 engine,仅相同 sourceToken 的后续块继续 accept。 */ /** 新项目以 partial 文档/loading 打开;已有项目只在当前 loading.sourceToken 匹配时 accept。
* 不同 token 或已无 loading 时直接返回原 engine,不替换其文档/加载任务;匹配但 accept 拒绝则抛错。 */
openPartial(document:Document,versions:ReadonlyMap<Id,number>,loading:LoadingState){ openPartial(document:Document,versions:ReadonlyMap<Id,number>,loading:LoadingState){
return acquire(document,versions,loading).then(engine=>{if(!current())throw fault('identity_changed');const previous=engine.loading.getState();if(previous?.sourceToken===loading.sourceToken){const result=engine.loading.accept(document,versions,loading);if(result.status==='rejected')throw fault(result.code,result.targets);}return engine;}); return acquire(document,versions,loading).then(engine=>{if(!current())throw fault('identity_changed');const previous=engine.loading.getState();if(previous?.sourceToken===loading.sourceToken){const result=engine.loading.accept(document,versions,loading);if(result.status==='rejected')throw fault(result.code,result.targets);}return engine;});
}, },
/** view 离开后工作区仍可补完项目加载;await 前后都检查身份,错误交宿主报告。 */ /** view 离开后仍可 resume 已打开项目的背景加载;等待 opening 前后检查 current。
* loading.resume 的在途身份/结果围栏由 engine 负责,此方法在其返回后不额外再查 current。
* 错误仅在当前身份仍有效时报告 onError,随后原样拒绝;不吞错或清除项目句柄。 */
async resumeLoading(projectId:Id){ async resumeLoading(projectId:Id){
try{if(!current())throw fault('identity_changed');const opening=projects.get(projectId);if(!opening)throw fault('project_not_open');const engine=await opening;if(!current())throw fault('identity_changed');const result=await engine.loading.resume();if(result.status==='rejected')throw fault(result.code,result.targets);return result;} try{if(!current())throw fault('identity_changed');const opening=projects.get(projectId);if(!opening)throw fault('project_not_open');const engine=await opening;if(!current())throw fault('identity_changed');const result=await engine.loading.resume();if(result.status==='rejected')throw fault(result.code,result.targets);return result;}
catch(error){if(current()){try{options.onError?.(error);}catch{}}throw error;} catch(error){if(current()){try{options.onError?.(error);}catch{}}throw error;}
}, },
/** 幂等/可重试关闭:复用当前 closing Promise,全部 engine 成功后才释放池和 storage。 */ /** 先 accepting=false 永久停止此实例新准入,再单飞等待全部在途打开/engine 关闭。
* 打开失败视为无需关闭;engine 关闭失败聚合抛错并保留项目/共享池,后续 close 可重试但不重新 accepting。
* 项目全成功后才依次同步关闭共享端口;这段不是原子事务,注入端口 close 应可安全重复,抛错也允许重试。
* 全部收尾成功才清 projects/置 closed;此接口不证明外部服务或服务器生成任务也已停止。 */
close() { close() {
accepting = false; // Fence commands/transports before awaiting pending opens or storage. accepting = false; // Fence commands/transports before awaiting pending opens or storage.
if (closed) return Promise.resolve(); if (closed) return Promise.resolve();
File diff suppressed because it is too large Load Diff
+40
View File
@@ -1,10 +1,17 @@
/**
* 已构建 Next 应用的本地 HTTP smoke:启动临时 Foundry 替身和 next start,遍历页面、代理、资源及认证响应。
* 需要已有可用构建,运行会占用 127.0.0.1:3005;脚本不执行 next build,也不连接真实 Foundry/模型服务。
* 断言以 HTTP、文件字节及内存状态为准,不验证浏览器交互、真实登录提供商或数据库事务。
*/
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { createServer } from "node:http"; import { createServer } from "node:http";
import { spawn } from "node:child_process"; import { spawn } from "node:child_process";
import { once } from "node:events"; import { once } from "node:events";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import { readdir, readFile } from "node:fs/promises"; import { readdir, readFile } from "node:fs/promises";
// 应用使用固定端口;upstream 另选随机端口,由子进程环境变量指定为代理源站。
const host = "http://127.0.0.1:3005"; const host = "http://127.0.0.1:3005";
// 图谱、画布版本、会话剩余秒数与 profile 都是本次进程的内存夹具。
const graphs = new Map(); const graphs = new Map();
let releaseStream = () => {}; let releaseStream = () => {};
let canvasSnapshot = { version: 1, document: { schema_version: 1, nodes: [], edges: [], viewport: { x: 0, y: 0, zoom: 1 } } }; let canvasSnapshot = { version: 1, document: { schema_version: 1, nodes: [], edges: [], viewport: { x: 0, y: 0, zoom: 1 } } };
@@ -13,23 +20,28 @@ let mockProfile = {
id: "user", username: "test", display_name: "Test User", email: "test@example.com", id: "user", username: "test", display_name: "Test User", email: "test@example.com",
avatar_url: "https://lh3.googleusercontent.com/test-avatar=s96-c", avatar_url: "https://lh3.googleusercontent.com/test-avatar=s96-c",
}; };
// 最小 Foundry HTTP 替身:有效测试会话才发续期提示,并按路由返回页面代理需要的响应。
const upstream = createServer(async (req, res) => { const upstream = createServer(async (req, res) => {
res.setHeader("Content-Type", "application/json"); res.setHeader("Content-Type", "application/json");
if (req.headers.authorization === "Bearer test-only-session" && !req.url.endsWith("/auth/logout")) res.setHeader("X-Foundry-Session-Expires-In", mockSessionSeconds); if (req.headers.authorization === "Bearer test-only-session" && !req.url.endsWith("/auth/logout")) res.setHeader("X-Foundry-Session-Expires-In", mockSessionSeconds);
// 把 workspace/board slug 归一到同一记录,供四种 ID/slug 组合读取校验;不是通用路由解析器。
const canonical = req.url.replace("/workspaces/creative-space/", "/workspaces/space/").replace("/materials/my-board", "/materials/foundry-generated-id"); const canonical = req.url.replace("/workspaces/creative-space/", "/workspaces/space/").replace("/materials/my-board", "/materials/foundry-generated-id");
if (req.url.includes("/canvas")) assert.equal(req.headers.accept, "application/vnd.foundry.canvas+json;version=2"); if (req.url.includes("/canvas")) assert.equal(req.headers.accept, "application/vnd.foundry.canvas+json;version=2");
// 资源 GET 返回短 PNG 头字节或不支持 MIME;POST 汇总 multipart 原始字节并回显幂等键。
if (req.url.startsWith("/projects/endoleap/workspaces/space/assets")) { if (req.url.startsWith("/projects/endoleap/workspaces/space/assets")) {
if (req.headers.authorization !== "Bearer test-only-session") { res.statusCode = 401; res.end(JSON.stringify({error:"invalid_session"})); return; } if (req.headers.authorization !== "Bearer test-only-session") { res.statusCode = 401; res.end(JSON.stringify({error:"invalid_session"})); return; }
if (req.method === "GET") { res.setHeader("X-Asset-Variant", new URL(req.url,"http://mock").searchParams.get("variant") === "preview" ? "preview" : "original"); res.setHeader("Content-Type", req.url.endsWith("/bad") ? "image/svg+xml" : "image/png"); res.end(Buffer.from([137,80,78,71,13,10,26,10])); return; } if (req.method === "GET") { res.setHeader("X-Asset-Variant", new URL(req.url,"http://mock").searchParams.get("variant") === "preview" ? "preview" : "original"); res.setHeader("Content-Type", req.url.endsWith("/bad") ? "image/svg+xml" : "image/png"); res.end(Buffer.from([137,80,78,71,13,10,26,10])); return; }
let bytes = Buffer.alloc(0); for await (const chunk of req) bytes = Buffer.concat([bytes, chunk]); let bytes = Buffer.alloc(0); for await (const chunk of req) bytes = Buffer.concat([bytes, chunk]);
res.end(JSON.stringify({type:req.headers["content-type"],bytes:bytes.length,key:req.headers["idempotency-key"],multipart:bytes.includes(Buffer.from('name="image"'))})); return; res.end(JSON.stringify({type:req.headers["content-type"],bytes:bytes.length,key:req.headers["idempotency-key"],multipart:bytes.includes(Buffer.from('name="image"'))})); return;
} }
// 先写 JSON 前缀,再延迟 15.2 秒完成 body,用于检查旧总超时不截断合法传输。
if (canonical.endsWith("/materials/slow-body-board/canvas")) { if (canonical.endsWith("/materials/slow-body-board/canvas")) {
res.setHeader("ETag", '\"210\"'); res.setHeader("ETag", '\"210\"');
res.write('{"version":210,"document":'); res.write('{"version":210,"document":');
await new Promise((resolve) => setTimeout(resolve, 15200)); await new Promise((resolve) => setTimeout(resolve, 15200));
res.end('{"schema_version":2,"nodes":[],"edges":[],"viewport":{"x":0,"y":0,"zoom":1}}}'); return; res.end('{"schema_version":2,"nodes":[],"edges":[],"viewport":{"x":0,"y":0,"zoom":1}}}'); return;
} }
// 由 releaseStream 控制尾部放行:代理必须在完整 JSON 可用之前转交头和首块。
if (canonical.endsWith("/materials/stream-board/canvas")) { if (canonical.endsWith("/materials/stream-board/canvas")) {
assert.equal(req.headers.authorization, "Bearer test-only-session"); assert.equal(req.headers.authorization, "Bearer test-only-session");
res.setHeader("ETag", '\"1\"'); res.setHeader("X-Canvas-Schema-Version", "2"); res.setHeader("ETag", '\"1\"'); res.setHeader("X-Canvas-Schema-Version", "2");
@@ -37,9 +49,11 @@ const upstream = createServer(async (req, res) => {
await new Promise((resolve) => { releaseStream = resolve; }); await new Promise((resolve) => { releaseStream = resolve; });
res.end('{"schema_version":2,"nodes":[],"edges":[],"viewport":{"x":0,"y":0,"zoom":1}}}'); return; res.end('{"schema_version":2,"nodes":[],"edges":[],"viewport":{"x":0,"y":0,"zoom":1}}}'); return;
} }
// V2 子命令只回显 URL、方法与关键头;这个分支不执行真正的连接、AI 或结构事务。
if (canonical.includes("/canvas/")) { if (canonical.includes("/canvas/")) {
res.end(JSON.stringify({url:req.url,method:req.method,accept:req.headers.accept,key:req.headers["idempotency-key"],version:req.headers["if-match"]})); return; res.end(JSON.stringify({url:req.url,method:req.method,accept:req.headers.accept,key:req.headers["idempotency-key"],version:req.headers["if-match"]})); return;
} }
// 旧完整快照 GET/PATCH 独立维护版本,If-Match 过期时返回 412;不与图谱元信息共用版本。
if (canonical === "/projects/endoleap/workspaces/space/materials/foundry-generated-id/canvas") { if (canonical === "/projects/endoleap/workspaces/space/materials/foundry-generated-id/canvas") {
if (req.headers.authorization !== "Bearer test-only-session") { res.statusCode = 401; res.end(JSON.stringify({ error: "invalid_session" })); return; } if (req.headers.authorization !== "Bearer test-only-session") { res.statusCode = 401; res.end(JSON.stringify({ error: "invalid_session" })); return; }
if (req.method === "GET") { res.setHeader("X-Canvas-Schema-Version", String(canvasSnapshot.document.schema_version)); res.setHeader("ETag", '"' + canvasSnapshot.version + '"'); res.end(JSON.stringify(canvasSnapshot)); return; } if (req.method === "GET") { res.setHeader("X-Canvas-Schema-Version", String(canvasSnapshot.document.schema_version)); res.setHeader("ETag", '"' + canvasSnapshot.version + '"'); res.end(JSON.stringify(canvasSnapshot)); return; }
@@ -50,6 +64,7 @@ const upstream = createServer(async (req, res) => {
canvasSnapshot = { version: canvasSnapshot.version + 1, document }; canvasSnapshot = { version: canvasSnapshot.version + 1, document };
res.setHeader("ETag", '"' + canvasSnapshot.version + '"'); res.end(JSON.stringify({ version: canvasSnapshot.version })); return; res.setHeader("ETag", '"' + canvasSnapshot.version + '"'); res.end(JSON.stringify({ version: canvasSnapshot.version })); return;
} }
// 其余分支提供登出、图谱创建/重命名、登录 token 和 profile;未知路由返回 404。
if (req.url.endsWith("/auth/logout")) { if (req.url.endsWith("/auth/logout")) {
res.statusCode = 204; res.statusCode = 204;
res.end(); res.end();
@@ -59,6 +74,7 @@ const upstream = createServer(async (req, res) => {
for await (const chunk of req) raw += chunk; for await (const chunk of req) raw += chunk;
const input = JSON.parse(raw); const input = JSON.parse(raw);
assert.deepEqual(input, { kind: "board", title: "未命名图谱", content: { schema_version: 1 } }); assert.deepEqual(input, { kind: "board", title: "未命名图谱", content: { schema_version: 1 } });
// 图谱 ID 由替身分配,同时保留 slug;创建响应只回身份字段,详细内容需后续 GET。
const graph = { ...input, slug: "my-board", id: "foundry-generated-id", workspace_id: "space", version: 1 }; const graph = { ...input, slug: "my-board", id: "foundry-generated-id", workspace_id: "space", version: 1 };
graphs.set(graph.id, graph); graphs.set(graph.id, graph);
res.statusCode = 201; res.statusCode = 201;
@@ -100,8 +116,10 @@ const upstream = createServer(async (req, res) => {
res.end("{}"); res.end("{}");
} }
}); });
// 先监听随机端口再启动 Next 子进程,保证应用首次代理请求就能连到替身。
await new Promise((r) => upstream.listen(0, "127.0.0.1", r)); await new Promise((r) => upstream.listen(0, "127.0.0.1", r));
const port = upstream.address().port; const port = upstream.address().port;
// 启动 next start 消费已有构建,捕获 stdout/stderr 供启动失败诊断,不运行开发服务器。
const app = spawn( const app = spawn(
process.execPath, process.execPath,
[ [
@@ -118,10 +136,12 @@ const app = spawn(
stdio: ["ignore", "pipe", "pipe"], stdio: ["ignore", "pipe", "pipe"],
}, },
); );
// 收集子进程启动输出;轮询中若提前退出,错误包含这些诊断文本。
let startup = ""; let startup = "";
app.stdout.on("data", (x) => (startup += x)); app.stdout.on("data", (x) => (startup += x));
app.stderr.on("data", (x) => (startup += x)); app.stderr.on("data", (x) => (startup += x));
try { try {
// 最多轮询 100 次,失败后等待 100ms,再检查首页是否可用;不能只凭 spawn 返回就宣称服务已启动。
let ready = false; let ready = false;
for (let n = 0; n < 100; n++) { for (let n = 0; n < 100; n++) {
if (app.exitCode !== null) if (app.exitCode !== null)
@@ -135,6 +155,7 @@ try {
await new Promise((r) => setTimeout(r, 100)); await new Promise((r) => setTimeout(r, 100));
} }
assert(ready, "Frontend did not start"); assert(ready, "Frontend did not start");
// 逐页检查状态和公共元信息;这些是服务端 HTML 断言,不代表页面客户端交互已验证。
for (const path of ["/", "/login", "/auth/callback", "/dashboard", "/dashboard?demo=1", "/dashboard/space/foundry-generated-id"]) { for (const path of ["/", "/login", "/auth/callback", "/dashboard", "/dashboard?demo=1", "/dashboard/space/foundry-generated-id"]) {
const page = await fetch(host + path); const page = await fetch(host + path);
assert.equal(page.status, 200, path); assert.equal(page.status, 200, path);
@@ -146,6 +167,7 @@ try {
assert(html.includes('/brand/icons/favicon.ico')); assert(html.includes('/brand/icons/favicon.ico'));
assert(html.includes('/brand/icons/apple-touch-icon.png')); assert(html.includes('/brand/icons/apple-touch-icon.png'));
} }
// 逐个下载本地品牌资产并与仓库原字节比较,检查静态服务没有替换或遗漏文件。
const brandRoot = new URL("../public/brand/", import.meta.url); const brandRoot = new URL("../public/brand/", import.meta.url);
const assets = (await readdir(brandRoot, { recursive: true })).filter((file) => /\.(png|ico|svg)$/.test(file)); const assets = (await readdir(brandRoot, { recursive: true })).filter((file) => /\.(png|ico|svg)$/.test(file));
assert.equal(assets.length, 88); assert.equal(assets.length, 88);
@@ -154,6 +176,7 @@ try {
assert.equal(response.status, 200, asset); assert.equal(response.status, 200, asset);
assert.deepEqual(Buffer.from(await response.arrayBuffer()), await readFile(new URL(asset, brandRoot)), asset); assert.deepEqual(Buffer.from(await response.arrayBuffer()), await readFile(new URL(asset, brandRoot)), asset);
} }
// 按 manifest 枚举图标分片,核对 immutable 缓存头和 JSON 内容,与目录数量的日志文案区分。
const iconManifest = JSON.parse(await readFile(new URL("../src/lib/canvas-icon-assets.json", import.meta.url), "utf8")); const iconManifest = JSON.parse(await readFile(new URL("../src/lib/canvas-icon-assets.json", import.meta.url), "utf8"));
for (const file of Object.values(iconManifest.assets)) { for (const file of Object.values(iconManifest.assets)) {
const path = iconManifest.root + "/" + file; const path = iconManifest.root + "/" + file;
@@ -163,6 +186,7 @@ try {
assert.deepEqual(await response.json(), JSON.parse(await readFile(new URL("../public" + path, import.meta.url), "utf8"))); assert.deepEqual(await response.json(), JSON.parse(await readFile(new URL("../public" + path, import.meta.url), "utf8")));
} }
assert.equal((await fetch(host + "/missing-page")).status, 404); assert.equal((await fetch(host + "/missing-page")).status, 404);
// 登录代理必须去掉响应体 token,改设 HttpOnly/SameSite cookie,并报告上游计时。
const res = await fetch(host + "/api/foundry/auth/login", { const res = await fetch(host + "/api/foundry/auth/login", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json", Origin: host }, headers: { "Content-Type": "application/json", Origin: host },
@@ -190,6 +214,7 @@ try {
while (true) { const next = await reader.read(); if (next.done) break; raw += new TextDecoder().decode(next.value); } while (true) { const next = await reader.read(); if (next.done) break; raw += new TextDecoder().decode(next.value); }
assert.equal(JSON.parse(raw).document.schema_version, 2); assert.equal(JSON.parse(raw).document.schema_version, 2);
} finally { clearTimeout(streamTimer); releaseStream(); } } finally { clearTimeout(streamTimer); releaseStream(); }
// 子命令循环只检查头透传;随后检查资源 variant、目录分页与 AI 查询参数白名单。
const v2Base = host + "/api/foundry/workspaces/space/materials/foundry-generated-id/canvas"; const v2Base = host + "/api/foundry/workspaces/space/materials/foundry-generated-id/canvas";
const idem = "11111111-1111-4111-8111-111111111111"; const idem = "11111111-1111-4111-8111-111111111111";
for (const path of ["connections", "subgraphs", "subgraphs/migrate", "subgraphs/frame-1/members", "subgraphs/frame-1/dissolve", "transactions/tx-1/undo", "ai/runs", "ai/runs/run-1/cancel", "ai/runs/run-1/apply"]) { for (const path of ["connections", "subgraphs", "subgraphs/migrate", "subgraphs/frame-1/members", "subgraphs/frame-1/dissolve", "transactions/tx-1/undo", "ai/runs", "ai/runs/run-1/cancel", "ai/runs/run-1/apply"]) {
@@ -202,6 +227,7 @@ try {
assert.equal(directoryQuery.get("include_subgraphs"), "true"); assert.equal(directoryQuery.get("offset"), "100"); assert.equal(directoryQuery.get("include_subgraphs"), "true"); assert.equal(directoryQuery.get("offset"), "100");
const events = await (await fetch(v2Base + "/ai/runs/run-1?after_seq=12&limit=100&status=active&include_descendants=true&cursor=cursor&secret=drop", { headers: { Cookie: session } })).json(); const events = await (await fetch(v2Base + "/ai/runs/run-1?after_seq=12&limit=100&status=active&include_descendants=true&cursor=cursor&secret=drop", { headers: { Cookie: session } })).json();
const query = new URL(events.url, "http://mock").searchParams; assert.equal(query.get("after_seq"), "12"); assert.equal(query.get("include_descendants"), "true"); assert.equal(query.get("cursor"), "cursor"); assert.equal(query.get("secret"), null); const query = new URL(events.url, "http://mock").searchParams; assert.equal(query.get("after_seq"), "12"); assert.equal(query.get("include_descendants"), "true"); assert.equal(query.get("cursor"), "cursor"); assert.equal(query.get("secret"), null);
// 验证慢 body 完整读取和 trace 标识,再检查资源认证、MIME 拒绝、nosniff 与 multipart 转发。
const slowSnapshot = await fetch(host + "/api/foundry/workspaces/space/materials/slow-body-board/canvas", { headers: { Cookie: session } }); const slowSnapshot = await fetch(host + "/api/foundry/workspaces/space/materials/slow-body-board/canvas", { headers: { Cookie: session } });
assert.ok(slowSnapshot.headers.get("x-canvas-trace-id")); assert.equal((await slowSnapshot.json()).version, 210, "A valid body can finish after the old 15-second total deadline"); assert.ok(slowSnapshot.headers.get("x-canvas-trace-id")); assert.equal((await slowSnapshot.json()).version, 210, "A valid body can finish after the old 15-second total deadline");
const asset = await fetch(host + "/api/foundry/workspaces/space/assets/image-1", { headers: { Cookie: session } }); const asset = await fetch(host + "/api/foundry/workspaces/space/assets/image-1", { headers: { Cookie: session } });
@@ -211,6 +237,7 @@ try {
const form = new FormData(); form.set("image", new Blob([new Uint8Array([137,80,78,71])], { type: "image/png" }), "test.png"); const form = new FormData(); form.set("image", new Blob([new Uint8Array([137,80,78,71])], { type: "image/png" }), "test.png");
const upload = await (await fetch(host + "/api/foundry/workspaces/space/assets", { method: "POST", headers: { Cookie: session, Origin: host, "Idempotency-Key": idem }, body: form })).json(); const upload = await (await fetch(host + "/api/foundry/workspaces/space/assets", { method: "POST", headers: { Cookie: session, Origin: host, "Idempotency-Key": idem }, body: form })).json();
assert.match(upload.type, /^multipart\/form-data; boundary=/); assert.equal(upload.key, idem); assert.ok(upload.multipart); assert.match(upload.type, /^multipart\/form-data; boundary=/); assert.equal(upload.key, idem); assert.ok(upload.multipart);
// 有效会话首页重定向且不缓存私有结果;dashboard 初始 HTML 不应包含画布或 landing。
const signedInHome = await fetch(host + "/", { headers: { Cookie: session }, redirect: "manual" }); const signedInHome = await fetch(host + "/", { headers: { Cookie: session }, redirect: "manual" });
assert.equal(signedInHome.status, 307); assert.equal(signedInHome.status, 307);
assert.equal(signedInHome.headers.get("location"), "/dashboard"); assert.equal(signedInHome.headers.get("location"), "/dashboard");
@@ -222,6 +249,7 @@ try {
assert.match(dashboardHtml, /aria-label="(?:工作空间侧边栏|正在加载侧边栏)"/); assert.match(dashboardHtml, /aria-label="(?:工作空间侧边栏|正在加载侧边栏)"/);
assert(!dashboardHtml.includes('data-testid="rf__wrapper"')); assert(!dashboardHtml.includes('data-testid="rf__wrapper"'));
assert(!dashboardHtml.includes('class="landing"')); assert(!dashboardHtml.includes('class="landing"'));
// 无会话或过期会话首页保持 landing,避免错误重定向到个人工作区。
for (const guestHeaders of [{}, { Cookie: "foundry_session=expired-session" }]) { for (const guestHeaders of [{}, { Cookie: "foundry_session=expired-session" }]) {
const guestHome = await fetch(host + "/", { headers: guestHeaders, redirect: "manual" }); const guestHome = await fetch(host + "/", { headers: guestHeaders, redirect: "manual" });
assert.equal(guestHome.status, 200); assert.equal(guestHome.status, 200);
@@ -240,6 +268,7 @@ try {
200, 200,
); );
assert.equal((await fetch(host + "/api/foundry/profile")).status, 401); assert.equal((await fetch(host + "/api/foundry/profile")).status, 401);
// 续期只接受有效剩余秒数;非法上游值和无效会话不应产生新的 Set-Cookie。
mockSessionSeconds = "3600"; mockSessionSeconds = "3600";
const renewed = await fetch(host + "/api/foundry/profile", { headers: { Cookie: session } }); const renewed = await fetch(host + "/api/foundry/profile", { headers: { Cookie: session } });
assert.equal(renewed.status, 200); assert.match(renewed.headers.get("set-cookie") || "", /Max-Age=3600/); assert.equal(renewed.status, 200); assert.match(renewed.headers.get("set-cookie") || "", /Max-Age=3600/);
@@ -251,6 +280,7 @@ try {
const expiredSession = await fetch(host + "/api/foundry/profile", { headers: { Cookie: "foundry_session=expired" } }); const expiredSession = await fetch(host + "/api/foundry/profile", { headers: { Cookie: "foundry_session=expired" } });
assert.equal(expiredSession.status, 401); assert.equal(expiredSession.headers.get("set-cookie"), null); assert.equal(expiredSession.status, 401); assert.equal(expiredSession.headers.get("set-cookie"), null);
// 读取头像再修改公开资料,确认未涉及的头像字段保持。
const profile = await (await fetch(host + "/api/foundry/profile", { headers: { Cookie: session } })).json(); const profile = await (await fetch(host + "/api/foundry/profile", { headers: { Cookie: session } })).json();
assert.equal(profile.avatar_url, "https://lh3.googleusercontent.com/test-avatar=s96-c"); assert.equal(profile.avatar_url, "https://lh3.googleusercontent.com/test-avatar=s96-c");
const updated = await fetch(host + "/api/foundry/profile", { const updated = await fetch(host + "/api/foundry/profile", {
@@ -263,6 +293,7 @@ try {
assert.equal(updatedProfile.display_name, "Updated name"); assert.equal(updatedProfile.display_name, "Updated name");
assert.equal(updatedProfile.avatar_url, profile.avatar_url); assert.equal(updatedProfile.avatar_url, profile.avatar_url);
// 经真实代理创建、读取和重命名内存图谱,检查服务分配身份、内容保留和元信息版本递增。
const create = await fetch(host + "/api/foundry/workspaces/space/materials", { const create = await fetch(host + "/api/foundry/workspaces/space/materials", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json", Cookie: session, Origin: host }, headers: { "Content-Type": "application/json", Cookie: session, Origin: host },
@@ -287,6 +318,7 @@ try {
assert.equal(renamed.version, 2); assert.equal(renamed.version, 2);
assert.deepEqual(renamed.content, stored.content); assert.deepEqual(renamed.content, stored.content);
// workspace/board 各自使用 ID 或 slug 的四种组合都应定位同一图谱和独立画布版本。
for (const workspace of ["space", "creative-space"]) for (const board of ["foundry-generated-id", "my-board"]) { for (const workspace of ["space", "creative-space"]) for (const board of ["foundry-generated-id", "my-board"]) {
const path = `/api/foundry/workspaces/${workspace}/materials/${board}`; const path = `/api/foundry/workspaces/${workspace}/materials/${board}`;
const material = await (await fetch(host + path, { headers: { Cookie: session } })).json(); const material = await (await fetch(host + path, { headers: { Cookie: session } })).json();
@@ -296,6 +328,7 @@ try {
assert.equal((await snapshot.json()).version, 1); assert.equal((await snapshot.json()).version, 1);
} }
const canvasPath = "/api/foundry/workspaces/creative-space/materials/my-board/canvas"; const canvasPath = "/api/foundry/workspaces/creative-space/materials/my-board/canvas";
// 生成超过 1 MiB 的完整快照经代理保存/恢复,再用旧版本重发检查 412;不是增量或大文件性能测试。
const largeDocument = { schema_version: 1, nodes: Array.from({ length: 15 }, (_, index) => ({ id: "text-" + index, type: "material", position: { x: index * 300, y: 100 }, width: 260, height: 200, data: { kind: "text", title: "保存测试", text: "x".repeat(90000), fontSize: 24, rotation: 30, textStyle: { bold: true, color: "#123456" } } })), edges: [], viewport: { x: 20, y: 40, zoom: 0.75 } }; const largeDocument = { schema_version: 1, nodes: Array.from({ length: 15 }, (_, index) => ({ id: "text-" + index, type: "material", position: { x: index * 300, y: 100 }, width: 260, height: 200, data: { kind: "text", title: "保存测试", text: "x".repeat(90000), fontSize: 24, rotation: 30, textStyle: { bold: true, color: "#123456" } } })), edges: [], viewport: { x: 20, y: 40, zoom: 0.75 } };
const savedCanvas = await fetch(host + canvasPath, { method: "PATCH", headers: { Cookie: session, Origin: host, "Content-Type": "application/json", "If-Match": '"1"' }, body: JSON.stringify({ document: largeDocument }) }); const savedCanvas = await fetch(host + canvasPath, { method: "PATCH", headers: { Cookie: session, Origin: host, "Content-Type": "application/json", "If-Match": '"1"' }, body: JSON.stringify({ document: largeDocument }) });
assert.equal(savedCanvas.status, 200); assert.equal(savedCanvas.headers.get("etag"), '"2"'); assert.equal(savedCanvas.status, 200); assert.equal(savedCanvas.headers.get("etag"), '"2"');
@@ -304,6 +337,7 @@ try {
assert.deepEqual(restoredCanvas.document, largeDocument); assert.deepEqual(restoredCanvas.document, largeDocument);
const conflict = await fetch(host + canvasPath, { method: "PATCH", headers: { Cookie: session, Origin: host, "Content-Type": "application/json", "If-Match": '"1"' }, body: JSON.stringify({ document: largeDocument }) }); const conflict = await fetch(host + canvasPath, { method: "PATCH", headers: { Cookie: session, Origin: host, "Content-Type": "application/json", "If-Match": '"1"' }, body: JSON.stringify({ document: largeDocument }) });
assert.equal(conflict.status, 412); assert.equal((await conflict.json()).error, "version_conflict"); assert.equal(conflict.status, 412); assert.equal((await conflict.json()).error, "version_conflict");
// 旧 frameId 成员关系及三种名称显示模式往返,逐次使用当前画布版本。
const frameDocument = structuredClone(largeDocument); const frameDocument = structuredClone(largeDocument);
frameDocument.nodes.unshift({ id: "frame-1", type: "frame", position: { x: 0, y: 0 }, width: 640, height: 400, data: { kind: "frame", title: "参考分区", text: "", rotation: 0, labelMode: "fixed" } }); frameDocument.nodes.unshift({ id: "frame-1", type: "frame", position: { x: 0, y: 0 }, width: 640, height: 400, data: { kind: "frame", title: "参考分区", text: "", rotation: 0, labelMode: "fixed" } });
frameDocument.nodes[1].data.frameId = "frame-1"; frameDocument.nodes[1].data.frameId = "frame-1";
@@ -317,10 +351,12 @@ try {
} }
assert.equal((await fetch(host + canvasPath)).status, 401); assert.equal((await fetch(host + canvasPath)).status, 401);
assert.equal((await fetch(host + "/api/foundry/workspaces/space/search/my-board/canvas")).status, 404); assert.equal((await fetch(host + "/api/foundry/workspaces/space/search/my-board/canvas")).status, 404);
// 超过画布 body 上限的请求必须返回 413,不能转成成功保存。
const oversizedCanvas = await fetch(host + canvasPath, { method: "PATCH", headers: { Cookie: session, "Content-Type": "application/json" }, body: JSON.stringify({ document: "x".repeat(5 * 1024 * 1024) }) }); const oversizedCanvas = await fetch(host + canvasPath, { method: "PATCH", headers: { Cookie: session, "Content-Type": "application/json" }, body: JSON.stringify({ document: "x".repeat(5 * 1024 * 1024) }) });
assert.equal(oversizedCanvas.status, 413); assert.equal(oversizedCanvas.status, 413);
// 检查跨源拒绝、路径白名单、OAuth challenge、Secure cookie、非法 JSON、大小上限及方法过滤。
for (const origin of ["https://example.com", "null"]) for (const origin of ["https://example.com", "null"])
assert.equal( assert.equal(
( (
@@ -351,17 +387,20 @@ try {
assert.equal(invalid.status, status); assert.equal(invalid.status, status);
} }
assert.equal((await fetch(host + "/api/foundry/profile", { method: "PUT" })).status, 405); assert.equal((await fetch(host + "/api/foundry/profile", { method: "PUT" })).status, 405);
// 查询保留 kind/limit 而删除未许可 secret;此处使用替身回显观察最终上游路径。
const materials = await fetch(host + "/api/foundry/workspaces/space/materials?kind=board&limit=100&secret=drop", { const materials = await fetch(host + "/api/foundry/workspaces/space/materials?kind=board&limit=100&secret=drop", {
method: "PATCH", headers: { "Content-Type": "application/json" }, body: "{}", method: "PATCH", headers: { "Content-Type": "application/json" }, body: "{}",
}); });
assert.deepEqual(await materials.json(), { assert.deepEqual(await materials.json(), {
url: "/projects/endoleap/workspaces/space/materials?kind=board&limit=100", method: "PATCH", url: "/projects/endoleap/workspaces/space/materials?kind=board&limit=100", method: "PATCH",
}); });
// OAuth 只检查重定向地址与 challenge,不实际打开提供商或完成授权。
for (const provider of ["google", "slack"]) { for (const provider of ["google", "slack"]) {
const redirect = await fetch(host + "/api/oauth/" + provider + "?challenge=" + "a".repeat(43), { redirect: "manual" }); const redirect = await fetch(host + "/api/oauth/" + provider + "?challenge=" + "a".repeat(43), { redirect: "manual" });
assert.equal(redirect.status, 302); assert.equal(redirect.status, 302);
assert.equal(redirect.headers.get("location"), `http://127.0.0.1:${port}/projects/endoleap/auth/${provider}?challenge=${"a".repeat(43)}`); assert.equal(redirect.headers.get("location"), `http://127.0.0.1:${port}/projects/endoleap/auth/${provider}?challenge=${"a".repeat(43)}`);
} }
// 登出保持 204 空体并清除 cookie;下方 PASS 长文是摘要,实际覆盖以逐项断言为准。
const logout = await fetch(host + "/api/foundry/auth/logout", { const logout = await fetch(host + "/api/foundry/auth/logout", {
method: "POST", headers: { "Content-Type": "application/json", Cookie: session }, body: "{}", method: "POST", headers: { "Content-Type": "application/json", Cookie: session }, body: "{}",
}); });
@@ -371,6 +410,7 @@ try {
console.log( console.log(
"PASS: V2 commands, idempotency and query forwarding, authenticated binary assets and multipart uploads, Canvas GET/PATCH and independent versions, ETag/If-Match, 1+ MiB snapshot restore, conflict and 5 MiB limit, four ID/slug combinations, authenticated home redirect, anonymous and expired-session landing, canvas-free dashboard, 88 original brand assets, 97 hashed icon shards with immutable caching, favicon metadata, avatar and profile updates, App Router pages and metadata, body validation, method and query filtering, OAuth redirects, logout, session cookie, token removal, authenticated proxy, invalid session, cross-origin rejection, path allowlist, OAuth validation", "PASS: V2 commands, idempotency and query forwarding, authenticated binary assets and multipart uploads, Canvas GET/PATCH and independent versions, ETag/If-Match, 1+ MiB snapshot restore, conflict and 5 MiB limit, four ID/slug combinations, authenticated home redirect, anonymous and expired-session landing, canvas-free dashboard, 88 original brand assets, 97 hashed icon shards with immutable caching, favicon metadata, avatar and profile updates, App Router pages and metadata, body validation, method and query filtering, OAuth redirects, logout, session cookie, token removal, authenticated proxy, invalid session, cross-origin rejection, path allowlist, OAuth validation",
); );
// 结束或断言失败均终止 Next 子进程并关闭替身监听;此处没有额外的强制退出超时。
} finally { } finally {
const exited = once(app, "exit"); const exited = once(app, "exit");
app.kill("SIGTERM"); app.kill("SIGTERM");
+8
View File
@@ -1,7 +1,13 @@
/**
* board 目录分页、树构建、祖先定位与筛选测试。请求由内存函数提供,不访问实际 workspace 服务。
* 列表顺序不保证父项先到;孤儿和循环节点仍须可访问,不能递归死循环。
*/
import test from 'node:test'; import test from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { loadBoardDirectory, boardDirectory, activeBoardAncestors, filterBoardDirectory } from '../src/lib/board-directory.ts'; import { loadBoardDirectory, boardDirectory, activeBoardAncestors, filterBoardDirectory } from '../src/lib/board-directory.ts';
// 生成带 ID、slug 和 parent_board_id 的最小目录项,默认 title 使用 ID。
const board=(id,parent,title=id)=>({id,parent_board_id:parent,title,workspace_id:'w',kind:'board',version:1,slug:id+'-slug'}); const board=(id,parent,title=id)=>({id,parent_board_id:parent,title,workspace_id:'w',kind:'board',version:1,slug:id+'-slug'});
// 第一页故意子先父后,下一页才给孙项;检查 include_subgraphs、offset 与 URL 编码,再检查层级、slug 祖先链和筛选路径。
test('directory includes subgraphs, traverses all pages and preserves nested ownership',async()=>{ test('directory includes subgraphs, traverses all pages and preserves nested ownership',async()=>{
const calls=[];const request=async(path)=>{calls.push(path);const offset=new URLSearchParams(path.split('?')[1]).get('offset');return offset==='0'?{items:[board('child','parent'),board('parent')],next_offset:100}:{items:[board('grandchild','child')],next_offset:null};}; const calls=[];const request=async(path)=>{calls.push(path);const offset=new URLSearchParams(path.split('?')[1]).get('offset');return offset==='0'?{items:[board('child','parent'),board('parent')],next_offset:100}:{items:[board('grandchild','child')],next_offset:null};};
const boards=await loadBoardDirectory(request,'space slug');assert.equal(calls.length,2);assert.ok(calls.every(path=>path.includes('include_subgraphs=true')));assert.ok(calls[1].endsWith('offset=100'));assert.ok(calls[0].includes('space%20slug')); const boards=await loadBoardDirectory(request,'space slug');assert.equal(calls.length,2);assert.ok(calls.every(path=>path.includes('include_subgraphs=true')));assert.ok(calls[1].endsWith('offset=100'));assert.ok(calls[0].includes('space%20slug'));
@@ -10,9 +16,11 @@ test('directory includes subgraphs, traverses all pages and preserves nested own
assert.deepEqual(filterBoardDirectory(roots,'grandchild')[0].children[0].children.map(item=>item.board.id),['grandchild']); assert.deepEqual(filterBoardDirectory(roots,'grandchild')[0].children[0].children.map(item=>item.board.id),['grandchild']);
assert.equal(filterBoardDirectory(roots,'parent')[0].children.length,1); assert.equal(filterBoardDirectory(roots,'parent')[0].children.length,1);
}); });
// 父项缺失及双节点循环都提升为可见根项,子列表为空,避免丢失入口或无限嵌套。
test('missing parents and corrupt cycles remain accessible without recursive loops',()=>{ test('missing parents and corrupt cycles remain accessible without recursive loops',()=>{
const roots=boardDirectory([board('orphan','missing'),board('a','b'),board('b','a')]);assert.deepEqual(roots.map(item=>item.board.id),['orphan','a','b']);assert.ok(roots.every(item=>item.children.length===0)); const roots=boardDirectory([board('orphan','missing'),board('a','b'),board('b','a')]);assert.deepEqual(roots.map(item=>item.board.id),['orphan','a','b']);assert.ok(roots.every(item=>item.children.length===0));
}); });
// 非递增 next_offset 明确拒绝;已经 abort 的信号由请求替身抛回,加载器不得吞掉取消。
test('directory rejects non-advancing pagination and propagates aborts',async()=>{ test('directory rejects non-advancing pagination and propagates aborts',async()=>{
await assert.rejects(loadBoardDirectory(async()=>({items:[],next_offset:0}),'w'),/invalid_pagination/); await assert.rejects(loadBoardDirectory(async()=>({items:[],next_offset:0}),'w'),/invalid_pagination/);
const controller=new AbortController();controller.abort();await assert.rejects(loadBoardDirectory(async(_path,options)=>{options.signal.throwIfAborted();},'w',controller.signal),{name:'AbortError'}); const controller=new AbortController();controller.abort();await assert.rejects(loadBoardDirectory(async(_path,options)=>{options.signal.throwIfAborted();},'w',controller.signal),{name:'AbortError'});
+10
View File
@@ -1,10 +1,17 @@
/**
* 旧画布附件上传助手和显示 URL 转换测试。请求为替身,检查 FormData、错误和资源地址,不是真实上传或图像解码。
* 这里的上传重试属于 canvas-assets helper,不可等同于 headless ledger 的持久冻结请求恢复。
*/
import test from 'node:test'; import test from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { uploadCanvasImage } from '../src/lib/canvas-assets.ts'; import { uploadCanvasImage } from '../src/lib/canvas-assets.ts';
import { managedImageSource } from '../src/lib/canvas-v2.ts'; import { managedImageSource } from '../src/lib/canvas-v2.ts';
// 固定 data URL 仅用来构造与对比二进制前缀,并非完整 PNG 解码样本。
const source='data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAAB'; const source='data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAAB';
// 固定资产身份;返回值必须是项目管理的地址,任意外域响应不被当作受管上传结果。
const id='77777777-7777-4777-8777-777777777777'; const id='77777777-7777-4777-8777-777777777777';
const asset={id,url:`https://api.foundry.entroia.com/projects/endoleap/workspaces/w/assets/${id}`,width:1,height:1,bytes:24,mime_type:'image/png'}; const asset={id,url:`https://api.foundry.entroia.com/projects/endoleap/workspaces/w/assets/${id}`,width:1,height:1,bytes:24,mime_type:'image/png'};
// 首例直接比对 FormData.image 原字节、上传路径、POST 和幂等键形状,返回值保留服务资产元数据。
test('attachment upload sends the original file bytes',async()=>{ test('attachment upload sends the original file bytes',async()=>{
const bytes=Buffer.from(source.split(',')[1],'base64');let calls=0; const bytes=Buffer.from(source.split(',')[1],'base64');let calls=0;
const result=await uploadCanvasImage(async(path,options)=>{ const result=await uploadCanvasImage(async(path,options)=>{
@@ -13,15 +20,18 @@ test('attachment upload sends the original file bytes',async()=>{
},'w',new Blob([bytes],{type:'image/png'}),'image.png'); },'w',new Blob([bytes],{type:'image/png'}),'image.png');
assert.equal(calls,1);assert.deepEqual(result,asset); assert.equal(calls,1);assert.deepEqual(result,asset);
}); });
// 权限拒绝直接传播;格式类似但地址不受管理的成功响应也要拒绝,不能把任意 URL 写成上传结果。
test('attachment upload propagates permission failures and rejects unmanaged response URLs',async()=>{ test('attachment upload propagates permission failures and rejects unmanaged response URLs',async()=>{
const file=new Blob(['test'],{type:'image/png'}); const file=new Blob(['test'],{type:'image/png'});
await assert.rejects(uploadCanvasImage(async()=>{throw new Error('forbidden');},'w',file,'test.png'),/forbidden/); await assert.rejects(uploadCanvasImage(async()=>{throw new Error('forbidden');},'w',file,'test.png'),/forbidden/);
await assert.rejects(uploadCanvasImage(async()=>({...asset,url:'https://invalid.example/a.png'}),'w',file,'test.png'),/invalid_asset_response/); await assert.rejects(uploadCanvasImage(async()=>({...asset,url:'https://invalid.example/a.png'}),'w',file,'test.png'),/invalid_asset_response/);
}); });
// 模拟第一次连接丢失、第二次成功;此例直接断言两次调用与相同幂等键,文件字节传递由首例单独检查。
test('uncertain upload retries the exact bytes with the same idempotency key',async()=>{ test('uncertain upload retries the exact bytes with the same idempotency key',async()=>{
let calls=0,key; let calls=0,key;
await uploadCanvasImage(async(_path,options)=>{calls++;if(!key)key=options.headers['Idempotency-Key'];assert.equal(options.headers['Idempotency-Key'],key);if(calls===1)throw new TypeError('connection lost');return asset;},'w',new Blob(['test'],{type:'image/png'}),'test.png');assert.equal(calls,2); await uploadCanvasImage(async(_path,options)=>{calls++;if(!key)key=options.headers['Idempotency-Key'];assert.equal(options.headers['Idempotency-Key'],key);if(calls===1)throw new TypeError('connection lost');return asset;},'w',new Blob(['test'],{type:'image/png'}),'test.png');assert.equal(calls,2);
}); });
// 受管原始 URL 转同源认证代理,preview 仅加显示变体参数;普通外部图片地址保持原样。
test('preview URLs are renderer-only and do not replace original managed references',()=>{ test('preview URLs are renderer-only and do not replace original managed references',()=>{
assert.equal(managedImageSource(asset.url),`/api/foundry/workspaces/w/assets/${id}`);assert.equal(managedImageSource(asset.url,true),`/api/foundry/workspaces/w/assets/${id}?variant=preview`); assert.equal(managedImageSource(asset.url),`/api/foundry/workspaces/w/assets/${id}`);assert.equal(managedImageSource(asset.url,true),`/api/foundry/workspaces/w/assets/${id}?variant=preview`);
assert.equal(managedImageSource('https://example.com/photo.png',true),'https://example.com/photo.png'); assert.equal(managedImageSource('https://example.com/photo.png',true),'https://example.com/photo.png');
+14
View File
@@ -1,16 +1,23 @@
/**
* 旧 frameId 分区成员模型的拖入、随动与恢复测试;不是 headless ownerCanvasId 树或 linked-board 深子 canvas 的全部规则。
* 成员关系由明确 drop 决定,几何重叠本身不代表已经加入分区。
*/
import { test } from "node:test"; import { test } from "node:test";
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { canvasReducer, decodeCanvas, emptyCanvas, makeCanvasNode } from "../src/lib/canvas.ts"; import { canvasReducer, decodeCanvas, emptyCanvas, makeCanvasNode } from "../src/lib/canvas.ts";
import { assignFrameMembership, frameDropFeedback } from "../src/lib/canvas-frames.ts"; import { assignFrameMembership, frameDropFeedback } from "../src/lib/canvas-frames.ts";
// frame、位于内部但尚未登记的 member,以及外部 icon;可分别检查 drop 准入和已有成员随动。
function seeded() { function seeded() {
const frame = makeCanvasNode("frame", { x: 100, y: 100 }); const frame = makeCanvasNode("frame", { x: 100, y: 100 });
const member = makeCanvasNode("text", { x: 160, y: 160 }); const member = makeCanvasNode("text", { x: 160, y: 160 });
const outsider = makeCanvasNode("icon", { x: 900, y: 120 }); const outsider = makeCanvasNode("icon", { x: 900, y: 120 });
return { frame, member, outsider, document: { ...emptyCanvas(), nodes: [frame, member, outsider] } }; return { frame, member, outsider, document: { ...emptyCanvas(), nodes: [frame, member, outsider] } };
} }
// 把给定快照包成空历史结构,保持所有测试通过相同 reducer 入口。
function state(document) { return { past: [], present: document, future: [] }; } function state(document) { return { past: [], present: document, future: [] }; }
// 多种内容 drop 到重叠 frame 时预览应与最终成员选择一致;较小 frame 优先,移到外部则同时显示和执行 detach。
test("drop previews match actual assignment for every content type, exits and overlapping frames", () => { test("drop previews match actual assignment for every content type, exits and overlapping frames", () => {
const { frame } = seeded(); const { frame } = seeded();
const smaller = makeCanvasNode("frame", { x: 120, y: 120 }); smaller.width = 300; smaller.height = 240; const smaller = makeCanvasNode("frame", { x: 120, y: 120 }); smaller.width = 300; smaller.height = 240;
@@ -26,6 +33,7 @@ test("drop previews match actual assignment for every content type, exits and ov
} }
}); });
// 只给 dropped IDs 分配成员,拖 frame 或其他节点不能顺手收集原本重叠的内容。
test("only dropped content joins a frame; pre-existing overlapping content stays outside", () => { test("only dropped content joins a frame; pre-existing overlapping content stays outside", () => {
const { frame, member, outsider, document } = seeded(); const { frame, member, outsider, document } = seeded();
assert.equal(document.nodes[1].data.frameId, undefined); assert.equal(document.nodes[1].data.frameId, undefined);
@@ -38,6 +46,7 @@ test("only dropped content joins a frame; pre-existing overlapping content stays
assert.equal(assignFrameMembership([frame, member, outsider], [member.id])[2].data.frameId, undefined); assert.equal(assignFrameMembership([frame, member, outsider], [member.id])[2].data.frameId, undefined);
}); });
// 已有成员随 frame 同量移动,旁边 outsider 不改位置和归属;undo 恢复成员坐标与 frameId。
test("frames carry existing members without collecting newly overlapped content and undo restores positions", () => { test("frames carry existing members without collecting newly overlapped content and undo restores positions", () => {
const { frame, member, outsider, document } = seeded(); const { frame, member, outsider, document } = seeded();
member.data.frameId = frame.id; member.data.frameId = frame.id;
@@ -51,6 +60,7 @@ test("frames carry existing members without collecting newly overlapped content
assert.equal(history.present.nodes[1].data.frameId, frame.id); assert.equal(history.present.nodes[1].data.frameId, frame.id);
}); });
// 已有成员拖出清除 frameId,落进另一 frame 则换归属;以同一位置比较两种节点集合。
test("dragging out detaches content and dragging between frames reassigns membership", () => { test("dragging out detaches content and dragging between frames reassigns membership", () => {
const { frame, member } = seeded(); const { frame, member } = seeded();
member.data.frameId = frame.id; member.data.frameId = frame.id;
@@ -60,6 +70,7 @@ test("dragging out detaches content and dragging between frames reassigns member
assert.equal(assignFrameMembership([frame, second, member], [member.id])[2].data.frameId, second.id); assert.equal(assignFrameMembership([frame, second, member], [member.id])[2].data.frameId, second.id);
}); });
// 同时上报父 frame 和成员的最终位置时,不能再把父位移叠加到成员,避免双重移动。
test("multi-selection does not move frame members twice", () => { test("multi-selection does not move frame members twice", () => {
const { frame, member, document } = seeded(); const { frame, member, document } = seeded();
member.data.frameId = frame.id; member.data.frameId = frame.id;
@@ -70,6 +81,7 @@ test("multi-selection does not move frame members twice", () => {
assert.deepEqual(result.present.nodes[1].position, { x: 260, y: 260 }); assert.deepEqual(result.present.nodes[1].position, { x: 260, y: 260 });
}); });
// 旧模型删除 frame 保留内容并清成员关联,undo 可恢复原 frameId;这不是 headless 删除 owning 子树的产品规则。
test("deleting a frame keeps its content and undo restores membership", () => { test("deleting a frame keeps its content and undo restores membership", () => {
const { frame, member, document } = seeded(); const { frame, member, document } = seeded();
member.data.frameId = frame.id; member.data.frameId = frame.id;
@@ -80,6 +92,7 @@ test("deleting a frame keeps its content and undo restores membership", () => {
assert.equal(canvasReducer(removed, { type: "undo" }).present.nodes[1].data.frameId, frame.id); assert.equal(canvasReducer(removed, { type: "undo" }).present.nodes[1].data.frameId, frame.id);
}); });
// hidden/fixed/scaled 标签、名称与 frameId 往返保存,frame 渲染层序为 -1,设置历史可撤销。
test("all label modes, frame names and memberships survive save/restore and settings undo", () => { test("all label modes, frame names and memberships survive save/restore and settings undo", () => {
const { frame, member, document } = seeded(); const { frame, member, document } = seeded();
member.data.frameId = frame.id; member.data.frameId = frame.id;
@@ -95,6 +108,7 @@ test("all label modes, frame names and memberships survive save/restore and sett
} }
}); });
// 缺失 frame、frame 作为别的内容成员、非法标签和 frame 旋转均拒绝;限制属于本旧格式。
test("invalid frame references, label modes, frame nesting and rotation are rejected", () => { test("invalid frame references, label modes, frame nesting and rotation are rejected", () => {
for (const mutate of [ for (const mutate of [
({ member }) => { member.data.frameId = "missing"; }, ({ member }) => { member.data.frameId = "missing"; },
+15
View File
@@ -1,12 +1,19 @@
/**
* 旧 CanvasElement 几何与能力测试,直接验证类、纯函数和旧 reducer;不分派真实 pointer 事件。
* 多方向/旋转样本用对边锚点及连接端点作为几何不变量,避免只比较宽高变化。
*/
import { test } from "node:test"; import { test } from "node:test";
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { CanvasElement, CardElement, TextCardElement, ImageCardElement, ScriptCardElement, PlainTextElement, elementCapabilities } from "../src/lib/canvas-elements.ts"; import { CanvasElement, CardElement, TextCardElement, ImageCardElement, ScriptCardElement, PlainTextElement, elementCapabilities } from "../src/lib/canvas-elements.ts";
import { nodeConnector, connectorBezier, nodeCenter, nodeSize, resizeFromPointer, rotateVector, rotationFromPointer } from "../src/lib/canvas-geometry.ts"; import { nodeConnector, connectorBezier, nodeCenter, nodeSize, resizeFromPointer, rotateVector, rotationFromPointer } from "../src/lib/canvas-geometry.ts";
import { branchCanvasNode, canvasReducer, decodeCanvas, emptyCanvas } from "../src/lib/canvas.ts"; import { branchCanvasNode, canvasReducer, decodeCanvas, emptyCanvas } from "../src/lib/canvas.ts";
// 浮点误差容差为 1e-5,失败消息保留 actual/expected,供旋转与坐标换算诊断。
function close(actual, expected) { assert(Math.abs(actual - expected) < 0.00001, `${actual} != ${expected}`); } function close(actual, expected) { assert(Math.abs(actual - expected) < 0.00001, `${actual} != ${expected}`); }
// 将几何 patch 与旧 node.data 中的 rotation/fontSize 合并,重算中心和端点时使用同一模型形状。
function transformed(node, patch) { return { ...node, ...patch, data: { ...node.data, rotation: patch.rotation ?? node.data.rotation, fontSize: patch.fontSize ?? node.data.fontSize } }; } function transformed(node, patch) { return { ...node, ...patch, data: { ...node.data, rotation: patch.rotation ?? node.data.rotation, fontSize: patch.fontSize ?? node.data.fontSize } }; }
// 三种卡片共享变换和 branching;PlainText 只继承公共变换,不能通过错误 ID 或自身 ID 绕过不连线/不分支能力。
test("card types inherit common element transforms and add branching; plain text inherits only common transforms", () => { test("card types inherit common element transforms and add branching; plain text inherits only common transforms", () => {
for (const Type of [TextCardElement, ImageCardElement, ScriptCardElement]) { for (const Type of [TextCardElement, ImageCardElement, ScriptCardElement]) {
const element = new Type({ x: 0, y: 0 }); const element = new Type({ x: 0, y: 0 });
@@ -27,12 +34,14 @@ test("card types inherit common element transforms and add branching; plain text
assert.throws(() => branchCanvasNode({ ...emptyCanvas(), nodes: [node] }, node.id, "right"), /纯文本/); assert.throws(() => branchCanvasNode({ ...emptyCanvas(), nodes: [node] }, node.id, "right"), /纯文本/);
}); });
// 五种旋转角度乘八方向 handle,检查受影响尺寸和相反局部锚点世界坐标保持不变。
test("all eight handles resize cards while retaining the opposite local anchor, including rotated cards", () => { test("all eight handles resize cards while retaining the opposite local anchor, including rotated cards", () => {
const directions = ["top", "right", "bottom", "left", "top-left", "top-right", "bottom-left", "bottom-right"]; const directions = ["top", "right", "bottom", "left", "top-left", "top-right", "bottom-left", "bottom-right"];
for (const rotation of [0, 37, 90, 180, 270]) { for (const rotation of [0, 37, 90, 180, 270]) {
for (const direction of directions) { for (const direction of directions) {
const node = new TextCardElement({ x: 100, y: 200 }).toNode(); const node = new TextCardElement({ x: 100, y: 200 }).toNode();
node.data.rotation = rotation; node.data.rotation = rotation;
// hx/hy 表示当前 handle 的局部符号;位移先旋转到世界,再比较缩放前后相反侧锚点的世界位置。
const hx = direction.includes("left") ? -1 : direction.includes("right") ? 1 : 0; const hx = direction.includes("left") ? -1 : direction.includes("right") ? 1 : 0;
const hy = direction.includes("top") ? -1 : direction.includes("bottom") ? 1 : 0; const hy = direction.includes("top") ? -1 : direction.includes("bottom") ? 1 : 0;
const delta = rotateVector({ x: hx * 60, y: hy * 40 }, rotation); const delta = rotateVector({ x: hx * 60, y: hy * 40 }, rotation);
@@ -49,6 +58,7 @@ test("all eight handles resize cards while retaining the opposite local anchor,
} }
}); });
// 纯文本宽高独立改变,最小值受限但不改变字号;仅拉宽或拉高不应连带另一维度。
test("plain text width and height resize independently without changing the font", () => { test("plain text width and height resize independently without changing the font", () => {
const node = new PlainTextElement({ x: 0, y: 0 }).toNode(); const node = new PlainTextElement({ x: 0, y: 0 }).toNode();
const patch = resizeFromPointer(node, "bottom-right", { x: 240, y: 72 }); const patch = resizeFromPointer(node, "bottom-right", { x: 240, y: 72 });
@@ -61,6 +71,7 @@ test("plain text width and height resize independently without changing the font
assert(min.width >= 60); assert(min.height >= 24); assert.equal(min.fontSize, undefined); assert(min.width >= 60); assert(min.height >= 24); assert.equal(min.fontSize, undefined);
}); });
// 旋转跟随指针绕中心方向,Shift 风格吸附到 15 度,负方向归一为 0..360 范围。
test("rotation follows the pointer, supports 15-degree snapping and normalizes the angle", () => { test("rotation follows the pointer, supports 15-degree snapping and normalizes the angle", () => {
const node = new TextCardElement({ x: 0, y: 0 }).toNode(); const node = new TextCardElement({ x: 0, y: 0 }).toNode();
const center = nodeCenter(node); const center = nodeCenter(node);
@@ -71,6 +82,7 @@ test("rotation follows the pointer, supports 15-degree snapping and normalizes t
close(rotationFromPointer(node, start, { x: center.x, y: center.y - 100 }), 270); close(rotationFromPointer(node, start, { x: center.x, y: center.y - 100 }), 270);
}); });
// checkpoint 内变换可保存恢复并一次 undo;纯文本 type、宽高、旋转和原字号各自保持正确。
test("geometry and plain-text type survive save/restore and transforms undo as one gesture", () => { test("geometry and plain-text type survive save/restore and transforms undo as one gesture", () => {
const node = new PlainTextElement({ x: 20, y: 30 }).toNode(); const node = new PlainTextElement({ x: 20, y: 30 }).toNode();
let state = { past: [], present: { ...emptyCanvas(), nodes: [node] }, future: [] }; let state = { past: [], present: { ...emptyCanvas(), nodes: [node] }, future: [] };
@@ -84,6 +96,7 @@ test("geometry and plain-text type survive save/restore and transforms undo as o
close(undo.present.nodes[0].data.rotation, 0); close(undo.present.nodes[0].data.rotation, 0);
}); });
// 旧 material/text 草稿仍解码成卡片且补默认 rotation;负尺寸、超界角度及超大字号逐项拒绝。
test("old text-card drafts remain cards and invalid transform values are rejected", () => { test("old text-card drafts remain cards and invalid transform values are rejected", () => {
const old = { id: "old", type: "material", position: { x: 0, y: 0 }, data: { kind: "text", title: "Old", text: "Keep" } }; const old = { id: "old", type: "material", position: { x: 0, y: 0 }, data: { kind: "text", title: "Old", text: "Keep" } };
const document = { ...emptyCanvas(), nodes: [old] }; const document = { ...emptyCanvas(), nodes: [old] };
@@ -94,6 +107,7 @@ test("old text-card drafts remain cards and invalid transform values are rejecte
} }
}); });
// 对 0/90/180 度检查四个边中心和单位法线,再验证缩放后的实际 right/bottom 锚点。
test("connection endpoints stay on the exact border centers after resize and rotation", () => { test("connection endpoints stay on the exact border centers after resize and rotation", () => {
const node = new TextCardElement({ x: 20, y: 30 }).toNode(); const node = new TextCardElement({ x: 20, y: 30 }).toNode();
const expected = { const expected = {
@@ -115,6 +129,7 @@ test("connection endpoints stay on the exact border centers after resize and rot
assert.deepEqual(nodeConnector(resized, "bottom").point, { x: 200, y: 280 }); assert.deepEqual(nodeConnector(resized, "bottom").point, { x: 200, y: 280 });
}); });
// Bezier 字符串必须从精确 source anchor 开始到 target anchor 结束;selected 仅改变 UI,不改变几何路径。
test("Bezier paths include both exact card anchors, independent of selected handle sizes", () => { test("Bezier paths include both exact card anchors, independent of selected handle sizes", () => {
const source = new TextCardElement({ x: 20, y: 30 }).toNode(); const source = new TextCardElement({ x: 20, y: 30 }).toNode();
const target = new TextCardElement({ x: 500, y: 180 }).toNode(); const target = new TextCardElement({ x: 500, y: 180 }).toNode();
+13
View File
@@ -1,3 +1,7 @@
/**
* 本地图标目录/分片和共享 ResourceLoader 测试。直接读仓库生成资产,校验本地哈希和引用,不访问图标 CDN。
* loader 的活跃并发与缓存容量受限,不能据此推断待处理队列本身有长度上限。
*/
import { test } from "node:test"; import { test } from "node:test";
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { readFile } from "node:fs/promises"; import { readFile } from "node:fs/promises";
@@ -8,10 +12,12 @@ import { iconAsset, validateIconShard } from "../src/lib/canvas-icon-loader.ts";
import { decodeCanvas, emptyCanvas, makeCanvasNode } from "../src/lib/canvas.ts"; import { decodeCanvas, emptyCanvas, makeCanvasNode } from "../src/lib/canvas.ts";
import { createResourceLoader } from "../src/lib/resource-loader.ts"; import { createResourceLoader } from "../src/lib/resource-loader.ts";
// 核对目录数量、ID 唯一性、分片文件哈希/体积/合法 SVG 数据、每个图标和分类存在;选取三个 ID 再验证旧快照往返。
test("all Lucide and experimental Tabler IDs map to valid local shards and survive snapshots", async () => { test("all Lucide and experimental Tabler IDs map to valid local shards and survive snapshots", async () => {
assert.equal(catalog.filter((icon) => icon.source === "lucide").length, 2313); assert.equal(catalog.filter((icon) => icon.source === "lucide").length, 2313);
assert.equal(catalog.filter((icon) => icon.source === "tabler").length, 24); assert.equal(catalog.filter((icon) => icon.source === "tabler").length, 24);
assert.equal(new Set(catalog.map((icon) => icon.name)).size, catalog.length); assert.equal(new Set(catalog.map((icon) => icon.name)).size, catalog.length);
// 按 manifest 逐分片读取本地 JSON;文件名哈希取 trimEnd 后文本的 SHA-256 前 12 位,单片小于 20000 字节。
const loaded = new Map(); const loaded = new Map();
for (const asset of Object.values(manifest.assets)) { for (const asset of Object.values(manifest.assets)) {
const content = await readFile(`public${manifest.root}/${asset}`, "utf8"); const content = await readFile(`public${manifest.root}/${asset}`, "utf8");
@@ -30,13 +36,16 @@ test("all Lucide and experimental Tabler IDs map to valid local shards and survi
} }
}); });
// 对 script、onload、外部 paint URL 和 use/href 的样例逐个拒绝;这是数据校验例,不是完整浏览器 SVG 安全审计。
test("icon data rejects executable shapes, events and external paint references", () => { test("icon data rejects executable shapes, events and external paint references", () => {
for (const node of [["script", {}], ["path", { onload: "alert(1)" }], ["path", { fill: "url(https://example.com)" }], ["use", { href: "bad" }]]) { for (const node of [["script", {}], ["path", { onload: "alert(1)" }], ["path", { fill: "url(https://example.com)" }], ["use", { href: "bad" }]]) {
assert.throws(() => validateIconShard({ Star: [node] })); assert.throws(() => validateIconShard({ Star: [node] }));
} }
}); });
// 让 setImmediate 推进到调度任务阶段;用于观察尚未开始、排队及已释放容量的加载。
const tick = () => new Promise((resolve) => setImmediate(resolve)); const tick = () => new Promise((resolve) => setImmediate(resolve));
// request 后立即 abort,尚未进入调度时不能启动网络加载,也不能把未完成值留入缓存。
test("an immediately unmounted icon does not start a network request", async () => { test("an immediately unmounted icon does not start a network request", async () => {
let calls = 0; let calls = 0;
const loader = createResourceLoader(async () => { calls++; return "unused"; }); const loader = createResourceLoader(async () => { calls++; return "unused"; });
@@ -47,8 +56,10 @@ test("an immediately unmounted icon does not start a network request", async ()
assert.equal(calls, 0); assert.equal(loader.peek("unused"), undefined); assert.equal(calls, 0); assert.equal(loader.peek("unused"), undefined);
}); });
// 同 key a 两个订阅只加载一次,容量二时 c 等待;完成后缓存命中不新增调用,峰值为二。
test("loader bounds concurrency, deduplicates shared shards and reuses cache", async () => { test("loader bounds concurrency, deduplicates shared shards and reuses cache", async () => {
let active = 0, peak = 0, calls = 0; let active = 0, peak = 0, calls = 0;
// 手动完成 active 加载,用于在容量为二时观察第三项等待及共享 key 去重。
const finish = []; const finish = [];
const loader = createResourceLoader(async (key) => { const loader = createResourceLoader(async (key) => {
active++; peak = Math.max(peak, active); calls++; active++; peak = Math.max(peak, active); calls++;
@@ -62,6 +73,7 @@ test("loader bounds concurrency, deduplicates shared shards and reuses cache", a
assert.equal(await loader.request("a"), "a"); assert.equal(calls, 3); assert.equal(peak, 2); assert.equal(await loader.request("a"), "a"); assert.equal(calls, 3); assert.equal(peak, 2);
}); });
// 取消排队 b 后它永不启动;取消 a 的一个订阅不能中止另一个订阅共享的 active a。
test("unused queued loads never start; a remaining subscriber keeps a shared load alive", async () => { test("unused queued loads never start; a remaining subscriber keeps a shared load alive", async () => {
const seen = []; let release; const seen = []; let release;
const loader = createResourceLoader(async (key) => { seen.push(key); if (key === "a") await new Promise((resolve) => release = resolve); return key; }, 1); const loader = createResourceLoader(async (key) => { seen.push(key); if (key === "a") await new Promise((resolve) => release = resolve); return key; }, 1);
@@ -73,6 +85,7 @@ test("unused queued loads never start; a remaining subscriber keeps a shared loa
await rejected; assert.equal(await shared, "a"); await tick(); assert.deepEqual(seen, ["a"]); await rejected; assert.equal(await shared, "a"); await tick(); assert.deepEqual(seen, ["a"]);
}); });
// active abort 后释放容量,失败 key 可重新加载;容量二的 LRU 在加入 a/b 后淘汰更早 fail,保留仍在缓存的 a。
test("active aborts and failed requests can retry; least recently used cache is bounded", async () => { test("active aborts and failed requests can retry; least recently used cache is bounded", async () => {
let fail = true; let fail = true;
const loader = createResourceLoader(async (key, signal) => { const loader = createResourceLoader(async (key, signal) => {
+7
View File
@@ -1,3 +1,7 @@
/**
* 旧画布 CanvasElement 类体系的纯图片能力及图标搜索测试,与 headless component registry 的模型定义分开。
* 只操作节点数据、几何和序列化,不挂载 renderer 或读取图片字节。
*/
import { test } from "node:test"; import { test } from "node:test";
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { canvasReducer, decodeCanvas, emptyCanvas, makeCanvasNode } from "../src/lib/canvas.ts"; import { canvasReducer, decodeCanvas, emptyCanvas, makeCanvasNode } from "../src/lib/canvas.ts";
@@ -5,6 +9,7 @@ import { CanvasElement, CardElement, PlainImageElement } from "../src/lib/canvas
import { resizeFromPointer } from "../src/lib/canvas-geometry.ts"; import { resizeFromPointer } from "../src/lib/canvas-geometry.ts";
import { iconCatalog, iconsById, searchIcons } from "../src/components/canvas/icon-catalog.ts"; import { iconCatalog, iconsById, searchIcons } from "../src/components/canvas/icon-catalog.ts";
// PlainImageElement 继承通用变换但不继承 CardElement,比例缩放、名称和 src 可恢复,重命名可撤销。
test("plain images inherit transforms without card connections and preserve source/name on restore", () => { test("plain images inherit transforms without card connections and preserve source/name on restore", () => {
const image = new PlainImageElement({ x: 0, y: 0 }, "", "data:image/png;base64,YQ=="); const image = new PlainImageElement({ x: 0, y: 0 }, "", "data:image/png;base64,YQ==");
assert(image instanceof CanvasElement); assert.equal(image instanceof CardElement, false); assert(image instanceof CanvasElement); assert.equal(image instanceof CardElement, false);
@@ -18,6 +23,7 @@ test("plain images inherit transforms without card connections and preserve sour
assert.equal(canvasReducer(renamed, { type: "undo" }).present.nodes[0].data.title, "图片"); assert.equal(canvasReducer(renamed, { type: "undo" }).present.nodes[0].data.title, "图片");
}); });
// 纯图片不能作为卡片连线目标;删除连线后 javascript: src 仍必须被解码器拒绝。
test("plain image sources are required and pure images cannot acquire card connections", () => { test("plain image sources are required and pure images cannot acquire card connections", () => {
const image = makeCanvasNode("plain-image", { x: 0, y: 0 }, "data:image/png;base64,YQ=="); const image = makeCanvasNode("plain-image", { x: 0, y: 0 }, "data:image/png;base64,YQ==");
const card = makeCanvasNode("text", { x: 300, y: 0 }); const card = makeCanvasNode("text", { x: 300, y: 0 });
@@ -27,6 +33,7 @@ test("plain image sources are required and pure images cannot acquire card conne
assert.throws(() => decodeCanvas(JSON.stringify(document))); assert.throws(() => decodeCanvas(JSON.stringify(document)));
}); });
// 图标 ID 唯一并符合后端字符形状,中文类别与英文词可搜索;仅验证当前本地目录,不请求后端搜索。
test("the complete Lucide catalog has unique backend-compatible identifiers and searchable names", () => { test("the complete Lucide catalog has unique backend-compatible identifiers and searchable names", () => {
assert(iconCatalog.length > 2000); assert(iconCatalog.length > 2000);
assert.equal(iconsById.size, iconCatalog.length); assert.equal(iconsById.size, iconCatalog.length);
+31
View File
@@ -1,21 +1,32 @@
/**
* 旧 linked-board 成员迁移的 hook 与 DOM 场景,由 canvas-ui-check 提供运行环境。
* 内存 records/receipt 模拟版本、丢响应和能力探测;它验证客户端协调,不能替代后端数据库原子性或持久恢复测试。
*/
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
// 先直接驱动 useCanvasTree 精确控制竞态,再以 GraphCanvas 的拖放事件检查局部状态和其他节点可编辑。
export async function canvasMemberScenario({root,act,h,useCanvasTree,makeCanvasNode,emptyCanvas,tick,mock,GraphCanvas,event,window}) { export async function canvasMemberScenario({root,act,h,useCanvasTree,makeCanvasNode,emptyCanvas,tick,mock,GraphCanvas,event,window}) {
// 父画布拥有分区入口和其他文本,子画布拥有待迁移图标;投影 ID 与服务端本地 ID 区分。
const parent='aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa',child='bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb'; const parent='aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa',child='bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb';
const frame=makeCanvasNode('frame',{x:100,y:100});frame.id='frame';frame.data.subgraph={boardId:child}; const frame=makeCanvasNode('frame',{x:100,y:100});frame.id='frame';frame.data.subgraph={boardId:child};
const icon=makeCanvasNode('icon',{x:10,y:20});icon.id='moving'; const icon=makeCanvasNode('icon',{x:10,y:20});icon.id='moving';
const other=makeCanvasNode('text',{x:950,y:100});other.id='other'; const other=makeCanvasNode('text',{x:950,y:100});other.id='other';
let records,tree,posts,saves,release,mode,receipt,key,body,capability=true,capMode="",capCalls=0; let records,tree,posts,saves,release,mode,receipt,key,body,capability=true,capMode="",capCalls=0;
// 统一生成 JSON 响应,HTTP 错误也由客户端常规解析路径处理。
const json=(v,status=200)=>new Response(JSON.stringify(v),{status,headers:{'content-type':'application/json'}}); const json=(v,status=200)=>new Response(JSON.stringify(v),{status,headers:{'content-type':'application/json'}});
// 只挂载 hook 并暴露最新 tree,测试直接调用命令而不依赖 DOM 手势。
function Fixture(){const seed=structuredClone(records.get(parent));tree=useCanvasTree(parent,'w',seed.document,seed);return null;} function Fixture(){const seed=structuredClone(records.get(parent));tree=useCanvasTree(parent,'w',seed.document,seed);return null;}
// 卸载后重建父子快照与请求记录;模式选择控制能力慢响应、失败、版本冲突、丢 ACK 或挂起提交。
const reset=async(nextMode='success')=>{ const reset=async(nextMode='success')=>{
await act(async()=>{root.render(null);await tick();});localStorage.clear(); await act(async()=>{root.render(null);await tick();});localStorage.clear();
records=new Map([[parent,{version:1,document:{...emptyCanvas(),schema_version:2,nodes:[structuredClone(frame),structuredClone(other)]}}],[child,{version:1,document:{...emptyCanvas(),schema_version:2,nodes:[structuredClone(icon)]}}]]); records=new Map([[parent,{version:1,document:{...emptyCanvas(),schema_version:2,nodes:[structuredClone(frame),structuredClone(other)]}}],[child,{version:1,document:{...emptyCanvas(),schema_version:2,nodes:[structuredClone(icon)]}}]]);
posts=[];saves=[];release=null;mode=nextMode;receipt=null;key=null;body=null;capMode=nextMode;capCalls=0; posts=[];saves=[];release=null;mode=nextMode;receipt=null;key=null;body=null;capMode=nextMode;capCalls=0;
if(mode==='queued'){const second=structuredClone(icon);second.id='moving-two';second.position={x:100,y:20};records.get(child).document.nodes.push(second);mode='delayed';} if(mode==='queued'){const second=structuredClone(icon);second.id='moving-two';second.position={x:100,y:20};records.get(child).document.nodes.push(second);mode='delayed';}
// 内存 API 按路径分流:完整快照保存、版本命令、分区创建与成员迁移,各自记录参与者版本。
mock(async(url,options={})=>{ mock(async(url,options={})=>{
const parts=new URL(url,'http://localhost').pathname.split('/'),id=parts[6],suffix=parts.slice(8).join('/'),method=options.method||'GET'; const parts=new URL(url,'http://localhost').pathname.split('/'),id=parts[6],suffix=parts.slice(8).join('/'),method=options.method||'GET';
if(suffix==='ai/runs')return json({runs:[]}); if(suffix==='ai/runs')return json({runs:[]});
// 慢响应延迟超过两秒;一次网络失败只影响首个请求,允许检查重新探测而非永久判为不支持。
if(suffix==='capabilities'){ if(suffix==='capabilities'){
capCalls++;if(capMode==='slowcap'){await new Promise(r=>setTimeout(r,2100));if(options.signal?.aborted)throw new DOMException('Aborted','AbortError');} capCalls++;if(capMode==='slowcap'){await new Promise(r=>setTimeout(r,2100));if(options.signal?.aborted)throw new DOMException('Aborted','AbortError');}
if(capMode==='cap-failure'&&capCalls===1)throw new TypeError('network unavailable'); if(capMode==='cap-failure'&&capCalls===1)throw new TypeError('network unavailable');
@@ -27,6 +38,7 @@ export async function canvasMemberScenario({root,act,h,useCanvasTree,makeCanvasN
const document=JSON.parse(options.body).document;saves.push({id,document});records.set(id,{version:records.get(id).version+1,document});return json({version:records.get(id).version}); const document=JSON.parse(options.body).document;saves.push({id,document});records.set(id,{version:records.get(id).version+1,document});return json({version:records.get(id).version});
} }
if(suffix==='ai/runs'&&method==='POST'){const next=structuredClone(records.get(id));next.version++;records.set(id,next);return json({canvas_version:next.version,board_id:id});} if(suffix==='ai/runs'&&method==='POST'){const next=structuredClone(records.get(id));next.version++;records.set(id,next);return json({canvas_version:next.version,board_id:id});}
// 模拟创建父入口和新子图,可在状态已写入后暂缓返回,制造上传完成先于命令响应的窗口。
if(suffix==='subgraphs'&&method==='POST'){ if(suffix==='subgraphs'&&method==='POST'){
const input=JSON.parse(options.body).frame,next=structuredClone(records.get(parent)),childID='66666666-6666-4666-8666-666666666666'; const input=JSON.parse(options.body).frame,next=structuredClone(records.get(parent)),childID='66666666-6666-4666-8666-666666666666';
const added=makeCanvasNode('frame',input.position);added.id=input.id;added.data.subgraph={boardId:childID};next.document.nodes.push(added);next.version++;records.set(parent,next); const added=makeCanvasNode('frame',input.position);added.id=input.id;added.data.subgraph={boardId:childID};next.document.nodes.push(added);next.version++;records.set(parent,next);
@@ -34,29 +46,36 @@ export async function canvasMemberScenario({root,act,h,useCanvasTree,makeCanvasN
if(mode==='create-delayed')await new Promise(resolve=>{release=resolve;}); if(mode==='create-delayed')await new Promise(resolve=>{release=resolve;});
return json({transaction_id:crypto.randomUUID(),parent:{board_id:parent,...structuredClone(next)},snapshots:[{board_id:parent,...structuredClone(next)}],child:{board_id:childID,...empty}}); return json({transaction_id:crypto.randomUUID(),parent:{board_id:parent,...structuredClone(next)},snapshots:[{board_id:parent,...structuredClone(next)}],child:{board_id:childID,...empty}});
} }
// 保存原 body/key;命中上一次回执必须逐字节相同,返回既有结果而不再移动成员。
assert.equal(suffix,'subgraphs/frame/members');posts.push({body:options.body,key:options.headers['Idempotency-Key']}); assert.equal(suffix,'subgraphs/frame/members');posts.push({body:options.body,key:options.headers['Idempotency-Key']});
if(receipt && options.headers['Idempotency-Key']===key){assert.equal(options.body,body);return json(receipt);} if(receipt && options.headers['Idempotency-Key']===key){assert.equal(options.body,body);return json(receipt);}
receipt=null; receipt=null;
body=options.body;key=options.headers['Idempotency-Key'];const input=JSON.parse(body); body=options.body;key=options.headers['Idempotency-Key'];const input=JSON.parse(body);
// 先核对双方期望版本,412/409 模式在迁移前返回;成功路径转换 owner 局部坐标并同时更新两个记录。
for(const id of [parent,child])assert.equal(input.expected_versions[id],records.get(id).version); for(const id of [parent,child])assert.equal(input.expected_versions[id],records.get(id).version);
if(mode==='conflict'){records.get(parent).version++;mode='success';return json({error:'version_conflict'},412);} if(mode==='conflict'){records.get(parent).version++;mode='success';return json({error:'version_conflict'},412);}
if(mode==='rejected'){mode='success';return json({error:'ownership_conflict'},409);} if(mode==='rejected'){mode='success';return json({error:'ownership_conflict'},409);}
const source=structuredClone(records.get(input.source_board_id)),target=structuredClone(records.get(input.target_board_id)); const source=structuredClone(records.get(input.source_board_id)),target=structuredClone(records.get(input.target_board_id));
const moving=source.document.nodes.find(n=>n.id===input.node_ids[0]);const pos=input.positions?.[moving.id]||moving.position; const moving=source.document.nodes.find(n=>n.id===input.node_ids[0]);const pos=input.positions?.[moving.id]||moving.position;
// 本夹具中分区内容原点相对父画布为 (124,136),进出分区据此增减坐标。
const converted=input.source_board_id===child?{x:pos.x+124,y:pos.y+136}:{x:pos.x-124,y:pos.y-136}; const converted=input.source_board_id===child?{x:pos.x+124,y:pos.y+136}:{x:pos.x-124,y:pos.y-136};
source.document.nodes=source.document.nodes.filter(n=>n.id!==moving.id);target.document.nodes.push({...moving,position:converted});source.version++;target.version++;records.set(input.source_board_id,source);records.set(input.target_board_id,target); source.document.nodes=source.document.nodes.filter(n=>n.id!==moving.id);target.document.nodes.push({...moving,position:converted});source.version++;target.version++;records.set(input.source_board_id,source);records.set(input.target_board_id,target);
receipt={transaction_id:crypto.randomUUID(),snapshots:[...records].map(([board_id,value])=>({board_id,...structuredClone(value)}))}; receipt={transaction_id:crypto.randomUUID(),snapshots:[...records].map(([board_id,value])=>({board_id,...structuredClone(value)}))};
// lost 在记录已更新后回 502;delayed 则挂起成功 ACK,分别覆盖不确定重试与并行本地编辑。
if(mode==='lost')return json({error:'upstream_unavailable'},502); if(mode==='lost')return json({error:'upstream_unavailable'},502);
if(mode==='delayed')await new Promise(resolve=>{release=resolve;}); if(mode==='delayed')await new Promise(resolve=>{release=resolve;});
return json(receipt); return json(receipt);
}); });
await act(async()=>{root.render(h(Fixture));await tick();await tick();}); await act(async()=>{root.render(h(Fixture));await tick();await tick();});
}; };
// 有限轮询并推进 React effect,超时描述具体等待条件,不把请求已发出当作已确认。
const wait=async(check,label)=>{for(let i=0;i<120;i++){if(check())return;await act(async()=>{await new Promise(r=>setTimeout(r,30));});}throw new Error('Timeout '+label);}; const wait=async(check,label)=>{for(let i=0;i<120;i++){if(check())return;await act(async()=>{await new Promise(r=>setTimeout(r,30));});}throw new Error('Timeout '+label);};
// 选中投影图标并模拟拖到父坐标,再提交跨 owner 迁移,等待请求实际进入替身。
const start=async()=>{ const start=async()=>{
await act(async()=>{tree.select(child+'::moving');tree.setDragging(true);tree.nodesChanged([{type:'position',id:child+'::moving',position:{x:950,y:200},dragging:false}]);tree.setDragging(false);void tree.moveMembers(parent,'frame',child,parent,['moving'],[parent,child]);await tick();}); await act(async()=>{tree.select(child+'::moving');tree.setDragging(true);tree.nodesChanged([{type:'position',id:child+'::moving',position:{x:950,y:200},dragging:false}]);tree.setDragging(false);void tree.moveMembers(parent,'frame',child,parent,['moving'],[parent,child]);await tick();});
await wait(()=>posts.length,'move admission'); await wait(()=>posts.length,'move admission');
}; };
// 迁移携带落点,不先 PATCH;挂起期间只保护待移动成员,其他文本/拖动仍可继续。
await reset('delayed');await start(); await reset('delayed');await start();
assert.equal(saves.length,0,'Drop-only atomic moves must not PATCH first');assert.equal(tree.busy,false,'Member moves must not lock all editing');assert.equal(tree.memberJobs.length,1); assert.equal(saves.length,0,'Drop-only atomic moves must not PATCH first');assert.equal(tree.busy,false,'Member moves must not lock all editing');assert.equal(tree.memberJobs.length,1);
assert.deepEqual(JSON.parse(posts[0].body).positions.moving,{x:826,y:64}); assert.deepEqual(JSON.parse(posts[0].body).positions.moving,{x:826,y:64});
@@ -64,6 +83,7 @@ export async function canvasMemberScenario({root,act,h,useCanvasTree,makeCanvasN
assert.equal(tree.rootDocument.nodes.find(n=>n.id==='other').data.text,'同步期间编辑'); assert.equal(tree.rootDocument.nodes.find(n=>n.id==='other').data.text,'同步期间编辑');
await act(async()=>{tree.nodesChanged([{type:'position',id:child+'::moving',position:{x:1200,y:900},dragging:false}]);await tick();}); await act(async()=>{tree.nodesChanged([{type:'position',id:child+'::moving',position:{x:1200,y:900},dragging:false}]);await tick();});
assert.deepEqual(tree.current.current.get(child).nodes[0].position,{x:826,y:64},'Pending member cannot move again'); assert.deepEqual(tree.current.current.get(child).nodes[0].position,{x:826,y:64},'Pending member cannot move again');
// 放行 ACK 后检查坐标、选中和唯一归属,同时保留并最终保存无关编辑;其 undo 不能复活旧 owner。
await act(async()=>{release();await tick();await tick();});await wait(()=>!tree.memberJobs.length,'move acknowledged'); await act(async()=>{release();await tick();await tick();});await wait(()=>!tree.memberJobs.length,'move acknowledged');
assert.equal(tree.rootDocument.nodes.find(n=>n.id==='other').data.text,'同步期间编辑','Remote snapshots preserve local text'); assert.equal(tree.rootDocument.nodes.find(n=>n.id==='other').data.text,'同步期间编辑','Remote snapshots preserve local text');
assert.deepEqual(tree.rootDocument.nodes.find(n=>n.id==='other').position,{x:1100,y:130},'Remote snapshots preserve unrelated dragging'); assert.deepEqual(tree.rootDocument.nodes.find(n=>n.id==='other').position,{x:1100,y:130},'Remote snapshots preserve unrelated dragging');
@@ -76,13 +96,16 @@ export async function canvasMemberScenario({root,act,h,useCanvasTree,makeCanvasN
await act(async()=>{await tree.redo();await tick();}); await act(async()=>{await tree.redo();await tick();});
assert.deepEqual(tree.rootDocument.nodes.find(n=>n.id==='other').position,{x:1100,y:130}); assert.deepEqual(tree.rootDocument.nodes.find(n=>n.id==='other').position,{x:1100,y:130});
console.log('PASS member sync: one POST, no preceding PATCH, local editing during delayed commit, geometry and draft merge'); console.log('PASS member sync: one POST, no preceding PATCH, local editing during delayed commit, geometry and draft merge');
// 丢 ACK 的重试沿用完全相同的 body/key,并保留响应丢失之后的新编辑。
await reset('lost');await start();await wait(()=>tree.memberJobs[0]?.error,'uncertain result'); await reset('lost');await start();await wait(()=>tree.memberJobs[0]?.error,'uncertain result');
assert.equal(tree.busy,false);await act(async()=>{tree.nodeAction('other',id=>({type:'edit',id,text:'响应丢失后的编辑'}));tree.retryMembers();await tick();}); assert.equal(tree.busy,false);await act(async()=>{tree.nodeAction('other',id=>({type:'edit',id,text:'响应丢失后的编辑'}));tree.retryMembers();await tick();});
await wait(()=>!tree.memberJobs.length,'idempotent retry');assert.equal(posts.length,2);assert.equal(posts[0].body,posts[1].body);assert.equal(posts[0].key,posts[1].key);assert.equal(tree.rootDocument.nodes.find(n=>n.id==='other').data.text,'响应丢失后的编辑'); await wait(()=>!tree.memberJobs.length,'idempotent retry');assert.equal(posts.length,2);assert.equal(posts[0].body,posts[1].body);assert.equal(posts[0].key,posts[1].key);assert.equal(tree.rootDocument.nodes.find(n=>n.id==='other').data.text,'响应丢失后的编辑');
console.log('PASS member retry: lost acknowledgement reuses body/key, keeps edits and does not duplicate ownership'); console.log('PASS member retry: lost acknowledgement reuses body/key, keeps edits and does not duplicate ownership');
// 明确拒绝后读取新版本再构造新命令,因此新幂等键与旧键不同。
await reset('conflict');await start();await wait(()=>tree.memberJobs[0]?.error,'version rejection'); await reset('conflict');await start();await wait(()=>tree.memberJobs[0]?.error,'version rejection');
await act(async()=>{tree.retryMembers();await tick();});await wait(()=>!tree.memberJobs.length,'fresh version retry');assert.equal(posts.length,2);assert.notEqual(posts[0].key,posts[1].key);assert.equal(JSON.parse(posts[1].body).expected_versions[parent],2); await act(async()=>{tree.retryMembers();await tick();});await wait(()=>!tree.memberJobs.length,'fresh version retry');assert.equal(posts.length,2);assert.notEqual(posts[0].key,posts[1].key);assert.equal(JSON.parse(posts[1].body).expected_versions[parent],2);
console.log('PASS member conflict: definite zero-write rejection reloads versions before a fresh command'); console.log('PASS member conflict: definite zero-write rejection reloads versions before a fresh command');
// 两个迁移共享参与者,第二个等待首个 ACK 后的新版本;不额外发送坐标 PATCH。
await reset('queued');await start(); await reset('queued');await start();
await act(async()=>{tree.nodesChanged([{type:'position',id:child+'::moving-two',position:{x:1000,y:300},dragging:false}]);void tree.moveMembers(parent,'frame',child,parent,['moving-two'],[parent,child]);await tick();}); await act(async()=>{tree.nodesChanged([{type:'position',id:child+'::moving-two',position:{x:1000,y:300},dragging:false}]);void tree.moveMembers(parent,'frame',child,parent,['moving-two'],[parent,child]);await tick();});
assert.equal(posts.length,1,'Second ownership change waits for participant versions'); assert.equal(posts.length,1,'Second ownership change waits for participant versions');
@@ -91,6 +114,7 @@ export async function canvasMemberScenario({root,act,h,useCanvasTree,makeCanvasN
assert.equal(saves.length,0);assert.equal(tree.rootDocument.nodes.filter(n=>n.id.startsWith('moving')).length,2);assert.equal(tree.current.current.get(child).nodes.length,0); assert.equal(saves.length,0);assert.equal(tree.rootDocument.nodes.filter(n=>n.id.startsWith('moving')).length,2);assert.equal(tree.current.current.get(child).nodes.length,0);
console.log('PASS member queue: unrelated cross-frame drops serialize versions without extra coordinate PATCH'); console.log('PASS member queue: unrelated cross-frame drops serialize versions without extra coordinate PATCH');
await reset(); await reset();
// 未上传图片尚无服务端节点:本地切换归属可撤销;上传到当前 owner 后,后续 undo/redo 才使用服务端迁移。
const pendingImage=makeCanvasNode('image',{x:200,y:200},'blob:editable-upload');pendingImage.id='pending-image';pendingImage.pendingUpload=pendingImage.id; const pendingImage=makeCanvasNode('image',{x:200,y:200},'blob:editable-upload');pendingImage.id='pending-image';pendingImage.pendingUpload=pendingImage.id;
await act(async()=>{tree.dispatch({type:'nodes',changes:[{type:'add',item:pendingImage}]});tree.movePendingImage(parent,child,pendingImage.id);await tick();}); await act(async()=>{tree.dispatch({type:'nodes',changes:[{type:'add',item:pendingImage}]});tree.movePendingImage(parent,child,pendingImage.id);await tick();});
assert.equal(posts.length,0);assert.equal(saves.length,0);assert.deepEqual(tree.current.current.get(child).nodes.find(n=>n.id===pendingImage.id).position,{x:76,y:64}); assert.equal(posts.length,0);assert.equal(saves.length,0);assert.deepEqual(tree.current.current.get(child).nodes.find(n=>n.id===pendingImage.id).position,{x:76,y:64});
@@ -102,6 +126,7 @@ export async function canvasMemberScenario({root,act,h,useCanvasTree,makeCanvasN
await act(async()=>{await tree.undo();await tick();});assert.ok(tree.rootDocument.nodes.some(n=>n.id===pendingImage.id));assert.ok(!tree.current.current.get(child).nodes.some(n=>n.id===pendingImage.id)); await act(async()=>{await tree.undo();await tick();});assert.ok(tree.rootDocument.nodes.some(n=>n.id===pendingImage.id));assert.ok(!tree.current.current.get(child).nodes.some(n=>n.id===pendingImage.id));
await act(async()=>{await tree.redo();await tick();});assert.ok(tree.current.current.get(child).nodes.some(n=>n.id===pendingImage.id));assert.equal(posts.length,2,'Undo/redo after persistence use real ownership transactions'); await act(async()=>{await tree.redo();await tick();});assert.ok(tree.current.current.get(child).nodes.some(n=>n.id===pendingImage.id));assert.equal(posts.length,2,'Undo/redo after persistence use real ownership transactions');
console.log('PASS pending image ownership: editable local moves, atomic local undo, current-owner upload resolution and transactional undo after persistence'); console.log('PASS pending image ownership: editable local moves, atomic local undo, current-owner upload resolution and transactional undo after persistence');
// 版本刷新和分区创建响应都不能覆盖本地 pending/已完成图片;重复父快照也只能采用一次。
await reset('create-delayed'); await reset('create-delayed');
const asyncImage=makeCanvasNode('image',{x:800,y:500},'blob:async-upload');asyncImage.id='async-image';asyncImage.pendingUpload=asyncImage.id; const asyncImage=makeCanvasNode('image',{x:800,y:500},'blob:async-upload');asyncImage.id='async-image';asyncImage.pendingUpload=asyncImage.id;
await act(async()=>{tree.dispatch({type:'nodes',changes:[{type:'add',item:asyncImage}]});await tick();}); await act(async()=>{tree.dispatch({type:'nodes',changes:[{type:'add',item:asyncImage}]});await tick();});
@@ -112,13 +137,17 @@ export async function canvasMemberScenario({root,act,h,useCanvasTree,makeCanvasN
await act(async()=>{tree.resolveImageUpload(asyncImage.id,{id:'77777777-7777-4777-8777-777777777777',url:'https://api.foundry.entroia.com/projects/endoleap/workspaces/w/assets/77777777-7777-4777-8777-777777777777'});release();await creation;await tick();}); await act(async()=>{tree.resolveImageUpload(asyncImage.id,{id:'77777777-7777-4777-8777-777777777777',url:'https://api.foundry.entroia.com/projects/endoleap/workspaces/w/assets/77777777-7777-4777-8777-777777777777'});release();await creation;await tick();});
assert.ok(tree.rootDocument.nodes.some(n=>n.id===asyncImage.id&&!n.pendingUpload),'Concurrent command response cannot overwrite the resolved image');assert.equal(tree.rootDocument.nodes.filter(n=>n.id==='new-frame').length,1,'Repeated parent snapshots are adopted only once'); assert.ok(tree.rootDocument.nodes.some(n=>n.id===asyncImage.id&&!n.pendingUpload),'Concurrent command response cannot overwrite the resolved image');assert.equal(tree.rootDocument.nodes.filter(n=>n.id==='new-frame').length,1,'Repeated parent snapshots are adopted only once');
console.log('PASS upload command races: version-only refresh and duplicate transaction snapshots retain pending/ready local images'); console.log('PASS upload command races: version-only refresh and duplicate transaction snapshots retain pending/ready local images');
// 慢能力探测和首次探测失败都不能误触发旧协议回退,失败后需重新探测。
await reset('slowcap');await start();await wait(()=>!tree.memberJobs.length,'slow capability move');assert.equal(saves.length,0,'A slow capability response must not trigger legacy PATCH fallback'); await reset('slowcap');await start();await wait(()=>!tree.memberJobs.length,'slow capability move');assert.equal(saves.length,0,'A slow capability response must not trigger legacy PATCH fallback');
await reset('cap-failure');await start();await wait(()=>!tree.memberJobs.length,'capability retry');assert.equal(capCalls,2);assert.equal(saves.length,0,'A failed prefetch is retried rather than cached as unsupported'); await reset('cap-failure');await start();await wait(()=>!tree.memberJobs.length,'capability retry');assert.equal(capCalls,2);assert.equal(saves.length,0,'A failed prefetch is retried rather than cached as unsupported');
console.log('PASS member capabilities: responses slower than two seconds and transient prefetch errors retain atomic moves'); console.log('PASS member capabilities: responses slower than two seconds and transient prefetch errors retain atomic moves');
// 明确不支持 positions 时才走旧的坐标保存加迁移两请求路径。
capability=false;await reset();await start();await wait(()=>!tree.memberJobs.length,'legacy move');assert.equal(saves.length,1,'Old backend retains the coordinate save');assert.equal(JSON.parse(posts[0].body).positions,undefined); capability=false;await reset();await start();await wait(()=>!tree.memberJobs.length,'legacy move');assert.equal(saves.length,1,'Old backend retains the coordinate save');assert.equal(JSON.parse(posts[0].body).positions,undefined);
console.log('PASS member compatibility: capability-gated positions and legacy two-request fallback'); console.log('PASS member compatibility: capability-gated positions and legacy two-request fallback');
// 切到真实 GraphCanvas DOM 事件:拖出子图显示局部同步徽标,另一个节点仍可拖动且 ACK 不复位它。
capability=true;await reset('delayed'); capability=true;await reset('delayed');
await act(async()=>{root.render(null);await tick();root.render(h(GraphCanvas,{board:{id:parent,workspace_id:'w',kind:'board',title:'Member gestures',version:1},snapshot:structuredClone(records.get(parent))}));await tick();await tick();}); await act(async()=>{root.render(null);await tick();root.render(h(GraphCanvas,{board:{id:parent,workspace_id:'w',kind:'board',title:'Member gestures',version:1},snapshot:structuredClone(records.get(parent))}));await tick();await tick();});
// 每次按投影 ID 重新查子图图标;成功迁移后应以父画布本地 ID 出现。
const member=()=>document.querySelector(`.react-flow__node[data-id="${child}::moving"]`); const member=()=>document.querySelector(`.react-flow__node[data-id="${child}::moving"]`);
await wait(()=>member(),'expanded icon'); await wait(()=>member(),'expanded icon');
await event(member(),'mousedown',{clientX:150,clientY:160,button:0,buttons:1}); await event(member(),'mousedown',{clientX:150,clientY:160,button:0,buttons:1});
@@ -127,6 +156,7 @@ export async function canvasMemberScenario({root,act,h,useCanvasTree,makeCanvasN
assert.ok(document.querySelector('[aria-label="元素同步状态"]'),'Moving node has a local sync badge'); assert.ok(document.querySelector('[aria-label="元素同步状态"]'),'Moving node has a local sync badge');
assert.equal([...document.querySelectorAll('[role="status"]')].some(n=>n.textContent==='正在同步画布…'),false,'No full-canvas synchronization overlay'); assert.equal([...document.querySelectorAll('[role="status"]')].some(n=>n.textContent==='正在同步画布…'),false,'No full-canvas synchronization overlay');
assert.equal(member().classList.contains('draggable'),false,'Only pending nodes and frame geometry are protected'); assert.equal(member().classList.contains('draggable'),false,'Only pending nodes and frame geometry are protected');
// 记录无关节点变换,确认同步中实际拖动生效,随后与 ACK 后 DOM 比较。
const otherNode=()=>document.querySelector('.react-flow__node[data-id="other"]');assert.equal(otherNode().classList.contains('draggable'),true); const otherNode=()=>document.querySelector('.react-flow__node[data-id="other"]');assert.equal(otherNode().classList.contains('draggable'),true);
const originalTransform=otherNode().style.transform; const originalTransform=otherNode().style.transform;
await event(otherNode(),'mousedown',{clientX:1000,clientY:150,button:0,buttons:1});await event(window,'mousemove',{clientX:1010,clientY:150,buttons:1});await event(window,'mousemove',{clientX:1100,clientY:180,buttons:1});await event(window,'mouseup',{clientX:1100,clientY:180,button:0}); await event(otherNode(),'mousedown',{clientX:1000,clientY:150,button:0,buttons:1});await event(window,'mousemove',{clientX:1010,clientY:150,buttons:1});await event(window,'mousemove',{clientX:1100,clientY:180,buttons:1});await event(window,'mouseup',{clientX:1100,clientY:180,button:0});
@@ -136,5 +166,6 @@ export async function canvasMemberScenario({root,act,h,useCanvasTree,makeCanvasN
assert.equal(otherNode().style.transform,changedTransform,'Acknowledgement does not reset concurrent dragging'); assert.equal(otherNode().style.transform,changedTransform,'Acknowledgement does not reset concurrent dragging');
assert.ok(document.querySelector('.react-flow__node[data-id="moving"]'),'Moved node has exactly one new owner'); assert.ok(document.querySelector('.react-flow__node[data-id="moving"]'),'Moved node has exactly one new owner');
console.log('PASS member UI: real cross-frame icon gesture, local status, no overlay and concurrent unrelated drag'); console.log('PASS member UI: real cross-frame icon gesture, local status, no overlay and concurrent unrelated drag');
// 正常结束卸载 hook/画布;整个 JSDOM 与全局资源由外层 canvas-ui-check 清理。
await act(async()=>{root.render(null);await tick();}); await act(async()=>{root.render(null);await tick();});
} }
+10
View File
@@ -1,9 +1,15 @@
/**
* 响应头、body 总传输与空闲三类截止时间的组合测试。用 Response/ReadableStream 和 fetch 替身验证读取、取消与诊断。
* HTTP 状态 200 不代表 body 可用:传输中断、非法 JSON、后端错误和 204 在 api 层需要分别表达。
*/
import test from 'node:test'; import test from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { fetchCanvasResponse } from '../src/lib/canvas-response.ts'; import { fetchCanvasResponse } from '../src/lib/canvas-response.ts';
import { api, ApiError } from '../src/lib/api.ts'; import { api, ApiError } from '../src/lib/api.ts';
// 延迟第二个真实流块,使传输超过 header 截止但仍在 body/idle 截止内;不是网络服务计时。
const wait = ms => new Promise(resolve => setTimeout(resolve, ms)); const wait = ms => new Promise(resolve => setTimeout(resolve, ms));
// header 返回后取消其截止约束,两个 UTF-8 块继续读完;ETag、完整字节数、chunk 数和 transfer_ms 报告均要保留。
test('snapshot body outlives the header deadline and reports transfer timing and bytes',async()=>{ test('snapshot body outlives the header deadline and reports transfer timing and bytes',async()=>{
const reports=[]; const bytes=new TextEncoder().encode('{"version":210,"document":{"title":"测试"}}'); const reports=[]; const bytes=new TextEncoder().encode('{"version":210,"document":{"title":"测试"}}');
const upstream=new Response(new ReadableStream({async start(c){ c.enqueue(bytes.slice(0,10));await wait(50);c.enqueue(bytes.slice(10));c.close();}}),{headers:{ETag:'"210"'}}); const upstream=new Response(new ReadableStream({async start(c){ c.enqueue(bytes.slice(0,10));await wait(50);c.enqueue(bytes.slice(10));c.close();}}),{headers:{ETag:'"210"'}});
@@ -11,15 +17,18 @@ test('snapshot body outlives the header deadline and reports transfer timing and
assert.equal(response.headers.get('etag'),'"210"');assert.equal((await response.json()).version,210); assert.equal(response.headers.get('etag'),'"210"');assert.equal((await response.json()).version,210);
assert.equal(reports.length,1);assert.equal(reports[0].outcome,'complete');assert.equal(reports[0].bytes,bytes.length);assert.ok(reports[0].transfer_ms>=30);assert.equal(reports[0].chunks,2); assert.equal(reports.length,1);assert.equal(reports[0].outcome,'complete');assert.equal(reports[0].bytes,bytes.length);assert.ok(reports[0].transfer_ms>=30);assert.equal(reports[0].chunks,2);
}); });
// 首块后不再产生数据,触发 idle 超时;consumer 必须收到 TimeoutError,上游 cancel 被调用,不能返回空快照。
test('snapshot idle timeout aborts unfinished bodies instead of returning a valid empty snapshot',async()=>{ test('snapshot idle timeout aborts unfinished bodies instead of returning a valid empty snapshot',async()=>{
const reports=[];let cancelled=false; const reports=[];let cancelled=false;
const upstream=new Response(new ReadableStream({start(c){c.enqueue(new TextEncoder().encode('{"version":'));},cancel(){cancelled=true;}})); const upstream=new Response(new ReadableStream({start(c){c.enqueue(new TextEncoder().encode('{"version":'));},cancel(){cancelled=true;}}));
const response=await fetchCanvasResponse('https://example.test',{}, {headersMs:100,bodyMs:500,idleMs:15},async()=>upstream,r=>reports.push(r)); const response=await fetchCanvasResponse('https://example.test',{}, {headersMs:100,bodyMs:500,idleMs:15},async()=>upstream,r=>reports.push(r));
await assert.rejects(response.json(),{name:'TimeoutError'});assert.ok(cancelled);assert.equal(reports[0].outcome,'error');assert.equal(reports[0].error,'TimeoutError'); await assert.rejects(response.json(),{name:'TimeoutError'});assert.ok(cancelled);assert.equal(reports[0].outcome,'error');assert.equal(reports[0].error,'TimeoutError');
}); });
// fetch 尚未返回头时监听 AbortSignal,header 超时应以 TimeoutError 拒绝,而不是等待 body 读取。
test('header deadline rejects a server that has not responded',async()=>{ test('header deadline rejects a server that has not responded',async()=>{
await assert.rejects(fetchCanvasResponse('https://example.test',{}, {headersMs:10,bodyMs:500,idleMs:100},(_url,options)=>new Promise((_resolve,reject)=>options.signal.addEventListener('abort',()=>reject(options.signal.reason)))),{name:'TimeoutError'}); await assert.rejects(fetchCanvasResponse('https://example.test',{}, {headersMs:10,bodyMs:500,idleMs:100},(_url,options)=>new Promise((_resolve,reject)=>options.signal.addEventListener('abort',()=>reject(options.signal.reason)))),{name:'TimeoutError'});
}); });
// 调用方 abort 和 consumer 直接 cancel 两条路径都释放上游读取,分别核对 AbortError 和 cancel 总次数。
test('disconnect and consumer cancellation release the upstream read',async()=>{ test('disconnect and consumer cancellation release the upstream read',async()=>{
const abort=new AbortController();let cancelled=0; const abort=new AbortController();let cancelled=0;
const make=()=>new Response(new ReadableStream({cancel(){cancelled++;}})); const make=()=>new Response(new ReadableStream({cancel(){cancelled++;}}));
@@ -27,6 +36,7 @@ test('disconnect and consumer cancellation release the upstream read',async()=>{
abort.abort();await assert.rejects(response.text(),{name:'AbortError'}); abort.abort();await assert.rejects(response.text(),{name:'AbortError'});
const second=await fetchCanvasResponse('https://example.test',{}, {headersMs:100,bodyMs:500,idleMs:100},async()=>make());await second.body.cancel();assert.equal(cancelled,2); const second=await fetchCanvasResponse('https://example.test',{}, {headersMs:100,bodyMs:500,idleMs:100},async()=>make());await second.body.cancel();assert.equal(cancelled,2);
}); });
// 临时替换全局 fetch,检查 interrupted/invalid JSON/server error/null 四种结果;finally 恢复原 fetch,避免污染其他用例。
test('client distinguishes a failed 200 body transfer, malformed JSON, server errors and 204',async()=>{ test('client distinguishes a failed 200 body transfer, malformed JSON, server errors and 204',async()=>{
const original=globalThis.fetch; const original=globalThis.fetch;
try { try {
+9
View File
@@ -1,11 +1,16 @@
/**
* 旧快照画布的分支创建、文字样式、图标变换与恢复校验。这里只验证 legacy reducer/schema,不代表 headless 增量事务实现。
*/
import { test } from "node:test"; import { test } from "node:test";
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { branchCanvasNode, canvasReducer, decodeCanvas, emptyCanvas, makeCanvasNode } from "../src/lib/canvas.ts"; import { branchCanvasNode, canvasReducer, decodeCanvas, emptyCanvas, makeCanvasNode } from "../src/lib/canvas.ts";
import { CanvasElement, CardElement, IconElement } from "../src/lib/canvas-elements.ts"; import { CanvasElement, CardElement, IconElement } from "../src/lib/canvas-elements.ts";
import { resizeFromPointer } from "../src/lib/canvas-geometry.ts"; import { resizeFromPointer } from "../src/lib/canvas-geometry.ts";
// 每次返回一个新文本节点,避免样式/分支变更在不同测试之间共享可变实例。
function seeded() { return { ...emptyCanvas(), nodes: [makeCanvasNode("text", { x: 100, y: 200 })] }; } function seeded() { return { ...emptyCanvas(), nodes: [makeCanvasNode("text", { x: 100, y: 200 })] }; }
// 从同一父节点分别创建 text/script/image,检查连线端口、图片源、快照往返,再用一次 replace 历史整体撤销。
test("branch type selection creates the chosen text, script or uploaded image and keeps the link in one undo step", () => { test("branch type selection creates the chosen text, script or uploaded image and keeps the link in one undo step", () => {
for (const kind of ["text", "script", "image"]) { for (const kind of ["text", "script", "image"]) {
const original = seeded(); const original = seeded();
@@ -22,6 +27,7 @@ test("branch type selection creates the chosen text, script or uploaded image an
} }
}); });
// 连线释放在任意画布坐标时,新 script 直接落在该 release 点,并只增加一条边。
test("a connection released on the canvas creates the chosen node at the release point", () => { test("a connection released on the canvas creates the chosen node at the release point", () => {
const original = seeded(); const original = seeded();
const at = { x: -300, y: 700 }; const at = { x: -300, y: 700 };
@@ -30,6 +36,7 @@ test("a connection released on the canvas creates the chosen node at the release
assert.equal(result.edges.length, 1); assert.equal(result.edges.length, 1);
}); });
// 颜色/背景/字体/行高等样式与字号可保存和遍历历史;部分样式清除只改目标键,保留未涉及颜色。
test("all text styles and font size survive serialization, undo and redo", () => { test("all text styles and font size survive serialization, undo and redo", () => {
const original = seeded(); const original = seeded();
const style = { color: "#123456", background: "#fedcba", bold: true, italic: true, underline: true, align: "right", fontFamily: "serif", lineHeight: 1.8 }; const style = { color: "#123456", background: "#fedcba", bold: true, italic: true, underline: true, align: "right", fontFamily: "serif", lineHeight: 1.8 };
@@ -45,6 +52,7 @@ test("all text styles and font size survive serialization, undo and redo", () =>
assert.equal(decodeCanvas(JSON.stringify(cleared.present)).nodes[0].data.textStyle.bold, false); assert.equal(decodeCanvas(JSON.stringify(cleared.present)).nodes[0].data.textStyle.bold, false);
}); });
// 逐个非法样式/类型和可执行样式片段均拒绝;非法 icon ID 也不能通过旧快照恢复。
test("unsafe CSS, unknown styles and invalid icon identifiers cannot enter a restored canvas", () => { test("unsafe CSS, unknown styles and invalid icon identifiers cannot enter a restored canvas", () => {
for (const style of [{ color: "url(https://example.com)" }, { background: "red" }, { fontFamily: "custom" }, { lineHeight: Infinity }, { bold: "true" }, { align: "justify" }, { align: ["left"] }, { fontFamily: ["sans"] }, { position: "fixed" }, []]) { for (const style of [{ color: "url(https://example.com)" }, { background: "red" }, { fontFamily: "custom" }, { lineHeight: Infinity }, { bold: "true" }, { align: "justify" }, { align: ["left"] }, { fontFamily: ["sans"] }, { position: "fixed" }, []]) {
const doc = seeded(); doc.nodes[0].data.textStyle = style; const doc = seeded(); doc.nodes[0].data.textStyle = style;
@@ -55,6 +63,7 @@ test("unsafe CSS, unknown styles and invalid icon identifiers cannot enter a res
assert.throws(() => decodeCanvas(JSON.stringify({ ...emptyCanvas(), nodes: [icon] })), /图标/); assert.throws(() => decodeCanvas(JSON.stringify({ ...emptyCanvas(), nodes: [icon] })), /图标/);
}); });
// IconElement 继承通用变换而非 CardElement,ID、颜色、旋转可恢复;等比缩放有最小尺寸且不能分支。
test("icon elements inherit transforms without card branching, preserve their identity and resize proportionally", () => { test("icon elements inherit transforms without card branching, preserve their identity and resize proportionally", () => {
const element = new IconElement({ x: 10, y: 20 }); const element = new IconElement({ x: 10, y: 20 });
assert(element instanceof CanvasElement); assert(element instanceof CanvasElement);
+18
View File
@@ -1,12 +1,20 @@
/**
* 旧 CanvasSync 完整快照自动保存及旧加载/草稿恢复测试。正在发送的请求串行,排队文档只保留最新份。
* 显式 retry 可以发送最新文档,与 headless 冻结请求的未知结果查询/原字节重试是不同契约,不可相互替代。
*/
import { test } from "node:test"; import { test } from "node:test";
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { CanvasSync, canvasPath, canvasPayload, readLocalRecovery } from "../src/lib/canvas-sync.ts"; import { CanvasSync, canvasPath, canvasPayload, readLocalRecovery } from "../src/lib/canvas-sync.ts";
import { emptyCanvas, makeCanvasNode, canvasReducer } from "../src/lib/canvas.ts"; import { emptyCanvas, makeCanvasNode, canvasReducer } from "../src/lib/canvas.ts";
import { loadBoardCanvas } from "../src/lib/canvas-loader.ts"; import { loadBoardCanvas } from "../src/lib/canvas-loader.ts";
// 返回固定 text ID 的新快照,便于只改变内容而不被随机 ID 差异触发多余保存。
function document(text = "") { const doc = emptyCanvas(); const node = makeCanvasNode("text", { x: 0, y: 0 }); node.id = "text"; node.data.text = text; doc.nodes.push(node); return doc; } function document(text = "") { const doc = emptyCanvas(); const node = makeCanvasNode("text", { x: 0, y: 0 }); node.id = "text"; node.data.text = text; doc.nodes.push(node); return doc; }
// 手动完成首个请求以检查在途期间版本和队列合并,reject 也供故障场景使用。
function deferred() { let resolve, reject; const promise = new Promise((yes, no) => { resolve = yes; reject = no; }); return { promise, resolve, reject }; } function deferred() { let resolve, reject; const promise = new Promise((yes, no) => { resolve = yes; reject = no; }); return { promise, resolve, reject }; }
// setImmediate 让保存泵进入 request,再添加后续文档,避免只测同一同步调用窗口。
const tick = () => new Promise((resolve) => setImmediate(resolve)); const tick = () => new Promise((resolve) => setImmediate(resolve));
// A 请求挂起时排队 B/C 不并发发送;A ACK=2 后只发送 C,并使用连续 If-Match 1→2。
test("serialized autosaves use successive versions and coalesce queued documents", async () => { test("serialized autosaves use successive versions and coalesce queued documents", async () => {
const calls = [], first = deferred(); const calls = [], first = deferred();
const writer = new CanvasSync(async (path, options) => { const writer = new CanvasSync(async (path, options) => {
@@ -23,6 +31,7 @@ test("serialized autosaves use successive versions and coalesce queued documents
assert.equal(JSON.parse(calls[1].body).document.nodes[0].data.text, "C"); assert.equal(writer.version, 3); assert.equal(JSON.parse(calls[1].body).document.nodes[0].data.text, "C"); assert.equal(writer.version, 3);
}); });
// 首次 version_conflict 停止自动写,后续 B 不再发送,旧服务版本和错误保留。
test("version conflicts stop automatic writes and preserve the latest pending content", async () => { test("version conflicts stop automatic writes and preserve the latest pending content", async () => {
const calls = []; const calls = [];
const writer = new CanvasSync(async (_path, options) => { calls.push(options); throw new Error("version_conflict"); }, "canvas", { version: 7, document: emptyCanvas() }, () => {}); const writer = new CanvasSync(async (_path, options) => { calls.push(options); throw new Error("version_conflict"); }, "canvas", { version: 7, document: emptyCanvas() }, () => {});
@@ -30,6 +39,7 @@ test("version conflicts stop automatic writes and preserve the latest pending co
assert.equal(calls.length, 1); assert.equal(writer.error, "version_conflict"); assert.equal(writer.version, 7); assert.equal(calls.length, 1); assert.equal(writer.error, "version_conflict"); assert.equal(writer.version, 7);
}); });
// 故障后手动 retry 发送当前最新 B,成功清错误;没有承诺保留最初 A 的冻结字节。
test("explicit retry sends the newest document after a failed write", async () => { test("explicit retry sends the newest document after a failed write", async () => {
const calls = []; const calls = [];
const writer = new CanvasSync(async (_path, options) => { calls.push(options); if (calls.length === 1) throw new Error("upstream_unavailable"); return { version: 2 }; }, "canvas", { version: 1, document: emptyCanvas() }, () => {}); const writer = new CanvasSync(async (_path, options) => { calls.push(options); if (calls.length === 1) throw new Error("upstream_unavailable"); return { version: 2 }; }, "canvas", { version: 1, document: emptyCanvas() }, () => {});
@@ -37,6 +47,7 @@ test("explicit retry sends the newest document after a failed write", async () =
assert.equal(calls.length, 2); assert.equal(JSON.parse(calls[1].body).document.nodes[0].data.text, "B"); assert.equal(writer.error, ""); assert.equal(calls.length, 2); assert.equal(JSON.parse(calls[1].body).document.nodes[0].data.text, "B"); assert.equal(writer.error, "");
}); });
// selected/measured 的变更不产生保存,payload 去 UI 字段;真正内容变化才调用一次接口。
test("selection and measured dimensions do not trigger redundant writes or leak UI fields", async () => { test("selection and measured dimensions do not trigger redundant writes or leak UI fields", async () => {
const initial = document("keep"); let calls = 0; const initial = document("keep"); let calls = 0;
const writer = new CanvasSync(async () => { calls++; return { version: 2 }; }, "canvas", { version: 1, document: initial }, () => {}); const writer = new CanvasSync(async () => { calls++; return { version: 2 }; }, "canvas", { version: 1, document: initial }, () => {});
@@ -48,6 +59,7 @@ test("selection and measured dimensions do not trigger redundant writes or leak
assert.equal(calls, 1); assert.equal(calls, 1);
}); });
// workspace/board 的 ID 与 slug 四组合都得到 canonical board 身份及保存快照,路径仍按传入引用构造。
test("all ID/slug combinations load the same canonical board and snapshot", async () => { test("all ID/slug combinations load the same canonical board and snapshot", async () => {
for (const workspace of ["space-id", "creative-space"]) for (const board of ["board-id", "my-board"]) { for (const workspace of ["space-id", "creative-space"]) for (const board of ["board-id", "my-board"]) {
const calls = []; const calls = [];
@@ -57,11 +69,13 @@ test("all ID/slug combinations load the same canonical board and snapshot", asyn
} }
}); });
// 快照读取失败或 board 身份不符直接拒绝,不以空文档允许用户覆盖远端。
test("failed snapshot loads and mismatched boards prevent editing rather than creating an empty replacement", async () => { test("failed snapshot loads and mismatched boards prevent editing rather than creating an empty replacement", async () => {
await assert.rejects(loadBoardCanvas(async (path) => { if (path.endsWith("/canvas")) throw new Error("upstream_unavailable"); return { id: "board", workspace_id: "space", kind: "board" }; }, "space", "board"), /upstream/); await assert.rejects(loadBoardCanvas(async (path) => { if (path.endsWith("/canvas")) throw new Error("upstream_unavailable"); return { id: "board", workspace_id: "space", kind: "board" }; }, "space", "board"), /upstream/);
await assert.rejects(loadBoardCanvas(async (path) => path.endsWith("/canvas") ? { version: 1, document: emptyCanvas() } : { id: "other", workspace_id: "space", kind: "board" }, "space", "board"), /invalid_reference/); await assert.rejects(loadBoardCanvas(async (path) => path.endsWith("/canvas") ? { version: 1, document: emptyCanvas() } : { id: "other", workspace_id: "space", kind: "board" }, "space", "board"), /invalid_reference/);
}); });
// dirty 及旧草稿可提供显式恢复候选;clean 备份或与远端相同内容返回 null,不能静默覆盖服务。
test("dirty local recovery and legacy drafts are offered for explicit migration; clean backups never replace server data", () => { test("dirty local recovery and legacy drafts are offered for explicit migration; clean backups never replace server data", () => {
const remote = { version: 3, document: document("server") }; const remote = { version: 3, document: document("server") };
const local = document("local"); const local = document("local");
@@ -72,6 +86,7 @@ test("dirty local recovery and legacy drafts are offered for explicit migration;
assert.equal(readLocalRecovery(storage({ "key:recovery": JSON.stringify({ document: remote.document, dirty: true }) }), "key", remote), null); assert.equal(readLocalRecovery(storage({ "key:recovery": JSON.stringify({ document: remote.document, dirty: true }) }), "key", remote), null);
}); });
// 指纹按内容规范化键序,textStyle 属性顺序不同不触发保存,也不被当作未保存草稿差异。
test("canonical fingerprints ignore server JSON key ordering", async () => { test("canonical fingerprints ignore server JSON key ordering", async () => {
const remote = document("same"), local = document("same"); const remote = document("same"), local = document("same");
remote.nodes[0].data.textStyle = { bold: true, color: "#123456" }; remote.nodes[0].data.textStyle = { bold: true, color: "#123456" };
@@ -85,6 +100,7 @@ test("canonical fingerprints ignore server JSON key ordering", async () => {
}); });
// 先挂起两种读取再检查 started=2,metadata/snapshot 必须并发启动。
test("board metadata and snapshot begin concurrently without a request waterfall", async () => { test("board metadata and snapshot begin concurrently without a request waterfall", async () => {
const started = []; const releases = []; const started = []; const releases = [];
const loading = loadBoardCanvas((path) => { started.push(path); return new Promise((resolve) => releases.push(() => resolve(path.endsWith("/canvas") ? { version: 1, document: emptyCanvas() } : { id: "board", workspace_id: "space", kind: "board" }))); }, "space", "board"); const loading = loadBoardCanvas((path) => { started.push(path); return new Promise((resolve) => releases.push(() => resolve(path.endsWith("/canvas") ? { version: 1, document: emptyCanvas() } : { id: "board", workspace_id: "space", kind: "board" }))); }, "space", "board");
@@ -92,6 +108,7 @@ test("board metadata and snapshot begin concurrently without a request waterfall
}); });
// 微小 viewport 浮点误差保持同一 state,正常 viewport 也不单独保存;随后内容变化才写一次。
test("viewport restoration roundoff does not create an autosave", async () => { test("viewport restoration roundoff does not create an autosave", async () => {
const doc=document("keep");doc.viewport={x:132.834729193,y:-73.098456241,zoom:0.637185392}; const doc=document("keep");doc.viewport={x:132.834729193,y:-73.098456241,zoom:0.637185392};
const state={past:[],present:doc,future:[]}; const state={past:[],present:doc,future:[]};
@@ -104,6 +121,7 @@ test("viewport restoration roundoff does not create an autosave", async () => {
}); });
// pendingUpload/Blob 节点和触边只在本地可编辑;传输排除它们,确认后的新 src、最新坐标和连线才一起进入快照。
test("editable local uploads and touching edges never enter transport snapshots",async()=>{ test("editable local uploads and touching edges never enter transport snapshots",async()=>{
const pending={...makeCanvasNode("image",{x:30,y:40},"blob:local-file"),pendingUpload:"upload-one"};pending.id="image-local"; const pending={...makeCanvasNode("image",{x:30,y:40},"blob:local-file"),pendingUpload:"upload-one"};pending.id="image-local";
const initial=emptyCanvas(),calls=[]; const initial=emptyCanvas(),calls=[];
+35
View File
@@ -1,3 +1,8 @@
/**
* 旧画布 JSDOM 回归入口:临时 esbuild 打包实际 React 组件,再注入 DOM/布局/网络替身执行场景。
* 运行会编译临时 fixture;不启动浏览器或开发服务器。模拟尺寸、命中和指针捕获不能替代浏览器布局/手势验证。
* CANVAS_UI_FEEDBACK_ONLY 仅跑反馈;ROTATION_BASELINE 对打包输入移除旧点击阻止逻辑,用于复现旋转取消。
*/
// Isolated DOM regression check; no browser or dev server is used. // Isolated DOM regression check; no browser or dev server is used.
// npm install --prefix /tmp/canvas-ui-tools --ignore-scripts esbuild jsdom // npm install --prefix /tmp/canvas-ui-tools --ignore-scripts esbuild jsdom
// CANVAS_UI_TOOLS=/tmp/canvas-ui-tools node tests/canvas-ui-check.mjs // CANVAS_UI_TOOLS=/tmp/canvas-ui-tools node tests/canvas-ui-check.mjs
@@ -7,37 +12,46 @@ import { mkdtemp, readFile, rm } from "node:fs/promises";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import { resolve } from "node:path"; import { resolve } from "node:path";
// 测试工具从 CANVAS_UI_TOOLS 指定目录解析,业务 React/组件仍来自当前仓库依赖。
const tools = createRequire(resolve(process.env.CANVAS_UI_TOOLS || "/tmp/canvas-ui-tools", "package.json")); const tools = createRequire(resolve(process.env.CANVAS_UI_TOOLS || "/tmp/canvas-ui-tools", "package.json"));
const { JSDOM } = tools("jsdom"); const { JSDOM } = tools("jsdom");
const { build, stop } = tools("esbuild"); const { build, stop } = tools("esbuild");
// 独立 DOM 和 localhost origin 提供 localStorage 等环境,不请求 localhost 服务。
const dom = new JSDOM('<!doctype html><html><body><div id="app"></div></body></html>', { url: "http://localhost", pretendToBeVisual: true }); const dom = new JSDOM('<!doctype html><html><body><div id="app"></div></body></html>', { url: "http://localhost", pretendToBeVisual: true });
const { window } = dom; const { window } = dom;
// React's async act creates MessageChannels; track and close test-owned ports. // React's async act creates MessageChannels; track and close test-owned ports.
const NativeMessageChannel = globalThis.MessageChannel; const NativeMessageChannel = globalThis.MessageChannel;
const channels = []; const channels = [];
globalThis.MessageChannel = class extends NativeMessageChannel { constructor() { super(); channels.push(this); } }; globalThis.MessageChannel = class extends NativeMessageChannel { constructor() { super(); channels.push(this); } };
// 把组件依赖的 DOM API 暴露到 Node 全局;这是独立测试进程,finally 不逐项恢复这些全局。
for (const key of ["window", "document", "HTMLElement", "Element", "Node", "SVGElement", "Event", "CustomEvent", "MouseEvent", "KeyboardEvent", "MutationObserver", "localStorage", "getComputedStyle"]) Object.defineProperty(globalThis, key, { value: key === "window" ? window : typeof window[key] === "function" && key === "getComputedStyle" ? window[key].bind(window) : window[key], configurable: true }); for (const key of ["window", "document", "HTMLElement", "Element", "Node", "SVGElement", "Event", "CustomEvent", "MouseEvent", "KeyboardEvent", "MutationObserver", "localStorage", "getComputedStyle"]) Object.defineProperty(globalThis, key, { value: key === "window" ? window : typeof window[key] === "function" && key === "getComputedStyle" ? window[key].bind(window) : window[key], configurable: true });
Object.defineProperty(globalThis, "navigator", { value: window.navigator, configurable: true }); Object.defineProperty(globalThis, "navigator", { value: window.navigator, configurable: true });
globalThis.requestAnimationFrame = window.requestAnimationFrame.bind(window); globalThis.requestAnimationFrame = window.requestAnimationFrame.bind(window);
globalThis.cancelAnimationFrame = window.cancelAnimationFrame.bind(window); globalThis.cancelAnimationFrame = window.cancelAnimationFrame.bind(window);
// 媒体查询、矩阵和 React act 环境使用最小替身,仅满足组件读取的字段。
window.matchMedia = () => ({ matches: false, addListener() {}, removeListener() {}, addEventListener() {}, removeEventListener() {} }); window.matchMedia = () => ({ matches: false, addListener() {}, removeListener() {}, addEventListener() {}, removeEventListener() {} });
globalThis.IS_REACT_ACT_ENVIRONMENT = true; globalThis.IS_REACT_ACT_ENVIRONMENT = true;
globalThis.DOMMatrixReadOnly = window.DOMMatrixReadOnly = class { m22 = 1; }; globalThis.DOMMatrixReadOnly = window.DOMMatrixReadOnly = class { m22 = 1; };
// 把 MouseEvent 扩为最小 pointer 事件并固定鼠标类型;不是原生多指输入实现。
class PointerEvent extends window.MouseEvent { constructor(type, init = {}) { super(type, init); this.pointerId = init.pointerId || 1; this.isPrimary = true; this.pointerType = "mouse"; } } class PointerEvent extends window.MouseEvent { constructor(type, init = {}) { super(type, init); this.pointerId = init.pointerId || 1; this.isPrimary = true; this.pointerType = "mouse"; } }
globalThis.PointerEvent = window.PointerEvent = PointerEvent; globalThis.PointerEvent = window.PointerEvent = PointerEvent;
// WeakMap 记录元素持有的 pointerId,仅模拟捕获 API 状态,不会自动重定向后续事件。
const captures = new WeakMap(); const captures = new WeakMap();
window.HTMLElement.prototype.setPointerCapture = function (id) { captures.set(this, id); }; window.HTMLElement.prototype.setPointerCapture = function (id) { captures.set(this, id); };
window.HTMLElement.prototype.hasPointerCapture = function (id) { return captures.get(this) === id; }; window.HTMLElement.prototype.hasPointerCapture = function (id) { return captures.get(this) === id; };
window.HTMLElement.prototype.releasePointerCapture = function () { captures.delete(this); }; window.HTMLElement.prototype.releasePointerCapture = function () { captures.delete(this); };
// 优先用内联宽高,其余给确定默认值;所有矩形起点为零,不进行 CSS 排版计算。
const width = (element) => parseFloat(element.style.width) || (element.className?.includes?.("react-flow__node") ? 260 : 1200); const width = (element) => parseFloat(element.style.width) || (element.className?.includes?.("react-flow__node") ? 260 : 1200);
const height = (element) => parseFloat(element.style.height) || (element.getAttribute("aria-label") === "图标列表" ? 192 : element.className?.includes?.("react-flow__node") ? 200 : 800); const height = (element) => parseFloat(element.style.height) || (element.getAttribute("aria-label") === "图标列表" ? 192 : element.className?.includes?.("react-flow__node") ? 200 : 800);
for (const [key, get] of [["offsetWidth", width], ["offsetHeight", height], ["clientWidth", width], ["clientHeight", height]]) Object.defineProperty(window.HTMLElement.prototype, key, { get() { return get(this); } }); for (const [key, get] of [["offsetWidth", width], ["offsetHeight", height], ["clientWidth", width], ["clientHeight", height]]) Object.defineProperty(window.HTMLElement.prototype, key, { get() { return get(this); } });
window.HTMLElement.prototype.getBoundingClientRect = function () { return { x: 0, y: 0, left: 0, top: 0, width: width(this), height: height(this), right: width(this), bottom: height(this) }; }; window.HTMLElement.prototype.getBoundingClientRect = function () { return { x: 0, y: 0, left: 0, top: 0, width: width(this), height: height(this), right: width(this), bottom: height(this) }; };
// observe 立即同步回调尺寸,unobserve/disconnect 无行为;不验证真实观察器调度顺序。
globalThis.ResizeObserver = window.ResizeObserver = class { globalThis.ResizeObserver = window.ResizeObserver = class {
constructor(callback) { this.callback = callback; } constructor(callback) { this.callback = callback; }
observe(element) { this.callback([{ target: element, contentRect: element.getBoundingClientRect() }]); } observe(element) { this.callback([{ target: element, contentRect: element.getBoundingClientRect() }]); }
unobserve() {} disconnect() {} unobserve() {} disconnect() {}
}; };
// 场景可替换 Foundry 请求处理器;图标请求记录后从本地 public 读 JSON,拒绝其他网络目标。
let apiMock = null; let apiMock = null;
const requests = []; const requests = [];
globalThis.fetch = async (url, options) => { globalThis.fetch = async (url, options) => {
@@ -47,26 +61,33 @@ globalThis.fetch = async (url, options) => {
if (options?.signal?.aborted) throw new DOMException("Aborted", "AbortError"); if (options?.signal?.aborted) throw new DOMException("Aborted", "AbortError");
return new Response(await readFile(resolve("public", String(url).slice(1))), { headers: { "Content-Type": "application/json" } }); return new Response(await readFile(resolve("public", String(url).slice(1))), { headers: { "Content-Type": "application/json" } });
}; };
// 临时目录只存打包 fixture,finally 删除;不把生成文件写入项目源码。
const folder = await mkdtemp(resolve(tmpdir(), "endoleap-canvas-ui-")); const folder = await mkdtemp(resolve(tmpdir(), "endoleap-canvas-ui-"));
try { try {
const outfile = resolve(folder, "fixture.cjs"); const outfile = resolve(folder, "fixture.cjs");
// 打包实际组件和 React 为 CJS,CSS 置空;因此本测试不能证明最终样式或 Next 路由行为。
await build({ await build({
stdin: { contents: 'export { default as GraphCanvas } from "./src/components/canvas/graph-canvas"; export { AppFeedback } from "./src/components/ui/app-feedback"; export { IconButton } from "./src/components/ui/icon-button"; export { notify, dismissNotification } from "./src/lib/notifications"; export { BoardDirectory } from "./src/components/workspace/board-directory"; export { useCanvasTree } from "./src/components/canvas/use-canvas-tree"; export { useCanvasAI } from "./src/components/canvas/use-canvas-ai"; export { IconPicker } from "./src/components/canvas/icon-picker"; export { makeCanvasNode, emptyCanvas } from "./src/lib/canvas"; export { createRoot } from "react-dom/client"; export { act, createElement } from "react";', resolveDir: process.cwd(), loader: "tsx" }, stdin: { contents: 'export { default as GraphCanvas } from "./src/components/canvas/graph-canvas"; export { AppFeedback } from "./src/components/ui/app-feedback"; export { IconButton } from "./src/components/ui/icon-button"; export { notify, dismissNotification } from "./src/lib/notifications"; export { BoardDirectory } from "./src/components/workspace/board-directory"; export { useCanvasTree } from "./src/components/canvas/use-canvas-tree"; export { useCanvasAI } from "./src/components/canvas/use-canvas-ai"; export { IconPicker } from "./src/components/canvas/icon-picker"; export { makeCanvasNode, emptyCanvas } from "./src/lib/canvas"; export { createRoot } from "react-dom/client"; export { act, createElement } from "react";', resolveDir: process.cwd(), loader: "tsx" },
outfile, bundle: true, platform: "node", format: "cjs", jsx: "automatic", logLevel: "silent", nodePaths: [resolve("node_modules")], outfile, bundle: true, platform: "node", format: "cjs", jsx: "automatic", logLevel: "silent", nodePaths: [resolve("node_modules")],
loader: { ".css": "empty" }, loader: { ".css": "empty" },
plugins: [{ name: "dynamic-component", setup(build) { plugins: [{ name: "dynamic-component", setup(build) {
// Next Link 替换为 a,dynamic 替换为 lazy/Suspense;这些模块不经过实际 Next 运行时。
build.onResolve({ filter: /^next\/link$/ }, () => ({ path: "link", namespace: "fixture" })); build.onResolve({ filter: /^next\/link$/ }, () => ({ path: "link", namespace: "fixture" }));
build.onResolve({ filter: /^next\/dynamic$/ }, () => ({ path: "dynamic", namespace: "fixture" })); build.onResolve({ filter: /^next\/dynamic$/ }, () => ({ path: "dynamic", namespace: "fixture" }));
build.onLoad({ filter: /.*/, namespace: "fixture" }, ({ path }) => ({ contents: path === "link" ? 'import React from "react"; export default function Link(props) { return React.createElement("a",props); }' : 'import React from "react"; export default function dynamic(loader) { const Component = React.lazy(loader); return function Dynamic(props) { return React.createElement(React.Suspense, {fallback:null}, React.createElement(Component,props)); }; }', resolveDir: process.cwd() })); build.onLoad({ filter: /.*/, namespace: "fixture" }, ({ path }) => ({ contents: path === "link" ? 'import React from "react"; export default function Link(props) { return React.createElement("a",props); }' : 'import React from "react"; export default function dynamic(loader) { const Component = React.lazy(loader); return function Dynamic(props) { return React.createElement(React.Suspense, {fallback:null}, React.createElement(Component,props)); }; }', resolveDir: process.cwd() }));
// 只修改本次编译读入的 toolbar 文本来复现旧 portal 冒泡问题,不改磁盘源码。
if (process.env.ROTATION_BASELINE === "1") build.onLoad({ filter: /node-style-toolbar\.tsx$/ }, async ({ path }) => ({ contents: (await readFile(path, "utf8")).replace('onClick={(event) => event.stopPropagation()}', ''), loader: "tsx" })); if (process.env.ROTATION_BASELINE === "1") build.onLoad({ filter: /node-style-toolbar\.tsx$/ }, async ({ path }) => ({ contents: (await readFile(path, "utf8")).replace('onClick={(event) => event.stopPropagation()}', ''), loader: "tsx" }));
} }], } }],
}); });
const { GraphCanvas, AppFeedback, IconButton, notify, dismissNotification, BoardDirectory, useCanvasAI, useCanvasTree, IconPicker, makeCanvasNode, emptyCanvas, act, createElement: h, createRoot } = createRequire(import.meta.url)(outfile); const { GraphCanvas, AppFeedback, IconButton, notify, dismissNotification, BoardDirectory, useCanvasAI, useCanvasTree, IconPicker, makeCanvasNode, emptyCanvas, act, createElement: h, createRoot } = createRequire(import.meta.url)(outfile);
// 共享 React root 供各场景替换组件;tick 让 effect 推进,event 在 act 内选择事件类型并派发。
const root = createRoot(document.querySelector("#app")); const root = createRoot(document.querySelector("#app"));
const tick = () => new Promise((resolve) => setTimeout(resolve, 30)); const tick = () => new Promise((resolve) => setTimeout(resolve, 30));
const event = async (target, type, init = {}) => { assert.ok(target, `Missing target for ${type}`); await act(async () => { const Type = type.startsWith("pointer") ? PointerEvent : type.startsWith("key") ? window.KeyboardEvent : window.MouseEvent; target.dispatchEvent(new Type(type, { bubbles: true, cancelable: true, view: window, ...init })); await tick(); }); }; const event = async (target, type, init = {}) => { assert.ok(target, `Missing target for ${type}`); await act(async () => { const Type = type.startsWith("pointer") ? PointerEvent : type.startsWith("key") ? window.KeyboardEvent : window.MouseEvent; target.dispatchEvent(new Type(type, { bubbles: true, cancelable: true, view: window, ...init })); await tick(); }); };
// 非 feedback-only 才执行目录、轮询、旧画布工具和三个子场景;反馈场景总是执行。
if (process.env.CANVAS_UI_FEEDBACK_ONLY !== "1") { if (process.env.CANVAS_UI_FEEDBACK_ONLY !== "1") {
{ {
// 目录 fixture 验证折叠、当前项祖先展开以及搜索保留完整父路径。
const boards = [{ id: "root", title: "主画布", workspace_id: "w", slug: "root-slug" }, { id: "child", title: "未命名分区", parent_board_id: "root", workspace_id: "w", slug: "child-slug" }, { id: "nested", title: "孙画布", parent_board_id: "child", workspace_id: "w" }]; const boards = [{ id: "root", title: "主画布", workspace_id: "w", slug: "root-slug" }, { id: "child", title: "未命名分区", parent_board_id: "root", workspace_id: "w", slug: "child-slug" }, { id: "nested", title: "孙画布", parent_board_id: "child", workspace_id: "w" }];
const renderDirectory = (active, query = "") => h(BoardDirectory, { boards, workspace: "space", active, query, demo: false }); const renderDirectory = (active, query = "") => h(BoardDirectory, { boards, workspace: "space", active, query, demo: false });
await act(async () => { root.render(renderDirectory("root-slug")); await tick(); }); await act(async () => { root.render(renderDirectory("root-slug")); await tick(); });
@@ -81,12 +102,14 @@ try {
console.log("PASS directory: nested children, collapse, active ancestor expansion and search paths"); console.log("PASS directory: nested children, collapse, active ancestor expansion and search paths");
} }
{ {
// 记录长定时任务并手动触发,检查空闲轮询退避和可见性暂停;finally 清理所有捕获计时器。
const originalSetTimeout = globalThis.setTimeout, originalClearTimeout = globalThis.clearTimeout; const originalSetTimeout = globalThis.setTimeout, originalClearTimeout = globalThis.clearTimeout;
const scheduled = []; const scheduled = [];
globalThis.setTimeout = (callback, delay, ...args) => { const handle = originalSetTimeout(callback, delay, ...args); if (delay >= 1000) scheduled.push({ callback, delay, handle }); return handle; }; globalThis.setTimeout = (callback, delay, ...args) => { const handle = originalSetTimeout(callback, delay, ...args); if (delay >= 1000) scheduled.push({ callback, delay, handle }); return handle; };
let calls = 0, controls, renders = 0; let calls = 0, controls, renders = 0;
apiMock = async () => { calls++; return Response.json({ runs: [] }); }; apiMock = async () => { calls++; return Response.json({ runs: [] }); };
const tree = { busy: false, writers: { current: new Map() } }; const tree = { busy: false, writers: { current: new Map() } };
// 挂载 AI hook 并计数渲染,检查空结果轮询既不重复请求也不造成额外渲染。
function Probe() { renders++; controls = useCanvasAI("w", "root", true, tree); return null; } function Probe() { renders++; controls = useCanvasAI("w", "root", true, tree); return null; }
try { try {
await act(async () => { root.render(h(Probe)); await tick(); }); await act(async () => { root.render(h(Probe)); await tick(); });
@@ -105,13 +128,16 @@ try {
console.log("PASS polling: single discovery, idle backoff, no empty rerenders, hidden pause and visible resume"); console.log("PASS polling: single discovery, idle backoff, no empty rerenders, hidden pause and visible resume");
} finally { globalThis.setTimeout = originalSetTimeout; for (const item of scheduled) originalClearTimeout(item.handle); apiMock = null; } } finally { globalThis.setTimeout = originalSetTimeout; for (const item of scheduled) originalClearTimeout(item.handle); apiMock = null; }
} }
// 播种 demo 旧快照、分区和边,随后以 DOM 事件检查工具栏和历史操作。
const node = makeCanvasNode("text", { x: 100, y: 100 }); node.id = "text-one"; const node = makeCanvasNode("text", { x: 100, y: 100 }); node.id = "text-one";
const frameNode = makeCanvasNode("frame", { x: 500, y: 100 }); frameNode.id = "frame-one"; frameNode.data.title = "分区 A"; const frameNode = makeCanvasNode("frame", { x: 500, y: 100 }); frameNode.id = "frame-one"; frameNode.data.title = "分区 A";
localStorage.setItem("endoleap_canvas_v1:w:b", JSON.stringify({ ...emptyCanvas(), nodes: [node, frameNode, { ...makeCanvasNode("text", { x: 900, y: 100 }), id: "text-two" }], edges: [{ id: "edge-test", source: "text-one", target: "text-two", sourceHandle: "right", targetHandle: "left" }] })); localStorage.setItem("endoleap_canvas_v1:w:b", JSON.stringify({ ...emptyCanvas(), nodes: [node, frameNode, { ...makeCanvasNode("text", { x: 900, y: 100 }), id: "text-two" }], edges: [{ id: "edge-test", source: "text-one", target: "text-two", sourceHandle: "right", targetHandle: "left" }] }));
await act(async () => { root.render(h(GraphCanvas, { board: { id: "b", workspace_id: "w", kind: "board", title: "test", version: 1 } })); await tick(); }); await act(async () => { root.render(h(GraphCanvas, { board: { id: "b", workspace_id: "w", kind: "board", title: "test", version: 1 } })); await tick(); });
await event(document.querySelector('[data-id="text-one"]'), "click"); await event(document.querySelector('[data-id="text-one"]'), "click");
// 每次重查旋转按钮,避免 portal 重渲染后持有旧引用。
const toggle = () => document.querySelector('[aria-label="旋转模式"]'); const toggle = () => document.querySelector('[aria-label="旋转模式"]');
await event(toggle(), "click"); await event(toggle(), "click");
// baseline 预期复现即时取消;正常路径要求旋转保持并可 Escape、undo/redo。
if (process.env.ROTATION_BASELINE === "1") { if (process.env.ROTATION_BASELINE === "1") {
assert.equal(toggle().getAttribute("aria-pressed"), "false", "Baseline should reproduce immediate cancellation"); assert.equal(toggle().getAttribute("aria-pressed"), "false", "Baseline should reproduce immediate cancellation");
console.log("PASS baseline: portal click cancels rotation before the fix"); console.log("PASS baseline: portal click cancels rotation before the fix");
@@ -131,6 +157,7 @@ try {
assert.equal(requests.length, 0, "Closed picker and text-only board must not load icon shapes"); assert.equal(requests.length, 0, "Closed picker and text-only board must not load icon shapes");
console.log("PASS rotation: portal click, pointer rotation, Escape, undo and redo"); console.log("PASS rotation: portal click, pointer rotation, Escape, undo and redo");
// 分区进出通过 mouse 拖动触发,分别检查松开前预览、归属徽标和状态提示。
const textNode = document.querySelector('.react-flow__node[data-id="text-one"]'); const textNode = document.querySelector('.react-flow__node[data-id="text-one"]');
await event(textNode, "mousedown", { clientX: 200, clientY: 200, button: 0, buttons: 1 }); await event(textNode, "mousedown", { clientX: 200, clientY: 200, button: 0, buttons: 1 });
await event(window, "mousemove", { clientX: 210, clientY: 200, buttons: 1 }); await event(window, "mousemove", { clientX: 210, clientY: 200, buttons: 1 });
@@ -149,6 +176,7 @@ try {
assert.ok(document.querySelector('[role="status"]').textContent.includes("已移出「分区 A」")); assert.ok(document.querySelector('[role="status"]').textContent.includes("已移出「分区 A」"));
console.log("PASS frames: actual drag previews, membership badge, count and join/leave announcements"); console.log("PASS frames: actual drag previews, membership badge, count and join/leave announcements");
// 分区名称模式支持鼠标与键盘选择及历史,八向缩放控制可由 Escape 退出。
const frameElement = document.querySelector('.react-flow__node[data-id="frame-one"]'); const frameElement = document.querySelector('.react-flow__node[data-id="frame-one"]');
await event(frameElement, "click"); await event(frameElement, "click");
const group = document.querySelector('[role="radiogroup"][aria-label="分区名称显示模式"]'); const group = document.querySelector('[role="radiogroup"][aria-label="分区名称显示模式"]');
@@ -170,6 +198,7 @@ try {
assert.equal(document.querySelectorAll('[data-resize-handle]').length, 0); assert.equal(document.querySelectorAll('[data-resize-handle]').length, 0);
console.log("PASS frames: icon segments, no member count, keyboard choice, undo/redo and eight resize handles"); console.log("PASS frames: icon segments, no member count, keyboard choice, undo/redo and eight resize handles");
// 连线编辑覆盖曲线点增删、箭头 marker、常驻动画和无执行事件时停止动画。
await event(document.querySelector('.react-flow__edge[data-id="edge-test"]'), "click"); await event(document.querySelector('.react-flow__edge[data-id="edge-test"]'), "click");
assert.ok(document.querySelector('[aria-label="连线编辑"]')); assert.ok(document.querySelector('[aria-label="连线编辑"]'));
await event(document.querySelector('[aria-label="添加曲线点"]'), "click"); await event(document.querySelector('[aria-label="添加曲线点"]'), "click");
@@ -186,6 +215,7 @@ try {
assert.equal(document.querySelector('[aria-label="曲线点 1"]'), null); assert.equal(document.querySelector('[aria-label="曲线点 1"]'), null);
console.log("PASS edges: select, add/remove curve knot, markers and always/execution-only animation"); console.log("PASS edges: select, add/remove curve knot, markers and always/execution-only animation");
// IconPicker 以确定容器高度测试窗口化 DOM、按需分片、共享 Tabler 缓存和分类后滚动复位。
let choice; let choice;
await act(async () => { root.render(h(IconPicker, { close() {}, choose(name) { choice = name; } })); await tick(); }); await act(async () => { root.render(h(IconPicker, { close() {}, choose(name) { choice = name; } })); await tick(); });
await act(async () => { await tick(); }); await act(async () => { await tick(); });
@@ -209,20 +239,25 @@ try {
assert.equal(grid.querySelector("button").dataset.index, "0", "Category change resets virtual window"); assert.equal(grid.querySelector("button").dataset.index, "0", "Category change resets virtual window");
console.log("PASS picker: bounded DOM, on-demand data, shared Tabler load and selection"); console.log("PASS picker: bounded DOM, on-demand data, shared Tabler load and selection");
} }
// 正常模式追加 V2 linked-board 场景;baseline 跳过它,但成员和上传场景仍会执行。
if (process.env.ROTATION_BASELINE !== "1") { if (process.env.ROTATION_BASELINE !== "1") {
let scenario; let scenario;
await act(async () => { scenario = await import("./canvas-v2-scenario.mjs"); await tick(); }); await act(async () => { scenario = await import("./canvas-v2-scenario.mjs"); await tick(); });
const { canvasV2Scenario } = scenario; const { canvasV2Scenario } = scenario;
await canvasV2Scenario({ root, act, h, GraphCanvas, window, makeCanvasNode, emptyCanvas, event, tick, mock: (handler) => { apiMock = handler; } }); await canvasV2Scenario({ root, act, h, GraphCanvas, window, makeCanvasNode, emptyCanvas, event, tick, mock: (handler) => { apiMock = handler; } });
} }
// 注入 hook 与 mock setter,成员场景可精确控制版本、能力和 ACK 竞态。
const { canvasMemberScenario } = await import("./canvas-member-scenario.mjs"); const { canvasMemberScenario } = await import("./canvas-member-scenario.mjs");
await canvasMemberScenario({ root, act, h, useCanvasTree, GraphCanvas, event, window, makeCanvasNode, emptyCanvas, tick, mock: (handler) => { apiMock = handler; } }); await canvasMemberScenario({ root, act, h, useCanvasTree, GraphCanvas, event, window, makeCanvasNode, emptyCanvas, tick, mock: (handler) => { apiMock = handler; } });
// 上传场景复用 DOM/root,以文件、Image 和 API 替身检查预览、编辑与清理。
const { canvasUploadScenario } = await import("./canvas-upload-scenario.mjs"); const { canvasUploadScenario } = await import("./canvas-upload-scenario.mjs");
await canvasUploadScenario({ root, act, h, GraphCanvas, window, emptyCanvas, event, tick, mock: (handler) => { apiMock = handler; } }); await canvasUploadScenario({ root, act, h, GraphCanvas, window, emptyCanvas, event, tick, mock: (handler) => { apiMock = handler; } });
} }
// 最后验证通用通知/tooltip 与 demo 画布提示,再卸载 root。
const { feedbackScenario } = await import("./feedback-scenario.mjs"); const { feedbackScenario } = await import("./feedback-scenario.mjs");
await feedbackScenario({ root, act, h, AppFeedback, IconButton, notify, dismissNotification, GraphCanvas, window, makeCanvasNode, emptyCanvas, event, tick }); await feedbackScenario({ root, act, h, AppFeedback, IconButton, notify, dismissNotification, GraphCanvas, window, makeCanvasNode, emptyCanvas, event, tick });
await act(async () => { root.unmount(); await tick(); }); await act(async () => { root.unmount(); await tick(); });
// 清理临时打包文件、JSDOM、esbuild 及 act 创建的 MessageChannel 端口,恢复原通道构造器。
} finally { } finally {
await rm(folder, { recursive: true, force: true }); window.close(); stop(); await rm(folder, { recursive: true, force: true }); window.close(); stop();
for (const channel of channels) { channel.port1.close(); channel.port2.close(); } for (const channel of channels) { channel.port1.close(); channel.port2.close(); }

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