174 Commits

Author SHA1 Message Date
codex-bot bf1669125d docs(canvas): record final delivery and manual merge readiness 2026-11-11 19:28:19 +08:00
codex-bot 14f7475296 test(canvas): close requirement matrix and preserve interleaved import history 2026-11-11 19:26:12 +08:00
codex-bot d3eef78431 test(canvas): verify crash cuts and pending image replacement lifetimes 2026-11-11 19:05:33 +08:00
codex-bot dcbbdad25d test(canvas): report canonical creation receipts before checking descendants 2026-11-11 18:52:03 +08:00
codex-bot 15ee91772d feat(canvas): dissolve owning canvases without deleting their contents 2026-11-11 18:41:26 +08:00
codex-bot b05058a955 fix(canvas): capture schema migration data before lossy serialization 2026-11-11 18:27:51 +08:00
codex-bot b34dc6089a test(canvas): verify retry deadlines across wall-clock shifts and recovery 2026-11-11 18:13:43 +08:00
codex-bot 24c4592060 feat(canvas): gate legacy editing and hand off explicit migration safely 2026-11-11 18:05:22 +08:00
codex-bot 1f98d8d61f docs(canvas): record migration coordinator service evidence and handoff boundary 2026-11-11 17:20:34 +08:00
codex-bot 26da326c18 feat(canvas): persist and reconcile explicit identity migration requests 2026-11-11 17:10:14 +08:00
codex-bot f1c3b63a32 feat(canvas): retain explicit migration eligibility at the entry boundary 2026-11-11 16:50:41 +08:00
codex-bot 9d6d0775a7 docs(canvas): clarify migration JSON preservation evidence 2026-11-11 16:50:22 +08:00
codex-bot 4756f1b869 fix(canvas): ship linked acceptance documents in the SDK package 2026-11-11 16:43:47 +08:00
codex-bot dbb33bfdd8 docs(canvas): close service audit with concurrency and rollback evidence 2026-11-11 16:43:31 +08:00
codex-bot d995cc1784 docs(canvas): record verified Foundry AI and resource integration 2026-11-11 16:20:01 +08:00
codex-bot 580a14f508 test(canvas): retain copy checkpoints without duplicating managed image bytes 2026-11-11 16:09:25 +08:00
codex-bot db6594f473 fix(canvas): let headless hosts configure asset display routes 2026-11-11 16:04:12 +08:00
codex-bot 0ced171305 test(canvas): drain cancellation events before next AI admission 2026-11-11 15:55:23 +08:00
codex-bot c07f8fbd82 test(canvas): drive real HTTP run polling with controlled timers 2026-11-11 15:50:08 +08:00
codex-bot 96fc37c319 fix(canvas): negotiate v2 for headless AI requests 2026-11-11 15:41:59 +08:00
codex-bot 3ea1a33747 test(canvas): trace AI receipt protocol headers 2026-11-11 15:34:35 +08:00
codex-bot 58f97eaf07 test(canvas): report unresolved AI admission recovery 2026-11-11 15:28:25 +08:00
codex-bot 465596fb7b Exercise runtime AI actions through the authenticated Foundry integration client 2026-11-11 15:11:41 +08:00
codex-bot e3a2335160 Share detached JSON provenance with copy checkpoints and finish step-four verification 2026-11-11 14:16:45 +08:00
codex-bot 2089b63e79 Slice detached element recovery encoding and audit progressive canvas 2026-11-11 04:53:07 +08:00
codex-bot 5eabca5056 Slice pending import encoding and reuse completed immutable plans 2026-11-11 04:29:11 +08:00
codex-bot 210bc14074 Budget growing history and import recovery encoding 2026-11-11 04:16:21 +08:00
codex-bot b4c40d3087 Yield recovery container encoding without changing durable cuts 2026-11-11 04:04:24 +08:00
codex-bot 57dab91a9c Skip empty native selection listener scans 2026-11-11 03:37:03 +08:00
codex-bot 0ce73a6854 Scope host UI focus and curve-budget fallback to their own lifetimes 2026-11-11 02:57:03 +08:00
codex-bot 680aea0aa7 Retain explicit canvas owners without disabling viewport culling 2026-11-11 02:21:57 +08:00
codex-bot 72a8e01114 Reuse validated immutable history ranges during resource retention 2026-11-11 01:56:43 +08:00
codex-bot 26d1688f2f Verify native input and snapshot isolation during progressive copy 2026-11-11 01:33:54 +08:00
codex-bot 4946e8025b Keep visible native editing owners within viewport culling 2026-11-11 01:24:53 +08:00
codex-bot 64ed8fe57a Separate cooperative copy planning from durable checkpoint cadence 2026-11-10 19:28:10 +08:00
codex-bot 8958d835b9 Persist versioned Blob recovery cuts with cooperative JSON serialization 2026-11-10 19:01:25 +08:00
codex-bot b051509dee Allow import feedback to probe larger batches within the existing time budget 2026-11-10 18:25:09 +08:00
codex-bot 2452fd6ae9 Schedule copy snapshot preparation by time and byte budgets 2026-11-10 18:11:25 +08:00
codex-bot afccb8c95c Measure actual canvas performance with an isolated production frontend 2026-11-10 18:01:02 +08:00
codex-bot b7fe214d97 Coalesce background Root notifications without delaying input updates 2026-11-10 17:56:00 +08:00
codex-bot 0abb7c96ba Replay pending changes in one private document draft 2026-11-10 17:45:34 +08:00
codex-bot b735667e00 Frame initial canvas loading around direct members 2026-11-10 17:10:49 +08:00
codex-bot 786453cce0 Compact reconciled document receipts through an explicit retention policy 2026-11-10 17:06:10 +08:00
codex-bot 69eb29343e Clarify immutable element sharing lifecycle 2026-11-10 16:14:09 +08:00
codex-bot a6092e61cd Share core-owned immutable elements and validation facts 2026-11-10 16:07:02 +08:00
codex-bot c8e5f6ac49 Revert "Reuse immutable JSON validation proofs without weakening publication checks"
This reverts commit 697716ee89.
2026-11-10 15:13:27 +08:00
codex-bot 697716ee89 Reuse immutable JSON validation proofs without weakening publication checks 2026-11-10 15:08:19 +08:00
codex-bot 104ef3942f Reuse owned table entry views during recovery encoding 2026-11-10 14:45:18 +08:00
codex-bot 6b3986f89f Limit connection paint node indexes to inspected endpoints 2026-11-10 14:34:36 +08:00
codex-bot aa72f9e484 Separate request Root durability barriers from complete storage flushes 2026-11-10 03:03:39 +08:00
codex-bot 3ae18b1670 Release discarded document generations from query method closures 2026-11-10 02:48:59 +08:00
codex-bot dc086a4774 Bound browser fixture protocol calls and capture heap diagnostics 2026-11-10 02:48:49 +08:00
codex-bot c087d32e84 Share resource history interpretation across working and acknowledged scans 2026-11-10 01:50:14 +08:00
codex-bot fb997f23a8 Stage transaction creations without copying full tables per command 2026-11-10 01:30:47 +08:00
codex-bot 198c7f9863 Reuse captured paint dependencies for unchanged projected nodes 2026-11-10 01:20:24 +08:00
codex-bot 2529a801b2 Adapt import commit batches from observed transaction duration 2026-11-10 01:10:05 +08:00
codex-bot 40f3c97bbd Reuse preallocated import identities and stable recovery document views 2026-11-10 00:59:12 +08:00
codex-bot 5590d55bb1 Chain delta journal checksums without rehashing every full recovery image 2026-11-10 00:29:09 +08:00
codex-bot d8bf6406c6 Batch node measurements and mount toolbars only while active 2026-11-10 00:03:14 +08:00
codex-bot da7d9d056f Reuse existing scene projections when inserting ordinary canvas nodes 2026-11-09 23:38:12 +08:00
codex-bot 0ce3abbe04 Stream native imports before full source verification with durable send seals 2026-11-09 23:24:53 +08:00
codex-bot f11ea36b48 Add proof-backed parser byte reads and preserve retryable source failures 2026-11-09 22:47:49 +08:00
codex-bot f0ee0736b7 Cancel gestures when their visible ancestor coordinate context changes 2026-11-09 22:25:22 +08:00
codex-bot bcc11e2e84 Fence session lifetimes and cancel owned previews on tool changes 2026-11-09 22:09:30 +08:00
codex-bot ed85546674 Patch fixed-layout scene projections from coalesced element changes 2026-11-09 21:58:10 +08:00
codex-bot 732c15f7bf Reuse canvas scenes across viewport and interaction metadata changes 2026-11-09 21:46:58 +08:00
codex-bot bb5bc9d8e9 Index runtime hook subscriptions and share scoped sync counts 2026-11-09 21:38:26 +08:00
codex-bot b833f0a2fc Scope mode and gesture subscriptions and isolate controller leases 2026-11-09 20:58:57 +08:00
codex-bot ccd61bfb90 Route element hooks by ID and support field selectors 2026-11-09 20:33:28 +08:00
codex-bot 70bc134436 Use bounded native SHA256 inside digest Workers 2026-11-09 20:16:39 +08:00
codex-bot fea813ac4f Advance browser storage future-version rejection to recovery 17 2026-11-09 20:16:19 +08:00
codex-bot be6a092c40 Stream exported document records with versioned recovery 2026-11-09 20:01:32 +08:00
codex-bot 7f0d8e7983 Report live mode state when toolbar keyboard checks fail 2026-11-09 19:41:15 +08:00
codex-bot 180f66b2af Yield native import scans by elapsed time and bounded work 2026-11-09 19:41:00 +08:00
codex-bot fd6c166f4b Respect native input ownership before canvas keyboard cancellation 2026-11-09 19:33:40 +08:00
codex-bot 9efe6e7c9f Bind rendered editing callbacks to captured element lifetimes 2026-11-09 19:26:00 +08:00
codex-bot 28c792d640 Keep unrelated renderers idle through local edits and acknowledgments 2026-11-09 19:09:12 +08:00
codex-bot 43c74a1485 Cache connection paint by immutable geometry inputs and measured anchors 2026-11-09 18:55:21 +08:00
codex-bot 6c4ab46413 Invalidate node paint geometry by declared inputs instead of record identity 2026-11-09 18:50:30 +08:00
codex-bot a9bc6e7d29 Reuse unchanged projection arrays and limit extra viewport retention 2026-11-09 18:45:57 +08:00
codex-bot 0813b8df53 Fence pointer capture ownership across node and edge gestures 2026-11-09 18:35:43 +08:00
codex-bot 2f385433be Track view-scoped overlay placements and retain portalled editors 2026-11-09 18:28:13 +08:00
codex-bot e6b9999202 Retain visible connection paint beyond endpoint bounds 2026-11-09 18:22:42 +08:00
codex-bot 73b132f71e Authorize and atomically adopt document image resources during import 2026-11-09 18:17:24 +08:00
codex-bot eafc5ee694 Fence asynchronous import preparation before synchronous publication 2026-11-09 18:11:18 +08:00
codex-bot 2adc22a5d9 Import exported documents with durable root and reference remapping 2026-11-09 18:07:56 +08:00
codex-bot 3f7a063630 Index exported document envelopes for seekable record decoding 2026-11-09 18:01:21 +08:00
codex-bot 78dfbbf4d7 Persist JSON array imports through recovery v14 and the canvas file picker 2026-11-09 17:57:23 +08:00
codex-bot c4dfaf50a5 Add seekable bounded JSON array import source 2026-11-09 17:50:35 +08:00
codex-bot 46fabafca8 Retain projected paint geometry and grid across view updates 2026-11-09 17:47:44 +08:00
codex-bot c6d77bdefd Index projected node paint overflow for conservative viewport retention 2026-11-09 17:44:20 +08:00
codex-bot 04a9b0c2b2 Coalesce session presentation invalidations through a disposable frame queue 2026-11-09 17:41:02 +08:00
codex-bot 075bb79443 Route canvas keyboard claims through text and gesture boundaries 2026-11-09 17:38:35 +08:00
codex-bot 575b5a9256 Expose React canvas hooks with scoped selectors and resource lifecycles 2026-11-09 17:34:56 +08:00
codex-bot d7b3321df1 Add a permission-checked cross-project image reuse picker 2026-11-09 16:49:06 +08:00
codex-bot 902e30a65b Deduplicate stored upload files and retain adoption bytes until their Root is durable 2026-11-09 16:31:31 +08:00
codex-bot a626e6c300 Adopt authorized shared assets with durable project bindings and recovery 2026-11-09 16:11:25 +08:00
codex-bot a294c6ca41 Verify asset metadata and original bytes with the real Foundry client 2026-11-09 15:54:56 +08:00
codex-bot 74c219f91d Persist shared asset bytes and project pins with Root commits 2026-11-09 15:54:05 +08:00
codex-bot 196ca4514e Expose portable scene bindings and an explicit React Flow peer adapter 2026-11-09 15:07:18 +08:00
codex-bot bceaf2eca6 Inject response loss for Created upload receipts as well as OK 2026-11-09 14:42:55 +08:00
codex-bot 3a71c9ac57 Admit durable scoped image uploads during partial model loading 2026-11-09 14:35:41 +08:00
codex-bot 5b35519562 Exercise live scoped imports and deep copies before an unread model tail 2026-11-09 14:04:54 +08:00
codex-bot 6ec6b12074 Use the exported Foundry transport in live integration consumer 2026-11-09 13:50:28 +08:00
codex-bot 3c5e67c03d Add live Foundry HTTP consumer for remote cross-repository integration 2026-11-09 13:44:31 +08:00
codex-bot a66933775e Allow durable imports and deep copies in certified loading scopes 2026-11-09 13:39:39 +08:00
codex-bot 945aaea504 Admit scoped structural edits with snapshot identity manifests and replay proofs 2026-11-09 04:40:03 +08:00
codex-bot 6c0a421f59 Synchronize certified canvas scopes while preserving the original loading snapshot 2026-11-09 04:02:22 +08:00
codex-bot cabf2ec667 Certify loaded canvas scopes and publish completeness with runtime queries 2026-11-09 03:42:08 +08:00
codex-bot ef08222915 Enable partial canvas editing and complete detached views in the project runtime 2026-11-09 03:27:47 +08:00
codex-bot fff6213714 Persist partial-load edits and hold synchronization until a durable full base 2026-11-09 03:00:21 +08:00
codex-bot 0a705f82ee Show dependency-closed model prefixes before full canvas loading 2026-11-09 02:33:02 +08:00
codex-bot a92ae72c8b Negotiate and decode snapshot-bound canonical model streams 2026-11-09 02:06:07 +08:00
codex-bot 0205f24904 Package portable headless runtime with independent Node and browser consumers 2026-11-09 01:53:53 +08:00
codex-bot 8219209284 Coordinate scoped model reads with bounded streaming state and identity fences 2026-11-09 01:26:01 +08:00
codex-bot 03c3a0dc66 Share scoped display leases and retain incremental resource views 2026-11-09 00:46:34 +08:00
codex-bot 5919eb0453 Resolve default image sizes atomically with creation history and recovery 2026-11-09 00:25:18 +08:00
codex-bot fa7dad44e7 Plan creation size enrichment across undo and redo baselines 2026-11-08 23:57:53 +08:00
codex-bot 3750648dea Decode image metadata in bounded workers and expose explicit ratio fitting 2026-11-08 23:45:24 +08:00
codex-bot 3ccb8dbc5c Inventory scoped files and atomically collect verified legacy orphans 2026-11-08 23:21:27 +08:00
codex-bot 4b594f85eb Retire terminal import sources with durable ownership and recovery v4 2026-11-08 22:55:08 +08:00
codex-bot bbe1e60496 Collect unreferenced local resource bytes atomically with recovery cuts 2026-11-08 22:26:06 +08:00
codex-bot 6387939510 Add component geometry protocols and display-space hit queries 2026-11-08 22:01:42 +08:00
codex-bot b57c3404c5 Index committed canvas relationships and reuse geometric projections 2026-11-08 21:27:54 +08:00
codex-bot b0afe8aa8c Check import backpressure after ready parser publication 2026-11-08 21:27:34 +08:00
codex-bot 9769f80cf5 Bound chunked source hashing with workspace worker pools 2026-11-08 20:44:29 +08:00
codex-bot 4035956607 Decode duplicate payloads on demand from indexed snapshots 2026-11-08 20:28:09 +08:00
codex-bot b5ad3e0cc9 Stream duplicate record conversion into progressive insertion 2026-11-08 20:13:07 +08:00
codex-bot ed3c3a2ab3 Resume durable copy preparation from saved planning cursors 2026-11-08 20:03:02 +08:00
codex-bot a51d189b8e Bind import recovery checkpoints to source content digests 2026-11-08 19:34:32 +08:00
codex-bot 627a043d92 Install canvas renderers through validated host extensions 2026-11-08 19:08:58 +08:00
codex-bot 361e636ce7 Preserve opaque canvas elements with safe renderer fallbacks 2026-11-08 18:45:10 +08:00
codex-bot 6ff34b67e7 Store recovery journal deltas between verified checkpoints 2026-11-08 18:27:59 +08:00
codex-bot e00ac017f1 Bound optimistic imports with persistence backpressure 2026-11-08 18:18:23 +08:00
codex-bot 60fad39847 Remap frame port references and commit import cycles atomically 2026-11-08 18:09:39 +08:00
codex-bot 1f89f9ebb5 Cull offscreen canvas nodes while preserving active editing 2026-11-08 17:56:54 +08:00
codex-bot 8a2b1e4bc7 perf(canvas): stabilize renderer bindings and retain unchanged projections 2026-11-08 17:10:00 +08:00
codex-bot 8a58d9785c perf(canvas): reuse immutable recovery encoding subtrees 2026-11-08 16:42:40 +08:00
codex-bot ff07b52a21 perf(canvas): batch progressive import parsing and publication 2026-11-08 16:29:39 +08:00
codex-bot bde841330a feat(canvas): recover admitted copy preparation from immutable snapshots 2026-11-08 16:17:52 +08:00
codex-bot 7ed0aa61cb feat(canvas): persist and execute deep duplicate tasks 2026-11-08 15:59:15 +08:00
codex-bot 379db2fe14 feat(canvas): schedule deep copy planning cooperatively 2026-11-08 15:47:57 +08:00
codex-bot ce02e43958 feat(canvas): expose native import progress and controls 2026-11-08 15:40:10 +08:00
codex-bot 72d6105e7c feat(canvas): persist native import jobs with atomic editor progress 2026-11-08 15:40:10 +08:00
codex-bot 160c16afb7 feat(canvas): add bounded resumable import coordination 2026-11-08 15:40:10 +08:00
codex-bot be94446f12 feat(canvas): define copy schemas and plan hierarchical duplicates 2026-11-08 14:37:23 +08:00
codex-bot 5296c953b1 feat(canvas): elide closed unsent lifetimes with durable local proofs 2026-11-08 14:24:39 +08:00
codex-bot 911931d265 feat(canvas): compact mutable batches with conditional wire projections 2026-11-08 04:40:05 +08:00
codex-bot 95a5cd5697 feat(canvas): batch mutable document operations with durable coverage 2026-11-08 04:29:56 +08:00
codex-bot 90650873e6 Consume audited history evidence across repeated conflict recovery 2026-11-08 04:09:47 +08:00
codex-bot 564e878844 Persist verifiable adoption history anchors and replay evidence 2026-11-08 03:53:53 +08:00
codex-bot 1994ff2576 Replay confirmed history traversals without repeating applied content 2026-11-08 03:41:51 +08:00
codex-bot d99b6951cf Recover user groups across nonprefix acknowledgments and discarded entry births 2026-11-08 03:35:58 +08:00
codex-bot 1b29b0138b Replan native history events across repeated traversal and redo invalidation 2026-11-08 03:30:00 +08:00
codex-bot 231f70d87d Persist compact history transition evidence with recovery and resource pins 2026-11-08 03:20:50 +08:00
codex-bot 343bfdecff Recover rejected history traversals with atomic stack replanning 2026-11-08 03:12:59 +08:00
codex-bot 78194e9f95 Retain acknowledged structural prefixes during conflict recovery 2026-11-08 03:04:39 +08:00
codex-bot f741a4830f Preserve visual units across scaled canvas and container moves 2026-11-08 02:54:36 +08:00
codex-bot d702240240 Replan structural edits with shared coordinates and lifecycle-safe history 2026-11-08 02:29:51 +08:00
codex-bot f9cb1ae120 Recover verified applied receipts against a fresh canonical base 2026-11-08 02:09:36 +08:00
codex-bot 89b6f4a253 Replan reviewed field conflicts with durable audit and conditional history 2026-11-08 01:56:21 +08:00
codex-bot a0417b4811 Add quiescent canonical recovery sessions without rewriting requests 2026-11-08 01:41:23 +08:00
codex-bot 0c7fae6e32 Reconcile AI output ownership without duplicating or rewinding elements 2026-11-08 01:26:33 +08:00
codex-bot 77fddb9f1a Verify strict identity coverage of AI rejection proofs 2026-11-08 01:05:41 +08:00
codex-bot 6243602402 Add durable AI cancellation and undoable output application 2026-11-08 01:03:05 +08:00
codex-bot d07bc2ef91 Persist AI admissions and reconcile canvas runs across navigation 2026-11-08 00:48:36 +08:00
codex-bot 69b52fd819 Preserve frozen AI admission requests and expose receipt lookup 2026-11-08 00:30:22 +08:00
codex-bot e2987de21a Connect canonical projects to persistent workspace runtimes and canvas views 2026-11-08 00:07:27 +08:00
codex-bot 8a76e53a9f Persist staged image uploads alongside document operations 2026-11-07 23:24:00 +08:00
codex-bot 8a19909716 Connect durable canvas runtime to canonical Foundry protocol 2026-11-07 22:53:00 +08:00
codex-bot 10374c3696 Add durable canvas runtime, scheduling and crash recovery 2026-11-07 22:01:42 +08:00
codex-bot 7b4d4c0ad3 Add renderer-independent canvas model, transactions and view sessions 2026-11-07 22:00:41 +08:00
codex-bot 6ff7c3064b Keep canvas floating controls visible with the sidebar open 2026-11-07 18:30:33 +08:00
Zayden-Jung 39b85bcda4 Merge pull request 'Update repository URL to Endoleap' (#1) from codex/repository-capitalization into main
Reviewed-on: #1
2026-11-06 17:46:37 +00:00
296 changed files with 22266 additions and 196 deletions
+1
View File
@@ -1,5 +1,6 @@
node_modules/
.next/
.headless-dist/
.env*
!.env.example
*.tsbuildinfo
+3 -1
View File
@@ -116,7 +116,9 @@ CANVAS_UI_TOOLS=/path/to/isolated-tools node tests/canvas-ui-check.mjs
DOM 工具仅需在临时目录安装 esbuild/jsdom,见测试文件开头;不启动浏览器。生产构建与 `tests/api-smoke.mjs` 使用隔离副本和临时本地服务,避免影响现有开发服务器。实际结果及限制见 VALIDATION.md。
Headless Canvas API 的拆包方案保留在本地忽略的 `docs/HEADLESS_CANVAS_API_DRAFT.md`,已按同步本地编辑与独立异步 runtime 重写,包含 element/component/feature、API 选择、八个工具栏位置、mode 作用域、上传恢复与 ACK 合并合同。当前没有创建或发布该库;outbox/IndexedDB 等仍是待实施方案。其他已被替代的元素/分区草案已清理。
Headless Canvas API 的完整方案保留在本地忽略的 `docs/HEADLESS_CANVAS_API_DRAFT.md`。当前工作分支已接入纯 core/runtime、ledger/queue、IndexedDB 恢复、渐进导入/深复制、canonical Foundry 协议和私有 ESM 包预览;使用方式见 `headless/README.md`,尚未发布 npm registry,也不代表完整产品验收或后端部署完成。
Workspace AssetDepot 支持重新授权后的跨项目采用、持久绑定与共享字节。浏览器数据库 v4 将已确认上传的项目文件转换为共享字节指针,保留原 File MIME 和逻辑读取接口;绑定使用恢复格式13。共享回收要求所有项目的持久引用清单完整且与 Root 同步;关闭项目仍受保护。源文件转换、字节、引用清单和 Root 在同一个事务中保存,未确认上传继续保留原 Blob。
应用提示使用 Sonner,图标说明使用 Radix Tooltip;根布局统一安装 provider。画布通用操作错误与归属完成使用固定 ID 通知,保存冲突、失败上传和重试操作仍保留在相关内容处。消息和浮层依赖仅属于应用 UI,不作为未来 headless core 的依赖。
+102
View File
@@ -0,0 +1,102 @@
# Headless Canvas:完整验收矩阵
2026-11-11。T01–T36 的实现与直接断言已逐项审计,当前版本的浏览器、存储、代理和270文件打包验收均已通过。本文随 SDK 打包,避免验收依据只存在于被忽略的研究 Draft 中。它记录已实现的第一版合同及证据范围,不表示代码已合并或生产环境已验证。
## 版本与复核入口
前端产品实现基线为 `15ee917`,此前包含 `b05058a` 的迁移数据校验修复;真实服务 consumer 固定为 `dcbbdad25de7c3b1d6bee317df6074da2c0642eb`。后续 `d3eef78` 和本次修改增加测试与交付文档,不修改产品实现。后端为 `506f778ac33d47fbd6f38412995a10218d59fe0a`。
后端现有 ces-control CI [#1186](https://gitea.entroia.com/Zayden-Jung/foundry-core/actions/runs/1186) 与 [#1187](https://gitea.entroia.com/Zayden-Jung/foundry-core/actions/runs/1187) 的七个阶段全部成功,包括实际认证 HTTP、PostgreSQL、资源与 RunPort consumer。两次报告全部功能标志为 true,生成执行次数均为 3。轮询次数随调度变化,不能用总 HTTP 次数代替“不重复受理”的独立断言。详见 [SERVICE_ACCEPTANCE.md](SERVICE_ACCEPTANCE.md)。
下面命令从 Endoleap 源码仓库根目录执行。Node 测试通过不自动替代浏览器或真实后端证据。用户允许本地前端编译与测试;后端编译、容器和数据库验收只在既有远程 CI 执行。
| 检查 | 命令 | 本次结果 |
|---|---|---|
| 全量源码单元/协议 fixture | `npm run test:headless` | 822 项通过,无失败或跳过 |
| 类型 | `npm run typecheck` | 通过 |
| 实际页面 webpack | `node tests/headless/renderer.integration.mjs` | 51 项通过 |
| 实际页面 Turbopack | `node tests/headless/renderer.integration.mjs --turbopack` | 51 项通过 |
| 公共 hooks / StrictMode | `node tests/headless/renderer.integration.mjs --hooks-only` | 10 项通过 |
| 实际 IndexedDB / Web Locks | `npm run test:headless:browser` | 57 组通过 |
| 实际 Next 代理 | `npm run test:headless:proxy` | 6 项通过 |
| 原编辑器升级交接 | `node tests/headless/renderer.integration.mjs --migration-only`,另加 `--turbopack` | webpack 与 Turbopack 各 5 项通过 |
| 独立解包、exports、类型、可选 peers、编译后浏览器/Worker | `npm run test:headless:package` | 270 文件解包、所有列出检查通过 |
## 逐项验收
负责组与 Draft 一致:A 模型/registry,B editor/history/交互,C runtime/同步,D storage/assets,E 导入/复制/loader,F bindings/renderer,G 后端/service。文件名默认位于 `tests/headless/`;后端 Go 用例位于 Foundry `internal/platform/`。每行“通过”限于列出的实现模式与断言,限制见后文。
| ID / 负责组 | 场景与实现边界 | 直接测试入口与已检查断言 |
|---|---|---|
| T01 A/B/E | 项目 ID 唯一;复制新 ID;恢复新 incarnation | `core.test.mjs`、`duplicate.test.mjs`;冲突拒绝、稳定映射、delete/undo 后旧 target 无效;实际页面旧编辑回调不能修改恢复对象。 |
| T02 A/B/F/G | 重挂/循环/解散 canvas | `dissolve.test.mjs` 六项;直属成员提升、入口保留、孙 canvas 仅改直接 parent、世界坐标/单位/曲线转换、声明引用重写、权限/opaque/partial 拒绝;真实 consumer 丢 ACK、原 key 恢复及 undo/redo。 |
| T03 A/G | 跨兄弟 canvas 的边归共同上级 | `core.test.mjs`、`queries.test.mjs` 与真实 owner 并发用例;边 owner 与端点 owner 分开验证,不能为存边篡改端点归属。 |
| T04 A/F | 未知 component/未来版本保留 | `core.test.mjs`、`renderers.test.mjs` 与实际页面 opaque 恢复;编辑其它内容不丢 payload,不把未知资源字段当成可加载图片。 |
| T05 A/F | 同模型多 renderer;重复 handler 拒绝 | `core.test.mjs`、`renderers.test.mjs`;两个宿主 catalog 投影同一 record,共享行为;同 capability 冲突和重复 component 注册明确失败。 |
| T06 A/B | schema/引用迁移失败零发布 | `core.test.mjs`;缺 converter、引用转换抛错、多步后续失败、schema 失败、非法 JSON、getter 单次捕获;原数据/身份/版本和 Root/history/ledger/index 保留。 |
| T07 A/B/C | 第二命令失败、async、重入 | `core.test.mjs`、`publication-guard.test.mjs`;dispatch/transaction/history/updateRuntime/reconcile 嵌套拒绝,不执行 reducer 或 observer;异步事务关闭后不能继续提交。 |
| T08 B/D | 双栈、新编辑清 redo、容量与 pin | `history-transition.test.mjs`、`resources.test.mjs`;no-op 不造历史,容量淘汰释放 pin,删除后的 undo 资产在历史保留期内不回收。 |
| T09 B/C | 同字段冲突与未知外部结果 | `conflicts.test.mjs`、`applied-recovery.test.mjs`、`engine.test.mjs`;冲突不提前移动栈;本地补偿可见,外部 unknown 先协调原 key,不冒称服务器确认。 |
| T10 B/D | upload ACK 后 Escape | `core.test.mjs`、`resources.test.mjs` 与实际页面;清 session preview,保留 committed stored ref,不用手势起点的旧整对象恢复图片。 |
| T11 B/F | pointerup 包含节流尾帧 | 实际 `renderer.integration.mjs` 的 resize/曲线原生输入;最终 300×220、曲线 440/265,一个 POST;core 对应一个 history 单元。 |
| T12 B/F | owner/权限/删除使手势失效 | `gesture-placement.test.mjs`、`pointer-capture.test.mjs`、core 与实际页面;祖先移动取消,迟到 pointerup/lost-capture 不复活或覆盖新坐标。 |
| T13 B/F/C | IME/Portal/detach/多 view | `input-router.test.mjs`、`session-sources.test.mjs`、bindings 与实际页面;输入只 claim 一层,旧 detach 不删除新 view,engine 任务独立于挂载生命周期。 |
| T14 C/D | 未发 create→rename→delete 合并 | `ledger.test.mjs`、`batch-projection.test.mjs`、`elision.test.mjs`;absent/exists/unknown 基线和完整 logical coverage,position-only 不回退 title;冻结/possibly-applied 不抵消。 |
| T15 C/G | create 已提交丢 ACK,随后 delete | `engine.test.mjs` 的 reopen 用例;submit→query 原 body/key→独立 delete,创建不再 POST,服务器无残留且两项历史保留;幂等服务合同另由真实 DB 覆盖。 |
| T16 C/G | accepted/cancel/failed/404 | `service.test.mjs`、`runs.test.mjs` 与真实 RunPort consumer;没有 terminal identity proof 不判零写入,取消/迟到 poll 保留控制 epoch,已取消 queued run 未调用生成。 |
| T17 C/D | 多 canvas claim 与跨标签 fencing | `scheduler.test.mjs`、实际 browser-storage/migration;全部参与者一起认领,unknown 保留 claim,过期 handle 不能写新 fence,shared legacy lease 排斥独占升级。 |
| T18 C | heap 到期、时钟变化、饥饿 | `scheduler.test.mjs`、`ledger.test.mjs`;wall/elapsed 分开,前跳/回退/重开剩余等待/旧 timer 回调,依赖和容量 lane 不被越过,无无界忙轮询。 |
| T19 C/G | ACK 缺失/乱序/后续 pending | `service.test.mjs`、`engine.test.mjs`、`loading-engine.test.mjs`;缺失/多余参与者与 logical coverage 拒绝;不发布半份基线,旧 ACK/tail 不清新编辑。 |
| T20 C/D/G | 旧冻结请求/receipt 过期 | `scheduler.test.mjs`、`applied-recovery.test.mjs`;无解释器阻塞而保留原 body/key,已有 applied proof 后 receipt 过期走 fresh read,不重发旧 POST。 |
| T21 C/D | Root/journal/文件提交失败 | `durability.test.mjs`、`engine.test.mjs` 与实际 IDB;quota 不虚报 durable、不发送,后续 put/delete 失败整事务回滚,旧 revision 的完成不能确认新 dirty 状态。 |
| T22 C/D/G | 全部声明的恢复窗口 | `crash-windows.test.mjs` 的六个切点场景;原逻辑 ID/body/key、历史、POST/query 次数及最终 durability;实际 IDB 另验 checksum/journal gap/fence,非物理 kill 全组合测试。 |
| T23 D/B/E | 图片复制/替换/删除/undo | `resources.test.mjs` 32 项、实际浏览器与真实服务;资源共享,replacement 阻塞仍保留旧 ref,undo 挡迟到替换,redo/reopen 不重复上传或更换元素 incarnation。 |
| T24 D/A | 索引滞后/opaque/历史保留 pin | `resource-pin-scan.test.mjs`、`asset-depot.test.mjs`、实际 IDB;working/ACK/history owners 独立,coverage 不足/过期 revision/未知引用拒绝永久回收,repin 重启计时。 |
| T25 E/F | 边解析/边加入/边显示及编辑 | `imports.test.mjs`、`imports-engine.test.mjs`、budget 系列与实际页面;源尾部阻塞时首记录已可编辑,后续导入不覆盖编辑;完整 SHA 未验证前不发送。 |
| T26 E/C | 第 101 项失败/取消 | `imports.test.mjs` 的 100 valid→bad JSON→valid tail→missing edge;101 个合法元素保留、两个明确失败、未闭合边不发;取消成功前缀可恢复且不自动继续。 |
| T27 E/A/G | 深复制 F 与内部 A 同选 | `duplicate.test.mjs`、`imports-engine.test.mjs` 与真实 consumer;深层树只复制一次,P 保留 C→F 并增 C→F′,资源复用,无新 run;字面字段不误做 ID 重写。 |
| T28 A/E | 旧格式/未知 envelope | `entry.test.mjs`、`import-document.test.mjs`;只走明确协商 fallback/codec,403 不回退,读取不隐式迁移,未知格式/版本拒绝,不猜 owner。 |
| T29 B/E | import 与手动编辑历史穿插 | `imports-engine.test.mjs` 新增 reopen 测试;导入前缀→手动改标题→导入尾批保持三个 undo 单元,依次撤销再重做全部三项,标题与内容均正确。 |
| T30 C/F | 回放/重试/observer 重挂 | ledger/scheduler、`loader.test.mjs`、`session-sources.test.mjs`、公共 StrictMode hooks;queued Set 与 ledger 同源,旧回调不删新句柄,异常 observer 不阻止其它观察者,无历史/任务重复。 |
| T31 C/D/F | logout/账号切换/升级/配额 | `model-loader.test.mjs`、host/workspace 用例、实际 IDB 与 logout 页面;新 runtime 的 account/workspace/authEpoch scope、retire fence、DB versionchange、dirty 风险可见。旧兼容编辑器备份 key 的范围见限制。 |
| T32 E/C | loader 尾块失败/查询变化/晚导航 | `loader.test.mjs`、`model-preview.test.mjs`、`model-loader.test.mjs` 与实际页面;partial 不冒充 complete,fingerprint/epoch 分离,旧请求即使无视 abort 也不能删或完成新请求。 |
| T33 A/F | rotate/细线/zoom/展示路径 | `hit-query.test.mjs`、geometry/connection paint 与实际页面;两级旋转和单位换算,zoom .1/1/4 的屏幕容差,Bezier 精判非直线近似,折叠端点展示与原引用分开。 |
| T34 A/F | 只改标题/只 ACK 的局部计算 | `queries.test.mjs`、`projection-cache.test.mjs`、`scene-projection.test.mjs` 与实际 renderer 计数;无关几何/节点投影复用,listener 看到同 revision 的模型与索引。 |
| T35 A/E/F | 停放 view/导出/引用递归 | `bindings.test.mjs`、`import-document.test.mjs` 与实际 toolbar 导出/重导入;detach 不关闭项目任务,导出从完整模型读取所有 canvas/elements/tombstones,不遍历 DOM。 |
| T36 G/C | 幂等/版本/跨图原子 | `TestCanonicalConcurrentMigrationAndOwnershipWrites`、`TestCanonicalModelMigrationAndIncrementalLifecycle`、迁移 guards/late-cancel 用例及真实 consumer;同 key 同 bytes 只一次版本推进,改 bytes 拒绝,竞争端点重挂只有一个赢,失败与 late cancel 整体回滚。 |
## 恢复切点:本地事实与服务事实匹配
不能拿任意旧 checkpoint 配任意服务状态,再从结果声称完整恢复。`crash-windows.test.mjs` 捕获实际 engine 产生的不可变保存图像,逐个搭配对应服务状态:
| 窗口 | 重开时必须保持的行为 |
|---|---|
| 可见编辑尚未 durable | 从最后 durable Root 重开;可能丢失该编辑;关闭前 dirty 状态不能声称已保存。 |
| pending 意图已 durable、尚未冻结 | 原 logical operation ID 保留;生成一次请求,不能重复 history。 |
| frozen 已 durable、尚未发送 | 原 body/key 保留;恢复可保守先 query,再原样 retry,不能换请求身份。 |
| dispatch-marked,服务尚未提交 | query 缺失不能当可靠零写入终态;按协议原 body/key retry。 |
| 服务已提交,本地 receipt 尚未 durable | query 原请求取回成功,不再 POST;之后才推进本地基线。 |
| receipt/基线已 durable | 重开不再执行或重复历史/副作用。 |
六种测试分别覆盖 Draft 的提交前、durable 意图、冻结/发送、服务提交、本地确认边界。文件字节、Root 与 journal 的实际事务回滚另由真实 IndexedDB 用例证明;AI、上传与删除依赖分别有身份/丢 ACK 用例。没有声称“每种任务 × 每个故障 × 每个浏览器”全部做了进程强杀测试。
## 分工与唯一事实源的交付边界
A/B 维护 immutable Registry、规范化文档、命令/事务、history 双栈;C/D 维护 ledger、由其派生的 ready 集合/调度队列和到期 heap,以及同一恢复 Root 和字节仓库;E 维护受该 Root 管理的导入/复制检查点、稳定映射与 loader 捕获;F 只拥有 ViewSession 和投影/输入绑定;G 负责授权、版本、幂等、跨图事务和可查询 receipt。不同组通过公共 exports、Change 与端口合同连接,不能再维护另一份业务文档或任务状态。
结构、包含关系、生命周期、集成入口和示例见随包 [README.md](README.md)。源码测试覆盖各组内部纯逻辑和端口故障;真实页面覆盖连线后的生命周期;真实 CI 覆盖前后端 wire/DB,三个证据层分别保留。
## 已知边界
- 第一版的 dissolve 保留原入口元素和原线,只解除其分区入口关系;公开 headless 命令已实现,没有新增产品按钮或另一种“删除入口”政策。
- 支持记录/依赖闭合后的渐进显示;完整源 SHA 和恢复身份未可靠保存前不得发请求。任意数据捕获、原子发布、native 与 GC 不能全部抢占。
- 性能验收是合作调度软目标。10,000-child 生产复制的首次副本呈现 784.1ms、完成帧约 27.95s、最大 long task 386ms;保留此限制,不能称为完全无卡顿或生产 SLA。环境、命令和原生中文输入记录见 [STEP4_ACCEPTANCE.md](STEP4_ACCEPTANCE.md)。本次没有产品性能代码变化,未重复该规模测量。
- 新 canonical runtime 和迁移记录按 account/workspace/authEpoch 隔离;旧兼容编辑器原有的本地草稿备份 key 仍按 workspace/board 范围。T31 不声称已把全部旧备份迁移为账号命名空间。
- 未知/未来版本的 payload 保留并限制编辑;不承诺自动理解所有未来 schema。无解释器的冻结请求明确阻塞,不重编码或换 key。
- 本地资产回收基于完整 pin/coverage;跨客户端永久服务 GC、完整 CRDT、多处 notebook 阅读模型仍属原稿后续范围。
- 远程 deterministic worker fixture 替换生成输出。实际授权、HTTP、DB、事件与文件流程经过验证,外部模型可用性、计费、CLI 认证和生产后台队列不在该证据范围,付费 real-worker 用例仍 opt-in。
- 包验收、工作分支推送、PR 可评审、人工合并、部署是不同状态。最终提交/推送状态以交付报告和 Git 为准;Foundry 等待用户手动合并,不以全绿测试声称已合并或部署。
## 最终交付状态(2026-11-11)
T01–T36矩阵、当前前端回归、远程服务验收及独立SDK消费检查完成,原七步实现/验收交付完成。前端验收提交 `14f7475` 已推送 `codex/headless-canvas-v1`;后续只有交付状态记录更新。前后端相对最新main的merge-tree演算无冲突;后端506f778状态检查success。Foundry [PR #19](https://gitea.entroia.com/Zayden-Jung/foundry-core/pulls/19)已由Draft转为可评审,API返回draft=false、mergeable=true,等待用户手动合并。前端遵循当前不新建PR的项目约定保留工作分支。两仓库均未由bot合并main或部署。
+1238
View File
File diff suppressed because it is too large Load Diff
+172
View File
@@ -0,0 +1,172 @@
# Foundry service integration evidence
Status: implementation, service/migration and final acceptance delivery completed.
Main integration and deployment require separate actions.
The T01–T36 source/assertion audit and current frontend regression gates have
passed; see [FINAL_ACCEPTANCE.md](FINAL_ACCEPTANCE.md). Main integration and
production deployment are separate, pending steps.
## Verified revisions and environment
- Current verified Endoleap service consumer: `dcbbdad25de7c3b1d6bee317df6074da2c0642eb`
(product implementation `15ee917`; receipt diagnostics added by `dcbbdad`).
- Current Foundry: `506f778ac33d47fbd6f38412995a10218d59fe0a`.
- [Foundry CI #1186](https://gitea.entroia.com/Zayden-Jung/foundry-core/actions/runs/1186):
job 1283 completed successfully, all seven steps successful. All prior flags
and `dissolveCanvas`, `dissolveLostAckRecovered`, `dissolveUndoRedo` are true;
`aiExecutionCount: 3`, 84 transport calls. The new canonical-frame rotation
test also passed. Same-commit PR CI #1187/job 1284 subsequently passed all seven
steps, with the same flags/execution count and 83 transport calls.
- [Foundry CI #1182](https://gitea.entroia.com/Zayden-Jung/foundry-core/actions/runs/1182):
job 1279 completed successfully, all seven steps successful, pinned to the
browser delivery baseline `24c4592`, Foundry `5db2bf3`. Its report has all document/resource/AI and
migration flags true, `aiExecutionCount: 3`, and 75 transport calls.
Same-commit PR CI #1183/job 1280 subsequently passed all seven steps too.
- [Foundry CI #1180](https://gitea.entroia.com/Zayden-Jung/foundry-core/actions/runs/1180)
and [PR CI #1181](https://gitea.entroia.com/Zayden-Jung/foundry-core/actions/runs/1181):
completed successfully for consumer `26da326` and Foundry `3268c57`;
jobs 1277/1278, all seven steps successful. The original service scope audit
also passed CI #1178 and #1179.
- Existing `ces-control` runner executes Go checks/build, schema migration and
isolated PostgreSQL integration, then the pinned Node 24 consumer and HTTP smoke
checks. No local backend build or Docker execution was used.
- Local frontend: 822 Node tests and full TypeScript checking passed. Tests use
the source modules; packaged SDK and application delivery remain separate gates.
## What the checks actually prove
| Requirement | Authoritative entry and observed assertion |
| --- | --- |
| Explicit identity migration | `TestCanonicalModelMigrationAndIncrementalLifecycle`: GET does not migrate; retry returns the same identity map; implicit partitions become owning canvases; old snapshot writers are rejected. The real Node consumer also migrates and retries. |
| Migration admission and late rollback | `TestCanonicalMigrationPermissionVersionAndRunGuards` checks viewer denial, stale source versions, queued/running work and stable negative receipts after conditions change. `TestCanonicalMigrationLateCancellationRollsBack` observes the actual identity-map INSERT blocked after canonical/material/subgraph writes, cancels it, then checks all related rows, original legacy JSON and version before retrying the original key successfully. |
| Incremental writes and receipt identity | `TestCanonicalModelMigrationAndIncrementalLifecycle` verifies exact retries, unknown receipt semantics, logical ID coverage, participant versions, and zero writes/logical-ID consumption after a later batch condition fails. It checks canonical DB rows, not just HTTP status. |
| Canonical concurrent writers | `TestCanonicalConcurrentMigrationAndOwnershipWrites` races four identical migrations, then four identical edits, requiring identical receipts and one version advance. Two different requests then compete to move alternate endpoints and their common edge: exactly one wins, the loser has a queryable terminal version-conflict receipt, both canvas versions advance once, and the full ownership graph remains valid. Reusing the winning key with changed bytes is rejected without changing the model. |
| Dissolve without deleting members | The real consumer creates nested child/grandchild canvases, dissolves the child and loses its successful response. Reopen queries the original request without a second POST. Direct members and the nested entry move to the parent, the leaf retains its grandchild owner/geometry, and the original entry/edge remain. Undo/redo/undo restores the original bindings. This uses the existing atomic edit protocol and the real HTTP/DB service. |
| Canonical frame geometry | `TestCanonicalFramesRetainRotationAcrossOwnerChanges` preserves nonzero frame rotation through owner changes, projection and an unrelated canonical AI patch, while rejecting 360 degrees. The legacy snapshot writer still rejects rotated frames; its restriction no longer rejects canonical coordinate conversion. |
| Real frontend coordination | `TestHeadlessClientHTTPIntegration` runs `tests/headless/foundry.integration.mjs` against authenticated HTTP and PostgreSQL. It verifies lost document ACK recovery without duplicate POST, atomic partition edits, deep copy with a parent-owned incoming edge, partial import and old stream-tail coordination. |
| Resources and authorization | The consumer verifies lost upload ACK, shared pending-image upload, original bytes, metadata, cross-project adoption and reopen. `TestOnboardingAndSharing` additionally revokes source access after warm reads, verifies no target model/version/binding changes, preserves the rejected receipt after permission restoration, and checks independent target access. |
| AI execution identity and controls | `TestCanonicalAIAdmissionAndLifecycle` covers admission/query/retry, zero-effect rejection, old incarnation callbacks, cancellation, managed image output, owner relocation, archived admission routes and execution after permission revocation. |
| Actual frontend RunPort | `tests/headless/foundry-ai.integration.mjs` exercises `createFoundryRuns`, the HTTP transport and ProjectEngine: successful admission response loss/reopen/query, queued cancellation response loss, generation-time content conflict, explicit apply response loss plus undo/reopen/redo, generated image metadata/original bytes/preview, and copy without a new run or upload. |
| Host identity migration coordination | `canvas-migration.ts` now drives the real consumer's initial migration: the server commits, the response is lost, a new coordinator reads the stored body/key and queries the original receipt without a second POST. A later intentional raw POST still checks backend idempotent retry. The coordinator's drain and lock ports are fixtures here; this does not prove the old editor's browser handoff gate is connected. |
| Browser legacy handoff | `renderer.integration.mjs --migration-only` exercises the actual GraphPage, legacy editor, upgrade panel and Next proxy with native Chrome input, localStorage and Web Locks. Dirty old PATCH ACK precedes migration; another editable view excludes migration; committed response loss survives reload without a second migration POST; uncommitted response loss reuses the original body/key, with terminal rejection followed by explicit fresh preparation; a different cached draft must be explicitly restored before migration. A draft modal cannot block receipt recovery; it becomes actionable after terminal rejection. The upstream is a protocol fixture, not the real Foundry service. |
The CI #1174 consumer report contains `status: "ok"`, 72 HTTP transport calls,
all 14 prior document/resource flags true, plus `runtimeAI`, `aiLostAdmission`,
`aiCancelReconciled`, `aiConflictApplyUndo`, `aiManagedImage`, `aiCopyNoRun` true.
`aiExecutionCount` is exactly 3: text, retained conflict output, image. The queued
cancelled run never calls generation. Preview fetching is a separate authenticated
host fetch and is not included in the 72 transport calls.
CI #1178 repeated the same pinned consumer successfully with 71 transport calls
and the same flags/execution count. Timing-dependent poll counts are observations,
not an exact total-call contract; admission/action/upload duplication has separate
explicit assertions.
CI #1180 additionally reports `migrationCoordinator` and
`migrationLostAckRecovered` true, with 76 transport calls and the same document,
resource and AI results. The local coordinator suite covers 11 cases, including
quota/no-write behavior, uncommitted-request recovery, invalid receipt retention,
identity/abort fences and terminal rejection followed by explicit preparation.
## Issues exposed by the real consumer
1. The standalone AI client omitted the v2 Accept header. The Next proxy had
supplied it, hiding the omission in application-only testing. `canvas-v2`
negotiation now belongs to the AI adapter and shared transport; regression
tests cover all three writes and receipt queries with original body/key/version.
2. Asset display URLs assumed the application's relative preview route. The
asset adapter now accepts a host `displayURL(id)` callback. Canonical source
identity and authority remain separate; preview bytes are not used as original
file digest evidence. Authentication stays in the host fetch/transport.
3. CI #1184/#1185 rejected the nested creation before reaching dissolution:
canonical validation had copied the legacy frame's zero-rotation rule. A
rotated owner can require a frame to retain nonzero rotation after conversion.
Foundry `506f778` removes that canonical-only restriction; existing finite/range
checks and legacy normalization remain. The consumer now checks the creation
receipt before reading descendants, so a rejection reports its actual state.
The test harness also needed working controlled timers, a cancelled run's final
event drain before the next admission, and actual copy-source file persistence.
The adoption stage still asserts zero project-local file writes. The subsequent
copy stage permits source checkpoints but rejects bitmap file duplication and
asserts no extra upload or generation. Those fixes do not bypass busy, cursor,
durability or resource-sharing checks.
## Final-audit recovery cuts
`crash-windows.test.mjs` selects actual immutable cuts produced by the engine:
before local durability, durable pending intent, frozen request, dispatch-marked
before server commit, dispatch-marked after server commit, and durable receipt/base.
Each independent recovery pairs that cut with its corresponding server/receipt
state, then checks document/history/logical IDs, exact frozen request bytes,
POST/query counts and final durability. Frozen recovery may conservatively query
before its exact retry; a missing receipt never creates a new identity. These are
memory StoragePort/protocol fixtures, not physical browser-process termination.
Actual IndexedDB atomic rollback, fencing/checksum/journal-gap and reopening
evidence remains the separate browser suite; this is not every possible cross-product.
`engine.test.mjs` additionally commits a create, loses its ACK, deletes locally,
reopens, queries the original create and finally sends the dependent deletion.
The server has no residual element; both original history units remain and the
creation is not submitted twice. The focused engine/crash suite passes 36 tests;
the full Node suite passes 821. `resources.test.mjs` also verifies replacement of
an existing image while the new upload is gated: the old server reference stays
valid, undo prevents a late replacement, and redo/reopen reuse the two uploaded
resources without a new upload or element incarnation. Its 32 checks pass.
The subsequent T01–T36 audit is recorded in `FINAL_ACCEPTANCE.md`; the final
frontend run passes 822 Node tests, with both 51-check browser bundlers, 10 hooks,
57 browser-storage checks, 6 proxy checks, both five-scenario migration entries
and the 270-file unpacked package passing.
## Boundaries and remaining audit
The recovery store is an in-memory durable-batch fixture, and response loss is
injected after successful HTTP responses. Real IndexedDB/Web Locks/browser
acceptance is recorded separately in `STEP4_ACCEPTANCE.md`; this service run does
not combine every browser crash window with every server operation.
The migration coordinator uses a string-storage port before a canonical engine
exists. `browser-canvas-migration.ts` supplies localStorage, identity fences and
workspace-scoped native Web Locks. Every editable legacy view holds a shared
lease; upgrade first freezes and drains its writer, releases that lease, then
takes the exclusive lease and prepares again before creating a request. Pending
records block affected legacy views across reload. Unknown results retain the
original request; only a verified terminal rejection permits fresh preparation.
Upload/import/member-task and draft-recovery gates preserve the old editor's
content rather than converting in-memory work silently. A differing cached draft
is surfaced for explicit recovery, and the current draft is backed up before
waiting for its PATCH ACK. The account fence also covers old snapshot requests.
The browser fixture above proves the connected UI path, including actual native
locking, and the separate real-service consumer proves server coordination.
They do not combine a real browser and real PostgreSQL in one scenario. Full
webpack and Turbopack application regressions each passed 51 checks; the migration
entry adds five scenarios and three lock-lifetime unit tests. Unknown
receipt/error states remain read-only with an export action. Unsupported Web
Locks prevents upgrading, while existing legacy editing remains available when
there is no pending migration. The stage 6 audit also inspected source/package
exports, peer dependencies and documentation targets, then passed the actual
267-file unpacked package's Node/types, framework-peer, compiled browser storage
and Worker checks. Together with CI #1182 and both browser bundlers this closes
stage 6. The final requirement matrix is recorded separately; the two new scheduler
clock-shift checks cover backward/forward wall time, dependency/unknown claims,
stale timer callbacks and remaining wait calculation after recovery.
`canvas_headless_worker_integration_test.go` supplies deterministic generation
output over the actual worker HTTP protocol. Admission, claim, events, canonical
writes, image storage and action receipts use the real service/DB. Private worker
controls are registered only in the test assembly. Production's background queue
loop, external model availability, provider billing and real CLI authentication
are not proved by that fixture. `TestCanvasAIRealWorker` remains opt-in and skipped.
The CI log includes an early skip of `TestHeadlessClientHTTPIntegration` without
its opt-in environment and a later successful execution in the explicit Node/DB
phase. Those are different invocations. The latest successful log has 158 distinct PASS names
including subtests; that count is not 154 independent end-to-end scenarios.
The service audit covers the original stage requirements: target incremental and
idempotent protocols, frontend/runtime/resource adapters, explicit legacy
compatibility, identity mapping, source-version/permission/run admission barriers,
and rollback after writes have begun. Migration/delivery now has the separate
browser/package evidence above; the final requirement matrix now has its separate per-row audit. A green
feature-branch run does not merge or deploy
either repository. [Foundry PR #19](https://gitea.entroia.com/Zayden-Jung/foundry-core/pulls/19)
is open, ready for review (`draft: false`, `mergeable: true`, checked 2026-11-11),
and waits for the user to merge manually.
+68
View File
@@ -0,0 +1,68 @@
# Step 4:渐进功能与呈现验收
状态:第4步原定功能、合作调度及规模输入验收已通过;整体进度 4/7。长任务与非抢占边界见下文,不宣称生产实时性能保证。此文件随实现提交;完整目标仍包含后续真实服务、迁移和集成验收。
## 范围与直接证据
| 原要求 | 代码与验证入口 | 本次结果 |
| --- | --- | --- |
| 导入、深复制逐步加入并允许编辑 | `imports.ts`、`canvas-runtime/duplicate.ts`;`imports.test.mjs`、`imports-engine.test.mjs`、`import-document-stream.test.mjs`、`duplicate*.test.mjs`;实际页面文件选择/工具栏 | Node 与页面检查通过。SHA 完成前可显示和编辑;完整校验完成且对应 Root 持久化后才发送。单项失败保留已加入内容并报告 partial。复制保留原图、新建身份,父层新增连线,复用合法资源且不自动启动 run。 |
| loader 旧捕获与后续编辑/ACK 协调 | `loading.ts`、`engine.ts`;`loading-engine.test.mjs`、浏览器存储和实际页面 | 当前 loading v1–v6 路径、未读 tail、undo/redo、重开、取消及源校验检查通过。旧数据不覆盖后来的编辑/ACK;已声明的 partial 状态保留。 |
| 公共 hooks 和局部订阅 | `canvas-bindings/react.ts`、`canvas-bindings`;`react-hooks-fixture.tsx`、`renderer.integration.mjs --hooks-only` | 同一最终源码的 StrictMode 入口 10 项通过;包括 owner 切换、engine close、资源句柄与 unloaded/absent。普通字段与 ACK 的无关投影复用由实际页面计数验证。 |
| 输入、呈现与合作调度 | frame queue、scene/paint projection、retained projection;`frame-queue.test.mjs`、`retained-projection.test.mjs`、实际页面与生产规模入口 | 页面验证拖动最终位置、IME、Portal、detach、未知焦点与订阅取消。生产万元素早/中/尾段输入与副本捕获隔离通过;性能仍有限制,见下文。 |
所有 `*.test.mjs` 均位于 `tests/headless/`,源码位于 `src/lib/` 或 `src/components/canvas/`。每个测试文件中的断言决定其覆盖范围;通过总数不代表 Draft 的 T01–T36 或整个项目已经完成。
## 编码与发布边界
1. `writePrefix` 是有序单 writer,逐个保存 Root,异步编码不会跳过中间 Root。
2. 外部对象、getter、custom `toJSON` 和浅冻结值在第一次让出前按原 codec 捕获;私有容器只证明成员固定,内部未知值仍需捕获。
3. core 的 `detachJsonData` 为元素和复制规划 checkpoint 提供 JSON 脱离、深冻结事实,可以延后逐字段编码。需要标准 Object/Array 原型且没有继承的 `toJSON`;每次未知值捕获后重新检查环境。外部回调不能改变已经捕获的前项,也不能让后项错误沿用旧环境。
4. 标量归一化、UTF-8 计数、history/import/pending 容器与首次元素遍历共用软预算。已完成的深不可变编码可复用,但不能省略 unsafe occurrence 的求值。
5. 编码、调度或存储失败保留待存 Root;持久化没有确认,不解除网络发送屏障。旧 Root 与编码期间的新编辑保持各自内容。
6. journal 比较先判断子值是否相同,再为变化项分配路径,避免为共享的整个前缀创建临时数组。patch 顺序与字节语义不变。
`json-data.test.mjs` 验证事实不能从外部冻结对象或结构克隆伪造;`duplicate-checkpoint.test.mjs` 验证首次 checkpoint 的真实字段编码在 yield 间发生,后续 planner.step 不改变已捕获值;`imports-engine.test.mjs` 验证真实 preparation/source envelope、字节和重开映射。ready plan 已先持久化时允许缓存复用,不要求空 yield。`recovery-encoding.test.mjs` 验证首次归一化、继承行为、外部回调、Proxy trap 次数、UTF-8、冷/热深度边界;`engine.test.mjs` 以 17000 元素验证暂停编码期间编辑、逐 Root 落盘和发送屏障。`journal-delta.test.mjs` 验证精确重放及属性顺序;浏览器检查验证真实 IndexedDB 重开。
## 可复现命令
```sh
node --test tests/headless/*.test.mjs
npm run typecheck -- --incremental false
node tests/headless/renderer.integration.mjs
node tests/headless/renderer.integration.mjs --turbopack
node tests/headless/renderer.integration.mjs --hooks-only
node tests/headless/browser-storage.mjs
node tests/headless/package.integration.mjs
HEADLESS_PERFORMANCE_COUNT=10000 HEADLESS_PERFORMANCE_EDIT_DURING_COPY=1 HEADLESS_PERFORMANCE_REPORT=/private/tmp/headless-checkpoint-production-10000.json node tests/headless/renderer.integration.mjs --performance --production --turbopack
```
性能入口须在其它构建/测试结束后单独执行;功能检查可并行。这里验证真实 Chrome、IndexedDB、Web Locks 和实际前端,但 HTTP 为隔离 fixture,不能据此声称已完成真实 Foundry 集成。
## 本次运行结果(2026-11-11)
- Node:783 项通过;全量前端 TypeScript 检查通过。
- 实际页面:webpack 51 项、Turbopack 51 项通过;公共 hooks 10 项通过。
- 浏览器存储:57 组通过;SDK:265 个打包文件,类型、独立消费和编译后的浏览器/Worker 检查通过。
- Chrome 退出后的 stdio 清理保留。此次五组并行检查中,webpack 有一次原生双击未打开恢复编辑器;同源码单独复跑51项通过。随后测试增加同步完成、两次rAF和实际可命中点击点的前置条件,保留原生事件/编辑焦点断言;两种构建器各51项均通过。未据此推定产品逻辑有错或仅是CPU争用。
- 生产规模环境:本机 arm64、macOS 15.6、Node 23.10.0、Chrome 157.0.8103.39,Next production Turbopack;无并行构建/测试、无计时代码插桩。
- 10000-child 展开分区深复制:首副本呈现 784.1ms、本地完成帧 27949.7ms、加入并同步 28169.9ms;帧间隔 P95 33.4ms/max383.4ms;21 次 longtask,max386ms/total1663ms。
- early 五次 input→两次 rAF:69.8/57.9/34.3/29.5/15.6ms;middle(5279 项)45.3ms、tail(9093 项)73.5ms。全部发生于复制活跃期间,DOM/focus 保留;中尾 CDP 往返约40.9/26.0ms。它们不含精确的派发前排队延迟。
- 服务端最终 20005 元素/3 canvas;原/新父层边、原文后续输入、副本旧快照、pan 后副本挂载通过。样本呈现时挂载35节点,全过程最大137,pan 后28;屏幕外 IME/Portal 各保留1,独立应用面板不额外保活,runStartsDuringScenario=0。
原始报告 `/private/tmp/headless-checkpoint-production-10000.json` 和同名 PNG 为本次本机观察;命令与断言已保存在仓库,可独立复现。单次运行有不同 adaptive 批次、native/GC 与系统调度负载,不能以此与前轮36.26秒比较而宣称确定提速,更不能说所有停顿已解决,或未经归因把386ms全部称为GC。
## 收口审查
前轮唯一明确的编码缺口已经接通:`createDuplicatePlanner().checkpoint()` 与元素复用同一 JSON 脱离工厂,runtime 自建 job/source/preparation envelope 保留 shape 来源;分配列表、ready ID表和根列表不再丢失这项边界。首次 preparation 确实分段,最终 ready plan 先经过持久屏障后复用已有编码,重开后映射保持。
第4步验收按开工时的四类职责和6ms合作调度软目标完成,不把它改成“每次GC/native/原子发布均小于6ms”。此结论覆盖原定实现与列出的复现入口;它不代表无卡顿、低端设备SLA、任意selector皆O(1)或T01–T36全部完成。首项可见、输入保留、两侧快照正确、Root按序落盘及发送屏障均有直接断言;最终长任务观测作为已知限制保留,不假称已经消除。
## 调度限制与后续分工
6ms 是合作调度软目标。捕获成员引用、未知值的同步 JSON 语义、单个字符串/native 操作、原子模型发布和 GC 仍可能超过一个时间片。未知调用不能假冒不可变值以换取响应速度;也不能把普通循环停顿一概称为 GC。测试测量 input 到两次 rAF;CDP 往返另含传输和驱动时间,均不是精确的输入派发前排队延迟。
未登记的第三方 Portal/未知输入 owner 保留明确的兼容回退;任意全局 Root selector、结构变化和未知 ACK 不保证 O(1)。renderer 不保存第二份业务文档,不为裁剪丢弃完整模型。
下一阶段由服务/集成工作负责真实 Foundry 协议、权限、迁移、冻结请求幂等与恢复联调,并沿用现有远程 CI。分支推送不等于主线合并或部署,仍需单独报告。
+68
View File
@@ -0,0 +1,68 @@
/** A second host: no DOM, React, Next, React Flow or network service required.
* Storage and protocol-2 transport below are in-memory examples, not a disk or
* production server implementation. Replace them with your host's real ports.
*/
import assert from 'node:assert/strict';
import { randomUUID } from 'node:crypto';
import { createDocument, createRegistry, builtinComponents, defineComponent, editable, movable, resizable, rectangleGeometry, applyChanges, validateDocument } from '@endoleap/headless-canvas/core';
import { openProjectEngine } from '@endoleap/headless-canvas/runtime';
import {projectCanvas,createRendererCatalog,builtinRendererDefinitions,createGeometryProjectionCache,hitProjectedCanvas} from '@endoleap/headless-canvas/bindings';
import { createFoundryPersistence, loadFoundryModel } from '@endoleap/headless-canvas/service';
const scope={accountId:'10000000-0000-4000-8000-000000000001',workspaceId:'10000000-0000-4000-8000-000000000002',projectId:'10000000-0000-4000-8000-000000000003'};
const registry=createRegistry([...builtinComponents(),defineComponent({id:'example.counter',dataVersion:1,structure:'node',validate:data=>typeof data.title==='string'&&Number.isSafeInteger(data.count)&&data.count>=0,features:[editable(),movable(),resizable()],geometry:rectangleGeometry,resourceFields:[],copy:{references:[],omit:[]}})]);
const original=createDocument(scope.projectId,[{id:scope.projectId,parent:null}],[]);
let image=null,leader,fence=0,server=original,version=1;
const files=new Map(),requests=new Map();
const check=handle=>{assert.strictEqual(handle,leader);assert.equal(handle.active,true);};
const storage={
async claim(requested){assert.equal(leader,undefined);assert.deepEqual(requested,scope);let active=true;const handle={scope:requested,fence:++fence,get active(){return active;},async release(){active=false;if(leader===handle)leader=undefined;}};leader=handle;return handle;},
async recover(handle){check(handle);return structuredClone(image);},
async commit(handle,batch){
check(handle);const cut=structuredClone(batch.image),nextFiles=new Map(files);
for(const file of batch.files??[]){assert.equal(nextFiles.has(file.id),false);nextFiles.set(file.id,file.bytes);}
for(const id of batch.deleteFiles??[])nextFiles.delete(id);
// Prepare before publishing. This is atomic in this process, not crash-durable storage.
image=cut;files.clear();for(const entry of nextFiles)files.set(...entry);return{durableRootRevision:cut.rootRevision};
},
async readFile(handle,id){check(handle);return files.get(id);},close(){assert.equal(leader,undefined);},
};
const model=()=>({documentFormatVersion:1,projectId:scope.projectId,complete:true,canvases:[...server.canvases.values()],elements:[...server.elements.values()],tombstones:Object.fromEntries(server.tombstones),versions:{[scope.projectId]:version},removedCanvasIds:[]});
const transport=async input=>{
input.signal?.throwIfAborted();
if(input.method==='GET'){
if(input.path.endsWith('/canvas/model'))return{status:200,body:{model:model()}};
const id=input.path.split('/').at(-1),known=requests.get(id);
return known?{status:200,body:known.receipt}:{status:404,body:{protocol_version:2,request_id:id,status:'unknown'}};
}
const body=JSON.parse(input.body),known=requests.get(input.requestId);
assert.equal(body.protocol_version,2);assert.equal(body.request_id,input.requestId);
if(known){assert.equal(known.body,input.body);return{status:200,body:known.receipt};}
assert.deepEqual(body.expected_versions,{[scope.projectId]:version});
server=applyChanges(server,body.operations.flatMap(operation=>operation.changes));validateDocument(server,registry);version++;
const receipt={protocol_version:2,request_id:input.requestId,status:'applied',operation_ids:body.operations.map(operation=>operation.id),model:{...model(),complete:false}};
requests.set(input.requestId,{body:input.body,receipt});return{status:200,body:receipt};
};
const persistence=createFoundryPersistence({workspaceId:scope.workspaceId,rootId:scope.projectId,registry,transport});
const open=()=>openProjectEngine({scope,registry,document:original,versions:new Map([[scope.projectId,1]]),storage,persistence,createId:randomUUID});
let engine=await open();
try{
const target={id:randomUUID(),incarnation:1};
const element={...target,ownerCanvasId:scope.projectId,componentId:'example.counter',dataVersion:1,structure:'node',data:{title:'Portable counter',count:1},geometry:{x:0,y:0,width:160,height:80,rotation:0}};
assert.equal(engine.editor.dispatch({type:'element.create',element}).status,'applied');await engine.settle();
const before=engine.getSnapshot();
assert.equal(engine.editor.transaction(tx=>{tx.dispatch({type:'element.geometry',target,patch:{x:32}});tx.dispatch({type:'element.data',target,patch:{count:-1}});}).status,'rejected');assert.strictEqual(engine.getSnapshot(),before);
assert.equal(engine.editor.transaction(tx=>{tx.dispatch({type:'element.geometry',target,patch:{x:32}});tx.dispatch({type:'element.data',target,patch:{count:2}});},{label:'Move and increment'}).status,'applied');await engine.settle();
assert.equal(engine.editor.history.undo().status,'applied');await engine.settle();assert.equal(server.elements.get(target.id).data.count,1);
assert.equal(engine.editor.history.redo().status,'applied');await engine.settle();assert.equal(server.elements.get(target.id).data.count,2);
engine.sessions.attach('headless-view');const writes=requests.size;
assert.equal(engine.sessions.begin('headless-view',[target]).enabled,true);engine.sessions.update('headless-view',target,{x:90});assert.equal(engine.sessions.effectiveGeometry(target.id).x,90);engine.sessions.cancel('example-cancel');await engine.settle();assert.equal(requests.size,writes);assert.equal(engine.getSnapshot().document.elements.get(target.id).geometry.x,32);
const copyId=await engine.imports.duplicate([target],{worldOffset:{x:24,y:24}});await engine.settle();assert.equal(engine.imports.getJobs().get(copyId).status,'completed',JSON.stringify(engine.imports.getJobs().get(copyId)));assert.equal(engine.getSnapshot().document.elements.size,2);assert.equal(server.elements.size,2);
const loaded=await loadFoundryModel(transport,scope.workspaceId,scope.projectId,registry);assert.equal(loaded.status,'ready');assert.equal(loaded.document.elements.size,2);
const snapshot=engine.getSnapshot(),undoDepth=snapshot.history.undo.length;await engine.close();engine=await open();await engine.settle();
assert.equal(engine.getSnapshot().document.elements.size,2);assert.equal(engine.getSnapshot().document.elements.get(target.id).data.count,2);assert.equal(engine.getSnapshot().history.undo.length,undoDepth);assert.equal(engine.sessions.getSnapshot().views.size,0);assert.ok([...engine.getSnapshot().runtime.ledger.operations.values()].every(record=>record.phase==='resolved'));
const current=engine.getSnapshot(),queries=engine.editor.getQueries(),renderers=createRendererCatalog([...builtinRendererDefinitions,{componentId:'example.counter',dataVersion:1,structure:'node',rendererId:'counter',projection:'record-v1'}]);
const projection=projectCanvas(current.document,scope.projectId,registry,new Set(),()=>undefined,null,renderers,{queries,geometry:createGeometryProjectionCache()});assert.equal(projection.nodes.length,2);assert.ok(projection.nodes.every(node=>node.type==='counter'&&node.data.__headlessRecord));
const hit=hitProjectedCanvas(projection,queries,registry,'portable-view',{space:'scene',point:{x:40,y:10},zoom:1});assert.equal(hit.complete,true);assert.ok(hit.hits.some(hit=>hit.target.id===target.id));
console.log(JSON.stringify({status:'ok',portableBindings:true,elements:2,count:2,undoDepth,reopened:true,transientViews:0,protocol:2,requests:requests.size,sourceFiles:files.size}));
}finally{await engine.close();storage.close();}
+67
View File
@@ -0,0 +1,67 @@
{
"name": "@endoleap/headless-canvas",
"version": "0.1.0",
"private": true,
"type": "module",
"engines": {
"node": ">=22"
},
"exports": {
".": {
"types": "./modules/canvas-core/index.d.ts",
"import": "./modules/canvas-core/index.js"
},
"./core": {
"types": "./modules/canvas-core/index.d.ts",
"import": "./modules/canvas-core/index.js"
},
"./runtime": {
"types": "./modules/canvas-runtime/index.d.ts",
"import": "./modules/canvas-runtime/index.js"
},
"./browser": {
"types": "./modules/canvas-runtime/browser.d.ts",
"import": "./modules/canvas-runtime/browser.js"
},
"./service": {
"types": "./modules/canvas-service/index.d.ts",
"import": "./modules/canvas-service/index.js"
},
"./bindings": {
"types": "./modules/canvas-bindings/index.d.ts",
"import": "./modules/canvas-bindings/index.js"
},
"./react-flow": {
"types": "./modules/canvas-bindings/react-flow.d.ts",
"import": "./modules/canvas-bindings/react-flow.js"
},
"./react": {
"types": "./modules/canvas-bindings/react.d.ts",
"import": "./modules/canvas-bindings/react.js"
}
},
"files": [
"modules",
"consumer.mjs",
"README.md",
"STEP4_ACCEPTANCE.md",
"SERVICE_ACCEPTANCE.md",
"FINAL_ACCEPTANCE.md"
],
"peerDependencies": {
"react": ">=19.3.0 <20",
"react-dom": ">=19.3.0 <20",
"@xyflow/react": ">=12.12.1 <13"
},
"peerDependenciesMeta": {
"react": {
"optional": true
},
"react-dom": {
"optional": true
},
"@xyflow/react": {
"optional": true
}
}
}
+15
View File
@@ -1,6 +1,21 @@
import type { NextConfig } from "next";
const config: NextConfig = {
reactStrictMode: true, poweredByHeader: false,
webpack(config) {
config.module.rules.push({
test: /[\\/]@xyflow[\\/]react[\\/]dist[\\/]esm[\\/]index\.(?:mjs|js)$/,
use: [{ loader: require.resolve("./scripts/loaders/xyflow-retained.cjs") }],
});
return config;
},
turbopack: {
rules: {
"*": {
condition: { path: /[\\/]@xyflow[\\/]react[\\/]dist[\\/]esm[\\/]index\.(?:mjs|js)$/ },
loaders: ["./scripts/loaders/xyflow-retained.cjs"],
},
},
},
async headers() {
return [{
source: "/canvas-icons/:version/:asset([a-z0-9-]+-[a-f0-9]{12}\\.json)",
+8 -1
View File
@@ -6,7 +6,14 @@
"dev": "next dev --hostname localhost --port 17321",
"build": "next build",
"start": "next start --hostname localhost --port 17321",
"typecheck": "tsc --noEmit"
"typecheck": "tsc --noEmit",
"test:headless": "node --test tests/headless/*.test.mjs",
"test:headless:browser": "node tests/headless/browser-storage.mjs",
"test:headless:proxy": "node tests/headless/proxy.integration.mjs",
"test:headless:renderer": "node tests/headless/renderer.integration.mjs",
"test:headless:performance": "node tests/headless/renderer.integration.mjs --performance",
"build:headless": "node scripts/build-headless.mjs",
"test:headless:package": "node tests/headless/package.integration.mjs"
},
"dependencies": {
"@radix-ui/react-dialog": "1.3.1",
+30
View File
@@ -0,0 +1,30 @@
import { execFileSync } from 'node:child_process';
import { cp, readdir, readFile, rm, writeFile } from 'node:fs/promises';
import { fileURLToPath } from 'node:url';
import { join, resolve } from 'node:path';
const root=resolve(fileURLToPath(new URL('..',import.meta.url))),output=join(root,'.headless-dist');
await rm(output,{recursive:true,force:true});
execFileSync(process.execPath,[join(root,'node_modules/typescript/bin/tsc'),'-p',join(root,'tsconfig.headless.json')],{cwd:root,stdio:'inherit'});
async function declarations(directory){
for(const entry of await readdir(directory,{withFileTypes:true})){
const path=join(directory,entry.name);
if(entry.isDirectory())await declarations(path);
else if(entry.name.endsWith('.d.ts')){
const source=await readFile(path,'utf8');
// Native TS rewrites runtime imports, but leaves source suffixes in declarations.
// Only module specifiers change; data/schema string literals remain intact.
const next=source.replace(/((?:from\s+|import\s*\()\s*['"])(\.\.?\/[^'"]+)\.ts(['"])/g,'$1$2.js$3');
await writeFile(path,next);
}
}
}
await declarations(join(output,'modules'));
for(const [module,worker]of [['browser-digests','digest.worker'],['browser-images','image.worker']]){
const path=join(output,'modules/canvas-runtime',`${module}.js`),source=await readFile(path,'utf8'),before=`./${worker}.ts`;
if(source.split(before).length!==2)throw Error(`Unexpected Worker URL in ${module}`);
await readFile(join(output,'modules/canvas-runtime',`${worker}.js`));
await writeFile(path,source.replace(before,`./${worker}.js`));
}
for(const name of ['package.json','consumer.mjs','README.md','STEP4_ACCEPTANCE.md','SERVICE_ACCEPTANCE.md','FINAL_ACCEPTANCE.md'])await cp(join(root,'headless',name),join(output,name));
console.log('Built .headless-dist: ESM, declarations, browser adapters and Worker modules');
+23
View File
@@ -0,0 +1,23 @@
// App-only extension for the pinned @xyflow/react 12.12.1 ESM renderer. This
// transforms compiler input; shared node_modules and the headless SDK are untouched.
const version = require('@xyflow/react/package.json').version;
module.exports = function retainVisibility(source) {
if (version !== '12.12.1') throw new Error('Review xyflow retained visibility before upgrading @xyflow/react');
const nodes = 'getNodesInside(s.nodeLookup, { x: 0, y: 0, width: s.width, height: s.height }, s.transform, true).map((node) => node.id)';
const edges = 'isEdgeVisible({\n sourceNode,';
const selection = 'const changeSelector = (s) => !!s.onSelectionChangeHandlers;';
const close = 'transform: s.transform,\n })) {';
if (source.includes('__endoleapRetain') || source.split(nodes).length !== 2 || source.split(edges).length !== 2 || source.split(close).length !== 2 || source.split(selection).length !== 2) {
throw new Error('Unsupported xyflow visibility implementation; retained editors must not silently lose virtualization');
}
return source
.replace(nodes, `Array.from(new Set([
...${nodes},
...s.nodes.filter((node) => node.__endoleapRetain === true && !node.hidden).map((node) => node.id),
]))`)
.replace(edges, '(edge.__endoleapRetain === true || isEdgeVisible({\n sourceNode,')
.replace(close, 'transform: s.transform,\n }))) {')
// An empty handler array is truthy; do not mount the full-graph selector
// when neither the prop nor a useOnSelectionChange subscriber needs it.
.replace(selection, 'const changeSelector = (s) => !!s.onSelectionChangeHandlers?.length;');
};
+16 -9
View File
@@ -4,8 +4,8 @@ import { NextRequest, NextResponse } from "next/server";
const allowed =
/^(profile|auth\/(login|register|verify-email|exchange|me|logout)|workspaces(?:\/[a-zA-Z0-9_-]+(?:\/(?:materials(?:\/[a-zA-Z0-9_-]+(?:\/canvas)?)?|search))?)?)$/;
const canvasRoute = /^workspaces\/[a-zA-Z0-9_-]+\/materials\/[a-zA-Z0-9_-]+\/canvas(?:\/(?:connections|capabilities|subgraphs(?:\/(?:migrate|[a-zA-Z0-9_-]+\/(?:members|dissolve)))?|transactions\/[a-zA-Z0-9_-]+\/undo|ai\/(?:models|runs(?:\/[a-zA-Z0-9_-]+(?:\/(?:cancel|apply))?)?)))?$/;
const assetRoute = /^workspaces\/[a-zA-Z0-9_-]+\/assets(?:\/[a-zA-Z0-9_-]+)?$/;
const canvasRoute = /^workspaces\/[a-zA-Z0-9_-]+\/materials\/[a-zA-Z0-9_-]+\/canvas(?:\/(?:model(?:\/(?:migrate|operations(?:\/[a-zA-Z0-9_-]+)?))?|operations(?:\/[a-zA-Z0-9_-]+)?|connections|capabilities|subgraphs(?:\/(?:migrate|[a-zA-Z0-9_-]+\/(?:members|dissolve)))?|transactions\/[a-zA-Z0-9_-]+\/undo|ai\/(?:models|operations\/[a-zA-Z0-9_-]+|runs(?:\/[a-zA-Z0-9_-]+(?:\/(?:cancel|apply))?)?)))?$/;
const assetRoute = /^workspaces\/[a-zA-Z0-9_-]+\/assets(?:\/(?:(?:operations|metadata)\/[a-zA-Z0-9_-]+|[a-zA-Z0-9_-]+))?$/;
const bodyLimit = 1024 * 1024;
// Route Handlers do not have the Pages Router bodyParser size limit.
@@ -53,12 +53,16 @@ async function handler(
}
const path = (await params).path.join("/");
if (!allowed.test(path) && !canvasRoute.test(path) && !assetRoute.test(path)) return json({ error: "not_found" }, 404);
const asset = assetRoute.test(path);
const assetReceipt = assetRoute.test(path) && /\/assets\/operations\//.test(path);
const assetMetadata = assetRoute.test(path) && /\/assets\/metadata\//.test(path);
const asset = assetRoute.test(path) && !assetReceipt && !assetMetadata;
const canvas = canvasRoute.test(path);
const canonical = canvas && /\/canvas\/model(?:\/|$)/.test(path);
const frozenAI = canvas && /\/canvas\/ai\/runs(?:\/|$)/.test(path);
let requestBody: unknown;
if (req.method !== "GET") {
try {
requestBody = await readBody(req, asset ? 21 * 1024 * 1024 : path.endsWith("/canvas") ? 5 * 1024 * 1024 : bodyLimit, asset);
requestBody = await readBody(req, asset ? 21 * 1024 * 1024 : canonical || path.endsWith("/canvas") || path.endsWith("/canvas/operations") ? 5 * 1024 * 1024 : bodyLimit, asset || canonical || frozenAI);
} catch (error) {
const tooLarge = error instanceof Error && error.message === "body_too_large";
return json({ error: tooLarge ? "body_too_large" : "invalid_json" }, tooLarge ? 413 : 400);
@@ -69,9 +73,11 @@ async function handler(
const project = process.env.NEXT_PUBLIC_FOUNDRY_PROJECT || "endoleap";
const headers: Record<string, string> = { "Content-Type": asset ? (req.headers.get("content-type") || "application/octet-stream") : "application/json" };
if (canvas) headers.Accept = "application/vnd.foundry.canvas+json;version=2";
const modelStream = canonical && path.endsWith('/canvas/model') && req.method==='GET' && req.headers.get('accept')==='application/vnd.foundry.canvas-model+ndjson;version=1';
if(modelStream)headers.Accept='application/vnd.foundry.canvas-model+ndjson;version=1';
const session = req.cookies.get("foundry_session")?.value;
if (session) headers.Authorization = "Bearer " + session;
for (const h of ["x-foundry-code", "x-foundry-verifier", "if-match", "idempotency-key"]) {
for (const h of ["x-foundry-code", "x-foundry-verifier", "if-match", "idempotency-key", "x-expected-account", "x-resource-id"]) {
const value = req.headers.get(h);
if (value !== null) headers[h] = value;
}
@@ -81,11 +87,12 @@ async function handler(
if (values.length === 1 && values[0].length < 300) query.set(key, values[0]);
}
const encodingStarted = performance.now();
const outboundBody = req.method === "GET" ? undefined : asset ? requestBody as BodyInit : JSON.stringify(requestBody || {});
// Frozen document and AI requests cross the proxy byte-for-byte.
const outboundBody = req.method === "GET" ? undefined : asset || canonical || frozenAI ? requestBody as BodyInit : JSON.stringify(requestBody || {});
const encodeMs = performance.now() - encodingStarted;
const started = performance.now();
try {
const snapshotRead = canvas && path.endsWith("/canvas") && req.method === "GET";
const snapshotRead = canvas && (path.endsWith("/canvas") || canonical) && req.method === "GET";
const streamedRead = snapshotRead || asset && req.method === "GET";
const snapshotWrite = canvas && path.endsWith("/canvas") && req.method === "PATCH";
const traceId = streamedRead || snapshotWrite ? crypto.randomUUID() : "";
@@ -128,8 +135,8 @@ async function handler(
const headersReceived = performance.now();
// Snapshots contain no login exchange payload. Forward them without buffering
// and parsing/re-encoding multi-megabyte inline image JSON in the proxy.
if (canvas && path.endsWith("/canvas") && req.method === "GET" && upstream.ok) {
responseHeaders.set("Content-Type", "application/json; charset=utf-8");
if (canonical || canvas && path.endsWith("/canvas") && req.method === "GET" && upstream.ok) {
responseHeaders.set("Content-Type", modelStream && upstream.ok && upstream.headers.get('content-type')?.startsWith('application/vnd.foundry.canvas-model+ndjson;') ? upstream.headers.get('content-type')! : "application/json; charset=utf-8");
responseHeaders.set("Server-Timing", `upstream;dur=${(headersReceived - started).toFixed(1)}, proxy_headers;dur=${(performance.now() - headersReceived).toFixed(1)}`);
return new NextResponse(upstream.body, { status: upstream.status, headers: responseHeaders });
}
+10 -7
View File
@@ -12,6 +12,7 @@ import { useActiveBoard } from "@/components/workspace/active-board";
import toolbar from "@/components/ui/toolbar.module.css";
import { EditableTitle } from "@/components/workspace/editable-title";
import { UserAvatar } from "@/components/workspace/user-avatar";
import { useCanvasRuntimeHost } from "@/components/workspace/canvas-runtime-host";
import { AccountPanel } from "@/components/workspace/account-panel";
import { readDemoBoards } from "@/lib/demo-workspace";
import { loadBoardDirectory } from "@/lib/board-directory";
@@ -20,6 +21,7 @@ import styles from "@/components/workspace/workspace.module.css";
export default function DashboardSidebar() {
const router = useRouter();
const runtimeHost = useCanvasRuntimeHost();
const { board: activeBoard, setBoard: setActiveBoard } = useActiveBoard();
const reduceMotion = useReducedMotion();
const params = useParams<{ workspace?: string; board?: string }>();
@@ -33,6 +35,7 @@ export default function DashboardSidebar() {
const [status, setStatus] = useState<"loading" | "ready" | "error">("loading");
const [error, setError] = useState("");
const [collapsed, setCollapsed] = useState(false);
const [accountAnchor, setAccountAnchor] = useState<"sidebar" | "floating">("sidebar");
const [query, setQuery] = useState("");
const [creating, setCreating] = useState(false);
const [panel, setPanel] = useState<"settings" | "workspace" | null>(null);
@@ -139,7 +142,7 @@ export default function DashboardSidebar() {
async function logout() {
try {
if (!demo) await api("auth/logout", { method: "POST" });
if (!demo) { await runtimeHost.retireIdentity(); await api("auth/logout", { method: "POST" }); }
router.push("/login");
} catch (e) { setError(errorText(e)); }
}
@@ -172,13 +175,13 @@ export default function DashboardSidebar() {
return (
<>
<AnimatePresence initial={false}>
{collapsed && (
<motion.div key="sidebar-controls" className={`${toolbar.surface} ${toolbar.relaxed} ${styles.floating}`} aria-label="侧边栏快捷操作"
{(collapsed || Boolean(params.board)) && (
<motion.div key="sidebar-controls" className={`${toolbar.surface} ${toolbar.relaxed} ${styles.floating}`} data-sidebar-collapsed={collapsed} aria-label={params.board ? "画布快捷操作" : "侧边栏快捷操作"}
initial={{ opacity: 0, y: reduceMotion ? 0 : -6 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: reduceMotion ? 0 : -6 }}
transition={{ duration: reduceMotion ? 0 : 0.14 }}>
<IconButton ref={expandButton} icon={PanelLeftOpen} label="展开侧边栏" aria-expanded={false} aria-controls="workspace-sidebar" onClick={() => toggleSidebar(false)} />
{collapsed && <IconButton ref={expandButton} icon={PanelLeftOpen} label="展开侧边栏" aria-expanded={false} aria-controls="workspace-sidebar" onClick={() => toggleSidebar(false)} />}
<Link href="/" className={styles.logoLink} aria-label="啡跃 Endoleap 主页" title="返回主页">
<img src="/brand/png/logo-light/endoleap-32.png"
srcSet="/brand/png/logo-light/endoleap-32.png 1x, /brand/png/logo-light/endoleap-64.png 2x, /brand/png/logo-light/endoleap-128.png 4x"
@@ -189,7 +192,7 @@ export default function DashboardSidebar() {
editable={Boolean(params.board && activeBoard && matchesRef(activeBoard, params.board) && activeBoard.workspace_id === workspace && canCreate)}
onSave={renameTitle} className={styles.floatingTitle} />
<span className={toolbar.divider} aria-hidden="true" />
<button type="button" className={styles.floatingAvatarButton} popoverTarget="workspace-account" aria-label="账户和工作空间">
<button type="button" className={styles.floatingAvatarButton} popoverTarget="workspace-account" aria-label="账户和工作空间" onClick={() => setAccountAnchor("floating")}>
<UserAvatar name={name} url={profile?.avatar_url} />
</button>
{error && <p className={styles.floatingError} role="alert">{error}</p>}
@@ -220,7 +223,7 @@ export default function DashboardSidebar() {
</nav>
{error && <p className={styles.error} role="alert">{error}</p>}
<div className={styles.accountArea}>
<button type="button" className={styles.accountButton} popoverTarget="workspace-account" aria-label="账户和工作空间">
<button type="button" className={styles.accountButton} popoverTarget="workspace-account" aria-label="账户和工作空间" onClick={() => setAccountAnchor("sidebar")}>
<UserAvatar name={name} url={profile?.avatar_url} />
<span className={styles.accountText}><strong>{name}</strong><small>{currentWorkspace?.name || (demo ? "本地演示" : "个人账户")}</small></span>
<ChevronsUpDown size={14} strokeWidth={1.6} />
@@ -228,7 +231,7 @@ export default function DashboardSidebar() {
</div>
</motion.aside>
</motion.div>
<div ref={accountPopup} id="workspace-account" popover="auto" className={styles.accountPopup} data-floating={collapsed} aria-label="账户和工作空间菜单">
<div ref={accountPopup} id="workspace-account" popover="auto" className={styles.accountPopup} data-floating={accountAnchor === "floating"} data-sidebar-collapsed={collapsed} aria-label="账户和工作空间菜单">
<div className={styles.accountHeading}>
<span className={styles.workspaceAvatar} aria-hidden="true">{(currentWorkspace?.name || "啡跃").slice(0, 1)}</span>
<div><strong>{currentWorkspace?.name || "你的工作空间"}</strong><small>{roleLabel}{demo ? " · 本地演示" : ""}</small></div>
+2 -1
View File
@@ -1,3 +1,4 @@
import { CanvasRuntimeHost } from "@/components/workspace/canvas-runtime-host";
import { AppFeedback } from "@/components/ui/app-feedback";
import type { Metadata } from "next";
import type { ReactNode } from "react";
@@ -19,7 +20,7 @@ export const metadata: Metadata = {
export default function RootLayout({ children }: { children: ReactNode }) {
return (
<html lang="zh-CN" data-scroll-behavior="smooth">
<body><AppFeedback>{children}</AppFeedback></body>
<body><AppFeedback><CanvasRuntimeHost>{children}</CanvasRuntimeHost></AppFeedback></body>
</html>
);
}
+45 -21
View File
@@ -1,25 +1,24 @@
"use client";
import { useContext, useRef, useState, type PointerEvent } from "react";
import { useContext, useEffect, useRef, useState, type PointerEvent } from "react";
import { BaseEdge, useInternalNode, useReactFlow, useViewport, type ConnectionLineComponentProps, type EdgeProps, type InternalNode } from "@xyflow/react";
import { useReducedMotion } from "motion/react";
import type { ProjectionNode } from "@/lib/canvas-bindings/projection";
import type { CanvasNode } from "@/lib/canvas";
import { connectorBezier, nodeConnector, type ConnectorSide, type Connector } from "@/lib/canvas-geometry";
import {compatConnector} from "@/lib/canvas-bindings/connectors";
import { connectorBezier, type ConnectorSide, type Connector } from "@/lib/canvas-geometry";
import { curvePath, curveSegments, cubicPoint, insertCurveKnot, minus, plus } from "@/lib/canvas-curves";
import type { CanvasEdgeModel, EdgeMarker } from "@/lib/canvas-v2";
import type { EditingLease } from "./editing-lease";
import { EdgeEditing } from "./edge-editing";
import { EdgeToolbarControls } from "./edge-toolbar";
import {ownsPointerCapture,acquirePointerCapture,releasePointerCapture} from "@/lib/canvas-bindings/pointer-capture";
import styles from "./canvas.module.css";
function connector(node: InternalNode<CanvasNode>, handle: string | null | undefined, fallback: ConnectorSide): Connector {
const port = node.data.ports?.find((port) => `port:${port.id}` === handle);
const side = handle === "top" || handle === "right" || handle === "bottom" || handle === "left" ? handle : port?.direction === "input" ? "left" : port ? "right" : fallback;
const result = nodeConnector({ ...node, position: node.internals.positionAbsolute }, side);
if (port) {
const peers = node.data.ports!.filter((item) => item.direction === port.direction);
result.point.y += ((peers.indexOf(port) + 1) / (peers.length + 1) - .5) * (node.measured?.height || node.height || 400);
}
return result;
function connector(node:InternalNode<CanvasNode>,handle:string|null|undefined,fallback:ConnectorSide):Connector{return compatConnector({...node,position:node.internals.positionAbsolute},handle,fallback);}
function nativeConnector(x:number,y:number,side:ConnectorSide):Connector{
const normals={top:{x:0,y:-1},right:{x:1,y:0},bottom:{x:0,y:1},left:{x:-1,y:0}};
return {point:{x,y},normal:normals[side]};
}
function Marker({ id, value, color }: { id: string; value?: EdgeMarker; color: string }) {
const type = value?.type || "none";
@@ -35,9 +34,12 @@ export function CanvasEdge(props: EdgeProps<CanvasEdgeModel>) {
const { zoom } = useViewport();
const reduce = useReducedMotion();
const [selectedKnot, setSelectedKnot] = useState<string | null>(null);
const gesture = useRef<{ id: string; part: "position" | "in" | "out" | "startControl" | "endControl" } | null>(null);
const gesture = useRef<{ capture: Element; controller: AbortController; pointerId: number; lease?: EditingLease<NonNullable<CanvasEdgeModel['data']>['path']>; id: string; part: "position" | "in" | "out" | "startControl" | "endControl" } | null>(null);
const pointerHandlers = useRef<{ move(event: PointerEvent<SVGCircleElement>): void; stop(event: PointerEvent<SVGCircleElement>): void; cancel(event: PointerEvent<SVGCircleElement>): void } | null>(null);
const interrupt=useRef(()=>{});interrupt.current=()=>cancel();
useEffect(()=>{const blur=()=>interrupt.current();window.addEventListener('blur',blur);return()=>{window.removeEventListener('blur',blur);interrupt.current();};},[]);
if (!source || !target) return null;
const from = connector(source, props.sourceHandleId, "right"), to = connector(target, props.targetHandleId, "left");
const from = (source.data as ProjectionNode['data']).__headlessRecord ? nativeConnector(props.sourceX,props.sourceY,props.sourcePosition) : connector(source, props.sourceHandleId, "right"), to = (target.data as ProjectionNode['data']).__headlessRecord ? nativeConnector(props.targetX,props.targetY,props.targetPosition) : connector(target, props.targetHandleId, "left");
const data = props.data || {}, appearance = data.appearance || {}, animation = data.animation || {};
const segments = curveSegments(from, to, data.path), path = curvePath(segments);
const midpoint = cubicPoint(segments[Math.floor(segments.length / 2)], .5);
@@ -51,13 +53,23 @@ export function CanvasEdge(props: EdgeProps<CanvasEdgeModel>) {
editing.patch(props.id, { ...data, path: next.path }); setSelectedKnot(next.knot.id);
}
function start(event: PointerEvent<SVGCircleElement>, id: string, part: NonNullable<typeof gesture.current>["part"]) {
event.preventDefault(); event.stopPropagation(); editing.begin(props.id); gesture.current = { id, part }; setSelectedKnot(id);
event.currentTarget.setPointerCapture(event.pointerId);
if(event.button!==0||event.defaultPrevented)return;if(gesture.current){if(gesture.current.pointerId!==event.pointerId)cancel();return;}if(event.isPrimary===false)return;
// The SVG control can be replaced by a projection update. The view surface survives it.
const capture = event.currentTarget.closest('[data-headless-canvas]') ?? event.currentTarget.closest('.react-flow') ?? event.currentTarget;
const lease = editing.begin(props.id); if (lease === false) return;
const controller = new AbortController();
gesture.current = { id, part, capture, controller, pointerId: event.pointerId, lease: lease && typeof lease === 'object' ? lease : undefined }; setSelectedKnot(id);
const owned = (input: Event) => (input as globalThis.PointerEvent).pointerId === event.pointerId;
capture.addEventListener('pointermove', input => { if (owned(input)) pointerHandlers.current?.move(input as unknown as PointerEvent<SVGCircleElement>); }, { signal: controller.signal });
capture.addEventListener('pointerup', input => { if (owned(input)) pointerHandlers.current?.stop(input as unknown as PointerEvent<SVGCircleElement>); }, { signal: controller.signal });
for (const name of ['pointercancel', 'lostpointercapture']) capture.addEventListener(name, input => { if (owned(input) && gesture.current) pointerHandlers.current?.cancel(input as unknown as PointerEvent<SVGCircleElement>); }, { signal: controller.signal });
if(!acquirePointerCapture(capture,event.pointerId)){cancel();return;}event.preventDefault();event.stopPropagation();
}
function move(event: PointerEvent<SVGCircleElement>) {
const current = gesture.current;
if (!current) return;
event.stopPropagation();
if(!current)return;event.stopPropagation();
if (!ownsPointerCapture(current,event)) return;
if(!current)return;
const point = flow.screenToFlowPosition({ x: event.clientX, y: event.clientY });
const next = structuredClone(data.path || { type: "bezier-chain" as const, knots: [] });
if (current.part === "startControl" || current.part === "endControl") next[current.part] = minus(point, current.part === "startControl" ? from.point : to.point);
@@ -76,14 +88,26 @@ export function CanvasEdge(props: EdgeProps<CanvasEdgeModel>) {
}
}
}
editing.patch(props.id, { ...data, path: next }, true);
if (current.lease) current.lease.preview(next); else editing.patch(props.id, { ...data, path: next }, true);
}
function stop(event: PointerEvent<SVGCircleElement>) {
event.stopPropagation(); gesture.current = null;
if (event.currentTarget.hasPointerCapture(event.pointerId)) event.currentTarget.releasePointerCapture(event.pointerId);
if(!gesture.current)return;event.stopPropagation();
if (!ownsPointerCapture(gesture.current,event)) return; const current = gesture.current!; move(event); gesture.current = null; current.controller.abort();
if (current.lease) current.lease.commit(); else editing.finish?.();
releasePointerCapture(current.capture,current.pointerId);
}
function cancel(event?:{pointerId:number;type?:string;target?:unknown;stopPropagation?():void}) {
if(event&&gesture.current)event.stopPropagation?.();
if(event&&!ownsPointerCapture(gesture.current,event))return;
const current = gesture.current; gesture.current = null;
if (!current) return;
current.controller.abort();
if (current.lease) current.lease.cancel(); else editing.cancel?.();
releasePointerCapture(current.capture,current.pointerId);
}
pointerHandlers.current = { move, stop, cancel };
const control = (point: { x: number; y: number }, id: string, part: NonNullable<typeof gesture.current>["part"], label: string) => <circle key={part + id} cx={point.x} cy={point.y} r={(part === "position" ? 5 : 3.5) / zoom} className={styles.curveControl} fill={part === "position" ? "#fffefb" : "#a47764"} stroke="#a47764" strokeWidth={1 / zoom} role="button" aria-label={label} tabIndex={0}
onPointerDown={(event) => start(event, id, part)} onPointerMove={move} onPointerUp={stop} onPointerCancel={stop} onLostPointerCapture={() => { gesture.current = null; }} onClick={(event) => { event.stopPropagation(); setSelectedKnot(id); }}
onPointerDown={(event) => start(event, id, part)} onPointerMove={move} onPointerUp={stop} onPointerCancel={cancel} onLostPointerCapture={(event) => { if (gesture.current) cancel(event); }} onClick={(event) => { event.stopPropagation(); setSelectedKnot(id); }}
onKeyDown={(event) => {
event.stopPropagation(); const delta = { ArrowLeft: [-1, 0], ArrowRight: [1, 0], ArrowUp: [0, -1], ArrowDown: [0, 1] }[event.key];
if (delta) { event.preventDefault(); const next = structuredClone(data.path || { type: "bezier-chain" as const, knots: [] }); const vector = { x: delta[0] * (event.shiftKey ? 10 : 1), y: delta[1] * (event.shiftKey ? 10 : 1) };
+20
View File
@@ -0,0 +1,20 @@
"use client";
import {createContext,useContext,useCallback,useLayoutEffect,useId,useRef} from 'react';
import {useViewport} from '@xyflow/react';
import type {OverlayIndex} from '@/lib/canvas-bindings/overlays';
import type {ProjectEngine} from '@/lib/canvas-runtime/workspace';
export const CanvasOverlays=createContext<{index:OverlayIndex;engine:ProjectEngine}|null>(null);
/** Optional for legacy/engine-less renderers; a portal keeps its explicit element target. */
export function useCanvasOverlay(targetId:string,placement:string){
const scope=useContext(CanvasOverlays),instance=useId(),refresh=useRef<()=>void>(()=>{}),cleanup=useRef<()=>void>(()=>{});
useViewport();
const incarnation=scope?.engine.getSnapshot().document.elements.get(targetId)?.incarnation;
const ref=useCallback((element:HTMLElement|null)=>{cleanup.current();refresh.current=()=>{};cleanup.current=()=>{};if(!element||!scope||incarnation===undefined)return;
const marker=crypto.randomUUID();element.dataset.canvasOverlayView=scope.index.viewId;element.dataset.canvasOverlayOwner=marker;element.dataset.canvasOverlayTarget=targetId;element.dataset.canvasOverlayPlacement=placement;
let composing=false;const value=()=>{const rect=element.getBoundingClientRect();return{placement,target:{id:targetId,incarnation},bounds:{x:rect.x-8,y:rect.y-8,width:rect.width+16,height:rect.height+16},focused:element.contains(document.activeElement),composing};};
const lease=scope.index.register(instance,value()),measure=()=>lease.update(value()),start=()=>{composing=true;measure();},end=()=>{composing=false;measure();};refresh.current=measure;
const blur=()=>queueMicrotask(measure),observer=new ResizeObserver(measure);observer.observe(element);element.addEventListener('focusin',measure);element.addEventListener('focusout',blur);element.addEventListener('compositionstart',start);element.addEventListener('compositionend',end);let frame=requestAnimationFrame(measure);
cleanup.current=()=>{if(element.dataset.canvasOverlayOwner===marker){delete element.dataset.canvasOverlayView;delete element.dataset.canvasOverlayOwner;delete element.dataset.canvasOverlayTarget;delete element.dataset.canvasOverlayPlacement;}cancelAnimationFrame(frame);observer.disconnect();element.removeEventListener('focusin',measure);element.removeEventListener('focusout',blur);element.removeEventListener('compositionstart',start);element.removeEventListener('compositionend',end);lease.release();};
},[scope,instance,targetId,incarnation,placement]);
useLayoutEffect(()=>{refresh.current();});useLayoutEffect(()=>()=>cleanup.current(),[]);return ref;
}
@@ -0,0 +1,14 @@
"use client";
import type { NodeProps } from '@xyflow/react';
import type { CanvasNode } from '@/lib/canvas-elements';
import { builtinRendererDefinitions } from '@/lib/canvas-bindings/renderers';
import { createReactRenderers, type ReactRendererDefinition } from '@/lib/canvas-bindings/react-flow';
import { MaterialNode } from './material-node';
import { PlainTextNode } from './plain-text-node';
import { PlainImageNode } from './plain-image-node';
import { FrameNode } from './frame-node';
import { IconNode } from './icon-node';
import { CanvasEdge } from './canvas-edge';
function HeadlessFrameNode(props:NodeProps<CanvasNode>){return <FrameNode {...props} directAnchors />;}
const nodes={material:MaterialNode,plainText:PlainTextNode,plainImage:PlainImageNode,frame:HeadlessFrameNode,icon:IconNode};
export const defaultCanvasRenderers=createReactRenderers(builtinRendererDefinitions.map(definition=>{const render=definition.structure==='node'?nodes[definition.rendererId as keyof typeof nodes]:CanvasEdge;return{...definition,render,preview:render} as ReactRendererDefinition;}));
+4 -4
View File
@@ -1,14 +1,14 @@
"use client";
import { useContext, useEffect, useRef, useState } from "react";
import { useEffect, useRef, useState } from "react";
import type { CanvasNodeData } from "@/lib/canvas-elements";
import { NodeEditing } from "./transformable-node";
import {useScopedNodeEditing} from "./node-editing";
import { textAppearance } from "./text-appearance";
import styles from "./canvas.module.css";
/** Single clicks select; native double-click timing controls entry into editing. */
export function CanvasTextEditor({ id, data, selected, plain = false }: { id: string; data: CanvasNodeData; selected: boolean; plain?: boolean }) {
const actions = useContext(NodeEditing);
const actions = useScopedNodeEditing(id,data.__headlessTarget);
const [editing, setEditing] = useState(false);
const textarea = useRef<HTMLTextAreaElement>(null);
const press = useRef({ x: 0, y: 0, moved: false });
@@ -27,7 +27,7 @@ export function CanvasTextEditor({ id, data, selected, plain = false }: { id: st
aria-label={label} readOnly={!active} tabIndex={active ? 0 : -1}
placeholder={plain ? "写下文字…" : data.kind === "script" ? "写下你的脚本…" : "留住一个想法…"}
value={data.text} maxLength={100000} onChange={(event) => actions.edit(id, event.target.value)}
onBlur={() => setEditing(false)} onKeyDown={(event) => { if (event.key === "Escape") { event.preventDefault(); event.stopPropagation(); setEditing(false); event.currentTarget.blur(); } }} />
onBlur={() => { setEditing(false); actions.endEditing?.(id); }} onKeyDown={(event) => { if (event.key === "Escape") { event.preventDefault(); event.stopPropagation(); setEditing(false); event.currentTarget.blur(); } }} />
{!active && <div className={styles.textSelectLayer} role="button" tabIndex={selected ? 0 : -1} aria-label={`单击选中,双击编辑${label}`}
onPointerDown={(event) => { press.current = { x: event.clientX, y: event.clientY, moved: false }; }}
onPointerMove={(event) => { if (Math.hypot(event.clientX - press.current.x, event.clientY - press.current.y) > 4) press.current.moved = true; }}
+14 -1
View File
@@ -21,6 +21,10 @@
.helpPopup p { margin-top: 9px; }
.error { position: absolute; top: 78px; left: 50%; transform: translateX(-50%); max-width: min(500px, calc(100vw - 32px)); padding: 10px 14px; border: 1px solid #e5c7b8; border-radius: 8px; color: #a25439; background: #fff9f3; font-size: 12px; z-index: 15; }
.helpToolbar { position: absolute; right: 12px; bottom: 12px; z-index: 10; }
.migrationPanel { position: absolute; right: 12px; top: 64px; z-index: 220; display: flex; flex-wrap: wrap; gap: 8px; align-items: center; max-width: 360px; padding: 8px; border: 1px solid #ded7ce; border-radius: 7px; background: #fffefb; color: #78523f; font-size: 12px; }
.migrationPanel button { border: 1px solid #ded7ce; border-radius: 5px; padding: 6px 9px; background: white; cursor: pointer; }
.migrationPanel button:disabled { cursor: default; opacity: .5; }
.migrationSurface { position: absolute; inset: 0; }
@media (max-width: 700px) {
.topRight { top: 62px; right: 12px; }
.bottomToolbar { left: 12px; bottom: 12px; transform: none; max-width: calc(100vw - 92px); overflow-x: auto; overscroll-behavior-x: contain; }
@@ -152,7 +156,7 @@
.edgeToolbar button:hover { background: #f1e6df; }
.edgeToolbar button:disabled { opacity: .4; }
.edgeToolbar small { width: 100%; color: #92877c; font-size: 10px; }
.curveControl { cursor: move; touch-action: none; }
.curveControl { cursor: move; touch-action: none; pointer-events: all; }
.edgeDashAnimation { animation: edgeDash 1.6s linear infinite; }
.edgePulseAnimation { animation: edgePulse 1.6s ease-in-out infinite; }
@keyframes edgeDash { to { stroke-dashoffset: -40; } }
@@ -192,3 +196,12 @@
.memberSync { padding: 5px 9px; border: 1px solid #ded7ce; border-radius: 7px; background: #fffefb; color: #796e63; font-size: 11px; box-shadow: 0 2px 8px #372f2410; }
.memberSync button { margin-left: 6px; border: 0; background: transparent; color: #78523f; text-decoration: underline; cursor: pointer; }
.importPanel { position: absolute; top: 84px; right: 12px; z-index: 12; width: max-content; max-width: min(310px, calc(100vw - 28px)); max-height: 45vh; overflow: auto; border: 1px solid #ded7ce; border-radius: 10px; padding: 6px 9px; background: #fffefb; box-shadow: 0 4px 18px #32251a10; color: #49372e; font-size: 11px; }
.importPanel summary { cursor: pointer; padding: 5px 0; }
.importPanel ul { padding-left: 16px; overflow-wrap: anywhere; }
.importPanel p { margin: 5px 0; }
.importPanel small { color: #92877c; }
.importJob { border-top: 1px solid #eee7de; padding-top: 5px; margin-top: 5px; }
.importActions { display: flex; align-items: center; gap: 8px; }
.importActions > button:not(:first-child), .importJob button { color: #78523f; padding: 4px 0; }
+3 -1
View File
@@ -1,4 +1,6 @@
"use client";
import { createContext } from "react";
import type { EditingLease } from "./editing-lease";
import type { EdgeData } from "@/lib/canvas-v2";
export const EdgeEditing = createContext({ begin: (_id?: string) => {}, patch: (_id: string, _data: EdgeData, _transient = false) => {}, active: new Set<string>() });
const defaultEditing = { finish: undefined as undefined | (() => void), cancel: undefined as undefined | (() => void), begin: (_id?: string): boolean | void | EditingLease<NonNullable<EdgeData['path']>> => {}, patch: (_id: string, _data: EdgeData, _transient = false) => {}, active: new Set<string>() };
export const EdgeEditing = createContext<Omit<typeof defaultEditing, 'finish' | 'cancel'> & Partial<Pick<typeof defaultEditing, 'finish' | 'cancel'>>>(defaultEditing);
+1 -1
View File
@@ -9,7 +9,7 @@ export function EdgeToolbarControls({ id, x, y, data, change, add, remove, count
const appearance = (patch: NonNullable<EdgeData["appearance"]>) => change({ ...data, appearance: { ...a, ...patch } });
const animate = (patch: NonNullable<EdgeData["animation"]>) => change({ ...data, animation: { ...animation, ...patch } });
const markers: [MarkerShape, string][] = [["none", "无"], ["open-arrow", "开放箭头"], ["triangle", "三角箭头"], ["circle", "圆形"], ["diamond", "菱形"], ["square", "方形"]];
return <EdgeToolbar edgeId={id} x={x} y={y} isVisible style={{ zIndex: 115, pointerEvents: "auto" }}><div role="toolbar" aria-label="连线编辑" className={`${styles.edgeToolbar} nodrag nopan nowheel`}
return <EdgeToolbar alignY="bottom" edgeId={id} x={x} y={y} isVisible style={{ zIndex: 115, pointerEvents: "auto" }}><div role="toolbar" aria-label="连线编辑" className={`${styles.edgeToolbar} nodrag nopan nowheel`}
onPointerDown={(event) => event.stopPropagation()} onClick={(event) => event.stopPropagation()} onDoubleClick={(event) => event.stopPropagation()} onKeyDown={(event) => event.stopPropagation()}>
<label>颜色<input aria-label="连线颜色" type="color" value={a.color || "#aa968b"} onChange={(event) => appearance({ color: event.target.value })} /></label>
<label>粗细<input aria-label="连线粗细" type="number" min={.5} max={20} step={.5} value={a.width ?? 1.5} onChange={(event) => { const value = +event.target.value; if (value >= .5 && value <= 20) appearance({ width: value }); }} /></label>
+10
View File
@@ -0,0 +1,10 @@
/** A captured pointer may only finish the exact interaction that acquired it. */
export type EditingLease<T> = { preview(value: T): void; commit(): void; cancel(): void };
export function guardEditingLease<T>(isCurrent: () => boolean, actions: EditingLease<T>): EditingLease<T> {
return Object.freeze({
preview(value: T) { if (isCurrent()) actions.preview(value); },
commit() { if (isCurrent()) actions.commit(); },
cancel() { if (isCurrent()) actions.cancel(); },
});
}
+16 -10
View File
@@ -5,14 +5,19 @@ import { FoldVertical, UnfoldVertical, ExternalLink } from "lucide-react";
import { Handle, NodeToolbar, Position, useViewport, type NodeProps } from "@xyflow/react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import type { CanvasNode } from "@/lib/canvas";
import { NodeEditing, TransformableNode, ResizeToggle } from "./transformable-node";
import { TransformableNode, ResizeToggle } from "./transformable-node";
import { FrameFeedback } from "./frame-feedback";
import { FrameLabelMenu } from "./frame-label-menu";
import { ContentHandles } from "./content-handles";
import {useCanvasOverlay} from "./canvas-overlays";
import {useScopedNodeEditing} from "./node-editing";
import styles from "./canvas.module.css";
export function FrameNode(props: NodeProps<CanvasNode>) {
const { id, data, selected } = props;
const editing = useContext(NodeEditing);
export function FrameNode(props: NodeProps<CanvasNode> & { directAnchors?: boolean }) {
const { directAnchors = false, ...nodeProps } = props;
const { id, data, selected } = nodeProps;
const overlay=useCanvasOverlay(id,"frame-settings");
const editing = useScopedNodeEditing(id,data.__headlessTarget);
const feedback = useContext(FrameFeedback);
const incoming = feedback.drops.filter((drop) => drop.to === id && drop.from !== id).length;
const outgoing = feedback.drops.filter((drop) => drop.from === id && drop.to !== id).length;
@@ -28,14 +33,14 @@ export function FrameNode(props: NodeProps<CanvasNode>) {
setTitle(name);
}
return <>
<NodeToolbar style={{ zIndex: 100, pointerEvents: "auto" }} isVisible={true} position={Position.Top} offset={45}>
<AnimatePresence>{selected && <motion.div className={`${styles.styleToolbar} nodrag nopan nowheel`} role="toolbar" aria-label="分区设置"
<AnimatePresence>{selected && <NodeToolbar style={{ zIndex: 100, pointerEvents: "auto" }} isVisible={true} position={Position.Top} offset={45}>
<motion.div ref={overlay} className={`${styles.styleToolbar} nodrag nopan nowheel`} role="toolbar" aria-label="分区设置"
initial={{ opacity: 0, y: reduce ? 0 : 5 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: reduce ? 0 : 5 }} transition={{ duration: reduce ? 0 : 0.14 }}
onPointerDown={(event) => event.stopPropagation()} onClick={(event) => event.stopPropagation()} onDoubleClick={(event) => event.stopPropagation()} onKeyDown={(event) => event.stopPropagation()}>
<input className={styles.frameNameInput} aria-label="分区名称" value={title} maxLength={300} onChange={(event) => setTitle(event.target.value)} onBlur={() => { if (cancelBlur.current) cancelBlur.current = false; else saveTitle(); }}
onKeyDown={(event) => { if (event.key === "Enter" && !event.nativeEvent.isComposing) { event.currentTarget.blur(); } if (event.key === "Escape") { cancelBlur.current = true; setTitle(data.title); event.currentTarget.blur(); } }} />
<FrameLabelMenu value={mode} change={(labelMode) => editing.frame(id, { labelMode })} />
<ResizeToggle id={id} />
<ResizeToggle id={id} renderedTarget={data.__headlessTarget} />
{data.subgraph && <><button type="button" aria-label={data.presentation?.collapsed ? "展开子画布" : "收起子画布"} title={data.presentation?.collapsed ? "展开子画布" : "收起子画布"} onClick={() => {
const collapsed = !data.presentation?.collapsed;
const currentSize = { width: (props.width || 640) / (data.viewScale || 1), height: (props.height || 400) / (data.viewScale || 1) };
@@ -45,13 +50,14 @@ export function FrameNode(props: NodeProps<CanvasNode>) {
<label className={styles.subgraphScale} title="子画布概览比例,不修改内容尺寸"><input aria-label="子画布概览比例" type="number" min={5} max={400} step={5} value={Math.round((data.presentation?.contentScale || 1) * 100)} onChange={(event) => { const scale = +event.target.value / 100; if (scale >= .05 && scale <= 4) editing.frame(id, { presentation: { ...data.presentation, contentScale: scale } }); }} />%</label>
<a href={`/dashboard/${encodeURIComponent(editing.workspace)}/${data.subgraph.boardId}`} aria-label="打开子画布" title="打开子画布"><ExternalLink size={15} /></a></>}
</motion.div>}</AnimatePresence>
</NodeToolbar>
<TransformableNode {...props} hideStyleToolbar className={`${styles.frameNode} ${incoming ? styles.frameDropTarget : ""} ${outgoing ? styles.frameDropExit : ""}`}>
</motion.div>
</NodeToolbar>}</AnimatePresence>
<TransformableNode {...nodeProps} hideStyleToolbar className={`${styles.frameNode} ${incoming ? styles.frameDropTarget : ""} ${outgoing ? styles.frameDropExit : ""}`}>
{data.subgraph && data.presentation?.collapsed && <a className={styles.collapsedSubgraph} href={`/dashboard/${encodeURIComponent(editing.workspace)}/${data.subgraph.boardId}`} onPointerDown={(event) => event.stopPropagation()}><FoldVertical size={22} /><span>{data.title}</span><small>打开子画布</small></a>}
{mode !== "hidden" && <div className={styles.frameLabel} style={{ scale: mode === "fixed" ? 1 / (zoom * (data.viewScale || 1)) : 1 }} title={data.title}>{data.title}</div>}
{(incoming > 0 || outgoing > 0) && <div className={styles.frameDropHint} style={{ scale: 1 / (zoom * (data.viewScale || 1)) }}>{incoming > 0 ? `松开加入 · ${incoming} 项` : `松开移出 · ${outgoing} 项`}</div>}
{(["top", "right", "bottom", "left"] as const).map((side) => <div key={side} className={`${styles.frameBorderHit} ${styles["frameBorder_" + side]}`} title="拖动分区,已有成员一起移动" />)}
{directAnchors && <ContentHandles selected={!!selected} />}
{data.ports?.map((port) => {
const peers = data.ports!.filter((item) => item.direction === port.direction);
return <Handle key={port.id} id={`port:${port.id}`} type="source" position={port.direction === "input" ? Position.Left : Position.Right} className={styles.framePort} style={{ top: `${(peers.indexOf(port) + 1) / (peers.length + 1) * 100}%` }} isConnectableStart={selected && port.direction === "output"} isConnectableEnd={port.direction === "input"} title={port.direction === "input" ? "子画布输入" : "子画布输出"} />;
+21 -8
View File
@@ -1,5 +1,7 @@
"use client";
import { useImageUploads } from "./use-image-uploads";
import {LegacyCanvasMigration} from "./legacy-canvas-migration";
import type {LegacyMigrationContext} from "@/lib/browser-canvas-migration";
import { notify, dismissNotification } from "@/lib/notifications";
import { FeedbackContext } from "@/components/ui/app-feedback";
import { ImagePreviews } from "./canvas-image";
@@ -43,7 +45,7 @@ const edgeTypes = { default: CanvasEdge };
const IconPicker = dynamic(() => import("./icon-picker").then((module) => module.IconPicker), { ssr: false, loading: () => <div role="status" className={styles.iconLoading}>正在加载图标面板…</div> });
const nodeTypes = { material: MaterialNode, plainText: PlainTextNode, icon: IconNode, frame: FrameNode, plainImage: PlainImageNode };
function CanvasEditor({ board, snapshot }: { board: Board; snapshot?: CanvasSnapshot }) {
function CanvasEditor({ board, snapshot, migration }: { board: Board; snapshot?: CanvasSnapshot; migration?:LegacyMigrationContext }) {
const flow = useReactFlow<CanvasNode>();
const { zoom } = useViewport();
const reduceMotion = useReducedMotion();
@@ -80,7 +82,7 @@ function CanvasEditor({ board, snapshot }: { board: Board; snapshot?: CanvasSnap
const imageInput = useRef<HTMLInputElement>(null);
const projectInput = useRef<HTMLInputElement>(null);
const [recovery, setRecovery] = useState(initial.recovery);
const tree = useCanvasTree(board.id, board.workspace_id, initial.document, snapshot, Boolean(recovery), board.parent_board_id);
const tree = useCanvasTree(board.id, board.workspace_id, initial.document, snapshot, Boolean(recovery), board.parent_board_id, migration);
const childRecovery = [...tree.recoveries][0];
const downloadDraft = (document: import("@/lib/canvas").CanvasDocument) => { const url = URL.createObjectURL(new Blob([JSON.stringify(document, null, 2)], { type: "application/json" })); const link = window.document.createElement("a"); link.href = url; link.download = "画布本地草稿.json"; link.click(); setTimeout(() => URL.revokeObjectURL(url), 1000); };
const sync = tree;
@@ -89,6 +91,7 @@ function CanvasEditor({ board, snapshot }: { board: Board; snapshot?: CanvasSnap
const treeRef = useRef(tree); treeRef.current = tree;
const uploads = useImageUploads(board.workspace_id, (job, asset) => treeRef.current.resolveImageUpload(job.node.id, asset,{width:job.node.width!,height:job.node.height!}));
const imageUploading = demoImageUploading;
const ioActive=useRef(false);
useEffect(() => {
const current = treeRef.current;
for (const job of uploads.jobs) {
@@ -322,6 +325,7 @@ function CanvasEditor({ board, snapshot }: { board: Board; snapshot?: CanvasSnap
const file = event.target.files?.[0];
event.target.value = "";
if (!file) { pendingImage.current = null; return; }
if(treeRef.current.isMigrationHeld()){pendingImage.current=null;setError("画布交接期间暂时不能添加图片。");return;}
if (!["image/png", "image/jpeg", "image/webp", "image/gif"].includes(file.type) || file.size > (snapshot ? 20 * 1024 * 1024 : 2_000_000)) {
pendingImage.current = null; setError(snapshot ? "请选择小于 20 MiB 的 PNG、JPEG、WebP 或 GIF 图片。" : "演示模式请选择小于 2 MB 的图片。"); return;
}
@@ -370,6 +374,8 @@ function CanvasEditor({ board, snapshot }: { board: Board; snapshot?: CanvasSnap
const file = event.target.files?.[0];
event.target.value = "";
if (!file) return;
if(treeRef.current.isMigrationHeld()){setError("画布交接期间暂时不能导入。");return;}
ioActive.current=true;
try {
if (file.size > 5_000_000) throw new Error("请选择小于 5 MB 的图谱文件。");
if (tree.rootDocument.nodes.some((node) => node.data.subgraph)) throw new Error("当前画布包含真实子图,请先导出备份并通过子图事务恢复,避免覆盖子图关系。");
@@ -379,6 +385,7 @@ function CanvasEditor({ board, snapshot }: { board: Board; snapshot?: CanvasSnap
void flow.setViewport(document.viewport);
setError("");
} catch (error) { setError(error instanceof Error ? error.message : "导入失败。"); }
finally{ioActive.current=false;}
}
async function exportProject() {
let documents: Map<string, import("@/lib/canvas").CanvasDocument>;
@@ -416,6 +423,7 @@ function CanvasEditor({ board, snapshot }: { board: Board; snapshot?: CanvasSnap
<NodeEditing.Provider value={editing}>
<FrameFeedback.Provider value={frameFeedback}>
<EdgeEditing.Provider value={{ begin: beginEdit, patch: tree.patchEdge, active: new Set([...tree.views.edgeRefs].filter(([id, ref]) => ai.activeEdges.has(ref.boardId + "/" + ref.id)).map(([id]) => id)) }}>
<div className={styles.migrationSurface} inert={tree.migrationHeld}>
<ReactFlow<CanvasNode> nodes={nodes} edges={edges} nodeTypes={nodeTypes} edgeTypes={edgeTypes}
onBeforeDelete={async ({ nodes: removing }) => {
if (tree.busy || tree.transactionBusy) return false;
@@ -483,6 +491,7 @@ function CanvasEditor({ board, snapshot }: { board: Board; snapshot?: CanvasSnap
return owned && <AIPanel key={node.id} id={node.id} node={owned} workspace={board.workspace_id} boardId={ref.boardId} ai={ai} save={(config) => tree.nodeAction(node.id, (id) => ({ type: "node-data", id, patch: { ai: config } }))} />;
})}
</ReactFlow>
</div>
</EdgeEditing.Provider>
</FrameFeedback.Provider>
</NodeEditing.Provider>
@@ -500,7 +509,7 @@ function CanvasEditor({ board, snapshot }: { board: Board; snapshot?: CanvasSnap
<IconButton icon={Download} label="导出图谱" onClick={exportProject} />
</div>
</div>
<div className={`${toolbar.surface} ${toolbar.relaxed} ${styles.toolbar} ${styles.bottomToolbar}`} role="toolbar" aria-label="画布创作工具">
<div className={`${toolbar.surface} ${toolbar.relaxed} ${styles.toolbar} ${styles.bottomToolbar}`} role="toolbar" aria-label="画布创作工具" inert={tree.migrationHeld}>
<IconButton icon={MousePointer2} label="选择工具(V)" aria-pressed={tool === "select"} onClick={() => setTool("select")} />
<IconButton icon={Hand} label="抓手工具(H)" aria-pressed={tool === "hand"} onClick={() => setTool("hand")} />
<span className={toolbar.divider} aria-hidden="true" />
@@ -542,8 +551,8 @@ function CanvasEditor({ board, snapshot }: { board: Board; snapshot?: CanvasSnap
setTool("select");
}} />}
{!feedback && <div className={styles.membershipNotice} role="status" aria-live="polite">{membershipNotice}</div>}
{recovery && <DraftRecovery canRestore={canRestoreCanvasDraft(recovery, tree.rootDocument)} exportDraft={() => downloadDraft(recovery)} restore={() => { dispatch({ type: "replace", document: recovery }); setRecovery(null); }} discard={() => setRecovery(null)} />}
{!recovery && childRecovery && <DraftRecovery canRestore={canRestoreCanvasDraft(childRecovery[1], tree.documents.get(childRecovery[0])!)} exportDraft={() => downloadDraft(childRecovery[1])} restore={() => tree.resolveRecovery(childRecovery[0], true)} discard={() => tree.resolveRecovery(childRecovery[0], false)} />}
{!tree.migrationHeld && recovery && <DraftRecovery canRestore={canRestoreCanvasDraft(recovery, tree.rootDocument)} exportDraft={() => downloadDraft(recovery)} restore={() => { if(treeRef.current.isMigrationHeld())return;dispatch({ type: "replace", document: recovery }); setRecovery(null); }} discard={() => {if(!treeRef.current.isMigrationHeld())setRecovery(null);}} />}
{!tree.migrationHeld && !recovery && childRecovery && <DraftRecovery canRestore={canRestoreCanvasDraft(childRecovery[1], tree.documents.get(childRecovery[0])!)} exportDraft={() => downloadDraft(childRecovery[1])} restore={() => tree.resolveRecovery(childRecovery[0], true)} discard={() => tree.resolveRecovery(childRecovery[0], false)} />}
{snapshot && sync.error && <div className={styles.syncError} role="alert"><span>{sync.error === "local_backup_failed" ? "本地备份失败,请导出 JSON 备份。" : sync.error === "version_conflict" ? "画布已在其他地方修改,自动保存已停止,请导出当前内容或重新读取服务端版本。" : errorText(new Error(sync.error))}</span>
{sync.error !== "version_conflict" && <button onClick={() => void sync.retry()}>重试保存</button>}
<button onClick={exportProject}>导出当前内容</button><button onClick={() => window.location.reload()}>重新读取</button>
@@ -556,7 +565,11 @@ function CanvasEditor({ board, snapshot }: { board: Board; snapshot?: CanvasSnap
</div>
</NodeToolbar>;
}))}
{tree.busy && <div className={styles.canvasBusy} role="status">{tree.pendingCommand ? <div>同步结果尚未确认,为保护内容暂时停止编辑。<button onClick={() => void tree.retryPending().catch((error) => setError(error.message))}>重试同步</button><button onClick={() => window.location.reload()}>重新读取画布</button></div> : "正在同步画布…"}</div>}
{migration&&<LegacyCanvasMigration context={migration} workspaceId={board.workspace_id} canvasId={board.id} registry={migration.registry} held={tree.migrationHeld} hold={tree.setMigrationHeld} prepare={tree.prepareMigration}
check={()=>{if(recovery||treeRef.current.recoveries.size)throw new Error('draft_recovery_required');if(ioActive.current||uploads.hasPending()||demoImageUploading)throw new Error('migration_pending_uploads');}}
canLeave={()=>![...treeRef.current.current.current].some(([id,doc])=>treeRef.current.writers.current.get(id)?.needsSave(doc))}
exportDraft={()=>downloadDraft(recovery??treeRef.current.rootDocument)} />}
{tree.busy && <div className={styles.canvasBusy} role="status">{tree.pendingCommand ? <div>同步结果尚未确认,为保护内容暂时停止编辑。<button onClick={() => void tree.retryPending().catch((error) => setError(error.message))}>重试同步</button><button onClick={() => window.location.reload()}>重新读取画布</button></div> : tree.migrationHeld?"正在检查或交接画布,编辑暂时暂停。":"正在同步画布…"}</div>}
{error && !feedback && <button className={styles.error} role="alert" onClick={() => setError("")} title="点击关闭提示">{error}</button>}
<input ref={imageInput} type="file" accept="image/png,image/jpeg,image/webp,image/gif" onChange={readImage} hidden />
<input ref={projectInput} type="file" accept="application/json,.json" onChange={importProject} hidden />
@@ -564,6 +577,6 @@ function CanvasEditor({ board, snapshot }: { board: Board; snapshot?: CanvasSnap
);
}
export default function GraphCanvas({ board, snapshot }: { board: Board; snapshot?: CanvasSnapshot }) {
return <ReactFlowProvider><CanvasEditor board={board} snapshot={snapshot} /></ReactFlowProvider>;
export default function GraphCanvas({ board, snapshot, migration }: { board: Board; snapshot?: CanvasSnapshot; migration?:LegacyMigrationContext }) {
return <ReactFlowProvider><CanvasEditor board={board} snapshot={snapshot} migration={migration} /></ReactFlowProvider>;
}
@@ -0,0 +1,59 @@
"use client";
import { useEffect, useState } from "react";
import { ArrowUp, Sparkles, Square, X } from "lucide-react";
import type { ElementRecord, Json, Result } from "@/lib/canvas-core/types";
import { runContainsTarget } from "@/lib/canvas-runtime/runs";
import type { RunModel } from "@/lib/canvas-runtime/runs";
import type { ProjectEngine } from "@/lib/canvas-runtime/workspace";
import { errorText } from "@/lib/api";
import { managedImageSource } from "@/lib/canvas-v2";
import styles from "./canvas.module.css";
const object = (value: Json | undefined) => value && typeof value === 'object' && !Array.isArray(value) ? value : {};
export function HeadlessAIPanel({ engine, element, close, viewId }: { engine: ProjectEngine; element: ElementRecord; close(): void; viewId:string }) {
const saved = object(element.data.ai), root = engine.getSnapshot();
const [models, setModels] = useState<readonly RunModel[]>([]), [loading, setLoading] = useState(true), [error, setError] = useState('');
const [model, setModel] = useState(typeof saved.modelId === 'string' ? saved.modelId : '');
const [prompt, setPrompt] = useState(typeof saved.prompt === 'string' ? saved.prompt : '');
const [propagate, setPropagate] = useState(saved.propagate === true);
const image = ['image','plain-image'].includes(element.componentId);
const relevant = [...root.runtime.runs.values()].filter(run => runContainsTarget(run,element));
const run = relevant.at(-1);
const starts = [...root.runtime.ledger.operations.values()].filter(record => record.intent.operationType === 'run.start' && record.intent.run.target.id === element.id && record.intent.run.target.incarnation === element.incarnation);
const pending=starts.filter(record=>record.phase!=='resolved'), rejectedStart=starts.at(-1)?.resolution==='not-applied'?starts.at(-1):undefined;
const actions=[...root.runtime.ledger.operations.values()].filter(record=>(record.intent.operationType==='run.cancel'||record.intent.operationType==='run.apply')&&record.intent.action.runId===run?.id&&record.phase!=='resolved');
const cancelling=actions.some(record=>record.intent.operationType==='run.cancel'),applying=actions.some(record=>record.intent.operationType==='run.apply');
const busy = pending.length > 0 || actions.length > 0 || !!run && (run.hasMore || !!run.pendingPage || ['queued','running'].includes(run.status));
const outputEvent = run?.outputEvents.filter(event => event.node_id === element.id).at(-1), output = object(outputEvent?.output);
useEffect(() => {
const controller = new AbortController();setLoading(true);setError('');
engine.models(element.ownerCanvasId,controller.signal).then(catalog => {
if(controller.signal.aborted)return;
const compatible = catalog.models.filter(model => model.output_modalities.includes(image ? 'image' : 'text'));
setModels(compatible);setModel(current => compatible.some(model => model.id === current) ? current : compatible.find(model => model.available)?.id ?? '');
if(catalog.unavailableReason)setError(errorText(new Error(catalog.unavailableReason)));
}).catch(error => { if(!controller.signal.aborted)setError(errorText(error)); }).finally(() => { if(!controller.signal.aborted)setLoading(false); });
return () => controller.abort();
},[engine,element.ownerCanvasId,image]);
function check(result: Result) { if(result.status === 'rejected') { setError(errorText(new Error(result.code)));return false; }return true; }
function save() {
if(!model || busy)return true;
return check(engine.editor.dispatch({type:'element.data',target:{id:element.id,incarnation:element.incarnation},patch:{ai:{enabled:true,modelId:model,prompt,propagate,parameters:{}}}},{label:'Configure generation'}));
}
const appliedOutput=typeof output.output_id==='string'&&run?.appliedOutputIds?.includes(output.output_id);
const message = cancelling ? '取消请求已保存,等待确认…' : applying ? '正在应用生成结果…' : rejectedStart ? `生成未受理:${rejectedStart.blockers.join('、')}` : appliedOutput ? '此生成结果已应用' : pending.length ? pending.some(record => record.phase === 'blocked') ? '生成受理需要处理' : '正在等待同步并受理生成…' : run?.blocked ? '生成结果已保留,当前编辑需要协调' : run?.error ? '任务跟踪需要处理,可重试' : run?.hasMore ? '正在同步任务状态…' : run ? ({queued:'已受理,等待生成',running:'正在生成…',succeeded:'已更新当前元素',failed:'生成未完成,原内容已保留',conflict:'内容已改变,生成结果已保留',cancelled:'已取消生成',partially_succeeded:'部分步骤已完成'}[run.status]) : '结果将显示在当前元素';
return <section data-canvas-ui-view={viewId} className={`${styles.aiPanel} nodrag nopan nowheel`} aria-label="元素 AI 工具" style={{position:'absolute',right:24,bottom:90,zIndex:110,maxWidth:'calc(100% - 48px)',maxHeight:'70%',overflow:'auto'}} onPointerDown={event=>event.stopPropagation()} onKeyDown={event=>event.stopPropagation()}>
<header><Sparkles size={14}/><strong>{image ? '图像创作' : '文本创作'}</strong><span>{element.data.title as string}</span><button aria-label="关闭 AI 工具" onClick={close}><X size={14}/></button></header>
<textarea aria-label="AI 指令" placeholder="希望如何处理当前内容?" maxLength={16000} disabled={busy} value={prompt} onChange={event=>setPrompt(event.target.value)} onBlur={save}/>
<footer><select aria-label="AI 模型" value={model} disabled={loading || busy} onChange={event=>setModel(event.target.value)}>{!model && <option value="">{loading ? '正在读取模型…' : '暂无可用模型'}</option>}{models.map(model=><option key={model.id} value={model.id} disabled={!model.available}>{model.label || model.display_name || model.id}</option>)}</select>
<label><input type="checkbox" aria-label="继续向下传播" checked={propagate} disabled={busy} onChange={event=>setPropagate(event.target.checked)}/>继续向下传播</label>
<button aria-label="执行 AI" title="在当前元素生成结果" disabled={busy || loading || !prompt.trim() || !models.some(item=>item.id===model && item.available)} onClick={()=>{setError('');if(save())check(engine.startRun({target:{id:element.id,incarnation:element.incarnation},boardId:element.ownerCanvasId,modelId:model,prompt,parameters:{},propagate}));}}><ArrowUp size={18}/></button>
{run && ['queued','running'].includes(run.status) && <button aria-label="取消生成" disabled={cancelling} onClick={()=>check(engine.cancelRun(run.id))}><Square size={15}/></button>}
</footer>
<p role="status">{message}</p>
{(run?.blocked || run?.status==='conflict') && <div className={styles.aiConflict}>{typeof output.text==='string' && <pre>{output.text}</pre>}{typeof output.src==='string' && <img src={managedImageSource(output.src)} alt="保留的生成结果"/>}{run?.status==='conflict'&&!run.blocked&&!run.hasMore&&!appliedOutput&&typeof output.output_id==='string'&&<button disabled={actions.length>0} onClick={()=>check(engine.applyRunOutput(run.id,{id:element.id,incarnation:element.incarnation},output.output_id as string))}>使用此结果替换当前内容</button>}</div>}
{(run?.blocked || run?.error || pending.some(record=>record.phase==='blocked')) && <button onClick={()=>void engine.retry().catch(error=>setError(errorText(error)))}>重试任务跟踪</button>}
{error && <p role="alert">{error}</p>}
</section>;
}
@@ -0,0 +1,347 @@
"use client";
import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore, type ContextType } from "react";
import { Background, BackgroundVariant, ConnectionMode, ReactFlow, ReactFlowProvider, useReactFlow, useViewport, useStore, type Connection,type Viewport } from "@xyflow/react";
import { Copy, Download, Frame, Hand, ImagePlus, Images, Maximize, Minus, Ratio, MousePointer2, Plus, Redo2, Sparkles, StickyNote, Type, Undo2 } from "lucide-react";
import type { Board } from "@/lib/workspace";
import type { ReactRenderers } from "@/lib/canvas-bindings/react-flow";
import { CanvasProvider, CanvasRendererCommandsContext, type CanvasRendererCommands } from "@/lib/canvas-bindings/react-flow";
import { defaultCanvasRenderers } from "./canvas-renderers";
import type { Data, ElementRecord, Geometry, Id, Json, Result } from "@/lib/canvas-core/types";
import { equal } from "@/lib/canvas-core/values";
import {createSceneProjection} from "@/lib/canvas-bindings/scene-projection";
import {createConnectionPaintIndex} from "@/lib/canvas-bindings/connection-paint";
import {createRetainedProjection} from "./retained-projection";
import {createOverlayIndex} from "@/lib/canvas-bindings/overlays";
import {CanvasOverlays} from "./canvas-overlays";
import {createProjectedPaintIndex} from "@/lib/canvas-bindings/projected-paint";
import {createFrameSource} from '@/lib/canvas-bindings/frame-queue';
import {createSessionFrameSource} from "@/lib/canvas-bindings/session-frames";
import {routeCanvasKey,canvasKeyContext} from "@/lib/canvas-bindings/input-router";
import {hitProjectedCanvas} from "@/lib/canvas-bindings/hit-query";
import {compatConnector,measuredConnector} from "@/lib/canvas-bindings/connectors";
import {createLoadingRendererCatalog} from '@/lib/canvas-bindings/renderers';
import { createProjectionCache } from "@/lib/canvas-bindings/projection-cache";
import { createResourceView, type ResourceViewSource } from "@/lib/canvas-bindings/resource-view";
import { ancestors, commonCanvas } from "@/lib/canvas-core/document";
import { toLocal, transformPath, type ProjectionNode } from "@/lib/canvas-bindings/projection";
import type { ProjectEngine } from "@/lib/canvas-runtime/workspace";
import { errorText } from "@/lib/api";
import { IconButton } from "@/components/ui/icon-button";
import toolbar from "@/components/ui/toolbar.module.css";
import { guardEditingLease } from "./editing-lease";
import { NodeEditing } from "./transformable-node";
import { EdgeEditing } from "./edge-editing";
import { HeadlessAIPanel } from "./headless-ai-panel";
import { HeadlessResourcePicker } from "./headless-resource-picker";
import { HeadlessImports } from "./headless-imports";
import { HeadlessRecoveryDialog } from "./headless-recovery-dialog";
import { UnsupportedNode, UnsupportedEdge } from "./unsupported-elements";
import { ImagePreviews } from "./canvas-image";
import styles from "./canvas.module.css";
const json = (value: unknown): Data => JSON.parse(JSON.stringify(value));
function CanvasView({ engine, board, viewId, renderers,fitLoading=true }: { engine: ProjectEngine; board: Board; viewId: Id; renderers:ReactRenderers;fitLoading?:boolean }) {
const sceneCache=useMemo(()=>createSceneProjection(),[engine,viewId]);
const subscribe = useCallback((notify: (immediate?:boolean) => void) => {
let before=engine.getSnapshot().document;
return engine.editor.subscribe((commit,next)=>{sceneCache.advance(before,next.document,commit.changes);before=next.document;
// Controlled inputs must publish their new value before the native event
// finishes. Batch creation/background work may share a presentation frame.
const immediate=commit.origin==='undo'||commit.origin==='redo'||commit.origin==='user'&&commit.changes.some(change=>change.kind==='field'||change.before!==null);
notify(immediate);
});
}, [engine,sceneCache]);
const rootFrames=useMemo(()=>createFrameSource({subscribe,getSnapshot:engine.getSnapshot},{request:callback=>requestAnimationFrame(callback),cancel:handle=>cancelAnimationFrame(handle as number)}),[engine,subscribe]);
const root = useSyncExternalStore(rootFrames.subscribe, rootFrames.getSnapshot, rootFrames.getSnapshot);
const sessionFrames=useMemo(()=>createSessionFrameSource(engine.sessions,{request:callback=>requestAnimationFrame(callback),cancel:handle=>cancelAnimationFrame(handle as number)}),[engine]);
const sessions = useSyncExternalStore(sessionFrames.subscribe, sessionFrames.getSnapshot, sessionFrames.getSnapshot);
const liveView = sessions.views.get(viewId), lastView = useRef(liveView);
const loading=root.runtime.loading,needsLoadingFit=useRef(!!loading&&fitLoading);
if (liveView) lastView.current = liveView;
const view = liveView ?? lastView.current!, flow = useReactFlow<ProjectionNode>();
const canCreate=!loading||loading.version>=4&&engine.editor.getQueries(root.document).coverage.completeCanvasIds.includes(view.canvasId);
useEffect(()=>{
if(!needsLoadingFit.current||!flow.viewportInitialized||!root.document.canvases.has(view.canvasId))return;
// Expanded descendants can extend far beyond their partition entry. Initial
// framing belongs to the direct members of this canvas.
const nodes=engine.editor.getQueries(root.document).members(view.canvasId).filter(id=>root.document.elements.get(id)?.structure==='node').map(id=>({id}));
if(!nodes.length)return;
needsLoadingFit.current=false;void flow.fitView({nodes,padding:.2,maxZoom:1});
},[engine,flow,root.document,view.canvasId]);
const preparingCopy = [...(root.runtime.imports?.values() ?? [])].some(job => job.status === 'preparing' && job.source?.targetCanvasId === view.canvasId);
const [focusedEditor, setFocusedEditor] = useState<HTMLElement | null>(null), focusEpoch = useRef(0);
useEffect(() => { focusEpoch.current++; setFocusedEditor(null); return () => { focusEpoch.current++; }; }, [engine,viewId]);
// Removing an active renderer need not dispatch a native blur. Retire only
// that detached input, without clearing a newer focus acquired in the meantime.
useEffect(()=>{if(focusedEditor&&!focusedEditor.isConnected)setFocusedEditor(current=>current===focusedEditor?null:current);});
const [copying, setCopying] = useState(false);
const [resourcePicker,setResourcePicker]=useState<{canvasId:Id;position:{x:number;y:number}}|null>(null);
const copyAvailability=useMemo(()=>engine.imports.canDuplicate([...view.selection].map(id=>root.document.elements.get(id)).filter((element):element is ElementRecord=>!!element).map(({id,incarnation})=>({id,incarnation})),{viewCanvasId:view.canvasId}),[engine,root,view.selection,view.canvasId]);
const [showAI, setShowAI] = useState(false), [showRecovery, setShowRecovery] = useState(false);
const [error, setError] = useState("");
const [displayState, setDisplayState] = useState<{engine:ProjectEngine|null;viewId:Id|null;sources:Map<Id,ResourceViewSource>;failures:number}>({engine:null,viewId:null,sources:new Map(),failures:0});
const resourceView=useRef<ReturnType<typeof createResourceView>|null>(null);
const pointerStart = useRef<{ x: number; y: number;pointerId?:number } | null>(null), drag = useRef<{ token: number;pointerId?:number; start: { x: number; y: number }; positions: Map<Id, { x: number; y: number }> } | null>(null);
const projectionCache = useRef<ReturnType<typeof createProjectionCache> | null>(null);
projectionCache.current ??= createProjectionCache();
const measurements = useRef(new Map<Id, { width: number; height: number; measured: { width: number; height: number } }>());
const imageInput = useRef<HTMLInputElement>(null), groups = useRef(new Map<Id,{incarnation:number;groupId:Id}>());
useEffect(()=>{for(const [id,group]of groups.current)if(root.document.elements.get(id)?.incarnation!==group.incarnation)groups.current.delete(id);},[root.document]);
const catalog=useMemo(()=>loading?createLoadingRendererCatalog(engine.editor.registry,renderers.catalog):renderers.catalog,[engine,renderers,!!loading]);
const scene=sceneCache.project(engine,root.document,view,sessions,engine.editor.registry,catalog,engine.editor.getQueries(root.document));
const projection = useMemo(() => {
const result = scene;
// Preserve renderer measurements across metadata/preview updates. Dropping them
// makes React Flow discard handle bounds and unmount active edge controls.
const visibleIds = new Set(result.nodes.map(node => node.id));
for (const id of measurements.current.keys()) if (!visibleIds.has(id)) measurements.current.delete(id);
for (const node of result.nodes) { const cached = measurements.current.get(node.id); if (cached && cached.width === node.width && cached.height === node.height && projectionCache.current!.canRetainMeasurement(engine,root.document,view.canvasId,node)) node.measured = cached.measured; }
return projectionCache.current!.reuse(engine,root.document,view.canvasId,result);
}, [engine, root.document, scene,view.canvasId]);
const surface=useRef<HTMLDivElement>(null),overlays=useMemo(()=>createOverlayIndex(viewId),[engine,viewId]);
const overlayScope=useMemo(()=>({index:overlays,engine}),[overlays,engine]);
const overlaySnapshot=useSyncExternalStore(overlays.subscribe,overlays.getSnapshot,overlays.getSnapshot);
const surfaceBounds=surface.current?.getBoundingClientRect();
const paintCache=useMemo(()=>createProjectedPaintIndex(),[engine,view.canvasId]);
const paint=useMemo(()=>paintCache.sync(projection,engine.editor.registry),[paintCache,projection,engine]);
const viewport=useViewport(),surfaceWidth=useStore(state=>state.width),surfaceHeight=useStore(state=>state.height);
const sceneViewport={x:-viewport.x/viewport.zoom,y:-viewport.y/viewport.zoom,width:surfaceWidth/viewport.zoom,height:surfaceHeight/viewport.zoom};
const visibleOverlays=overlaySnapshot.length?(surfaceBounds?overlays.visible(surfaceBounds,target=>root.document.elements.get(target.id)?.incarnation===target.incarnation):overlaySnapshot.filter(entry=>root.document.elements.get(entry.target.id)?.incarnation===entry.target.incarnation)):[];
const paintedNodes=[...paint.unknown,...paint.query(sceneViewport)];
const nativeNodes=useStore(state=>state.nodes);
const connectionPaintCache=useMemo(()=>createConnectionPaintIndex(),[engine,view.canvasId]);
const connectionPaint=useMemo(()=>connectionPaintCache.sync(projection,engine.editor.registry,{anchor:(node,handle,end,edge)=>{const measured=flow.getInternalNode(node.id);return node.data.__headlessRecord||edge.type==='unsupported'?measuredConnector(measured,handle,end):compatConnector(node,handle,end==='source'?'right':'left');}}),[connectionPaintCache,projection,engine,flow,nativeNodes]);
// Exhausted geometry work cannot prove any edge invisible. Retain the edge
// layer and its endpoints, not unrelated nodes or a synthetic sentinel owner.
const paintedEdges=connectionPaint.unknown.includes('paint_budget')?projection.edges.map(edge=>edge.id):[...connectionPaint.unknown,...connectionPaint.query(sceneViewport)];
const nodeTypes=useMemo(()=>({...renderers.nodeTypes,unsupported:UnsupportedNode}),[renderers]),edgeTypes=useMemo(()=>({...renderers.edgeTypes,unsupported:UnsupportedEdge}),[renderers]);
const current = useRef({ root, projection, view, engine, sessions }); current.current = { root, projection, view, engine, sessions };
const report = useCallback((result: Result | { enabled: boolean; code?: string }) => {
if ('status' in result && result.status === 'rejected' || 'enabled' in result && !result.enabled) setError(errorText(new Error(result.code)));
}, []);
const target = (id: Id) => { const element = engine.getSnapshot().document.elements.get(id); if (!element) throw new Error('element_missing'); return { id, incarnation: element.incarnation }; };
const data = (id: Id, patch: Data, groupId?: Id) => report(engine.editor.dispatch({ type: 'element.data', target: target(id), patch }, { groupId }));
function selectChanges(changes: readonly { id: Id; selected: boolean }[]) {
const current = engine.sessions.getSnapshot().views.get(viewId)!;
const ids = new Set(current.selection);
for (const change of changes) if (change.selected) ids.add(change.id); else ids.delete(change.id);
if (ids.size !== current.selection.size || [...ids].some(id => !current.selection.has(id))) engine.sessions.select(viewId, [...ids]);
}
function selectAtPane(event: {clientX:number;clientY:number}) {
const captured=current.current;
if(captured.view.tool!=='select'){engine.sessions.select(viewId,[]);return;}
// Native handles stay in the renderer adapter; the core never reads DOM or guesses custom ports.
const result=hitProjectedCanvas(captured.projection,engine.editor.getQueries(captured.root.document),engine.editor.registry,viewId,{
space:'scene',point:flow.screenToFlowPosition({x:event.clientX,y:event.clientY}),zoom:flow.getViewport().zoom,tolerancePx:12,
},{
previewIds:new Set(captured.sessions.gesture?.targets.map(target=>target.id)),
anchor:(node,handle,end,edge)=>{
const measured=flow.getInternalNode(node.id);if(!measured)return undefined;
return node.data.__headlessRecord||edge.type==='unsupported'?measuredConnector(measured,handle,end):compatConnector({...measured,position:measured.internals.positionAbsolute},handle,end==='source'?'right':'left');
},
layer:(id,kind)=>kind==='node'?flow.getInternalNode(id)?.internals.z:undefined,
});
// A possible hit is deliberately not selected; a stale frame must not select a new incarnation.
if(engine.getSnapshot().document!==captured.root.document||engine.sessions.getSnapshot()!==captured.sessions)return;
const hit=result.hits[0];
engine.sessions.select(viewId,hit?.certainty==='confirmed'?[hit.target.id]:[]);
}
function beginTransform(id: Id) {
const result = engine.sessions.begin(viewId, [target(id)]); report(result); if (!result.enabled) return false;
const token = engine.sessions.getSnapshot().gesture!.token;
const current = () => engine.sessions.getSnapshot().gesture?.token === token;
return guardEditingLease(current, { preview: (change: Parameters<typeof transform>[1]) => transform(id, change), commit: () => report(engine.sessions.commit(viewId)), cancel: () => engine.sessions.cancel('pointer_cancel') });
}
function transform(id: Id, change: { position?: { x: number; y: number }; width?: number; height?: number; rotation?: number; fontSize?: number }) {
const { root, projection } = current.current, element = root.document.elements.get(id);
if (element?.structure !== 'node') return;
const t = projection.transforms.get(element.ownerCanvasId)!;
const displayed = projectionCache.current!.getNode(id)!;
const width = (change.width ?? displayed.width!) / t.scale, height = (change.height ?? displayed.height!) / t.scale;
const position = change.position ?? displayed.position, center = toLocal(t, { x: position.x + width * t.scale / 2, y: position.y + height * t.scale / 2 });
const patch: Partial<Geometry> = { x: center.x - width / 2, y: center.y - height / 2, width, height, ...(change.rotation !== undefined ? { rotation: ((change.rotation - t.rotation) % 360 + 360) % 360 } : {}) };
report(engine.sessions.update(viewId, target(id), patch));
}
function add(kind: 'text' | 'plain-text' | 'image' | 'frame', ref?: { id: Id; src: string }, tx?: Parameters<Parameters<typeof engine.editor.transaction>[0]>[0], at?: { x: number; y: number }) {
const id = crypto.randomUUID(), canvasId = current.current.view.canvasId;
const rect = window.document.querySelector('[data-headless-canvas]')?.getBoundingClientRect();
const position = at ?? flow.screenToFlowPosition({ x: (rect?.left ?? 0) + (rect?.width ?? window.innerWidth) / 2, y: (rect?.top ?? 0) + (rect?.height ?? window.innerHeight) / 2 });
const size = kind === 'frame' ? { width: 640, height: 400 } : kind === 'plain-text' ? { width: 240, height: 72 } : { width: 260, height: 200 };
const element: ElementRecord = { id, incarnation: 1, structure: 'node', componentId: kind, dataVersion: 1, ownerCanvasId: canvasId, geometry: { ...position, ...size, rotation: 0 }, data: { title: kind === 'frame' ? '未命名分区' : kind === 'image' ? '视觉参考' : '灵感笔记', text: '', ...(ref ? { assetId: ref.id, src: ref.src } : {}) } };
const create = (transaction: NonNullable<typeof tx>) => { transaction.dispatch({ type: 'element.create', element }); if (kind === 'frame') transaction.dispatch({ type: 'canvas.create', canvas: { id: crypto.randomUUID(), parent: { canvasId, entryElementId: id } } }); };
if (tx) create(tx); else report(engine.editor.transaction(create, { label: 'Add element' }));
return id;
}
function connect(connection: Connection) {
if(engine.sessions.getSnapshot().views.get(viewId)?.tool!=='select')return;
const document = engine.getSnapshot().document, source = document.elements.get(connection.source), destination = document.elements.get(connection.target);
if (!source || !destination) return;
if([source.id,destination.id].some(id=>projectionCache.current!.getNode(id)?.type==='unsupported')){setError('此元素暂不支持创建新连线');return;}
report(engine.editor.dispatch({ type: 'element.create', element: { id: crypto.randomUUID(), incarnation: 1, componentId: 'connection', dataVersion: 1, structure: 'connection', ownerCanvasId: commonCanvas(document, source.ownerCanvasId, destination.ownerCanvasId), source: { elementId: source.id, ...(connection.sourceHandle ? { portId: connection.sourceHandle } : {}) }, target: { elementId: destination.id, ...(connection.targetHandle ? { portId: connection.targetHandle } : {}) }, data: {}, path: null } }));
}
const resourceKey = JSON.stringify(projection.nodes.filter(node => typeof node.data.assetId==='string'&&node.data.assetId).map(node => [node.id, node.data.assetId]));
useEffect(() => {
if (!liveView) return;
const view=createResourceView({resolve:(id,signal)=>engine.displayResource(id,{signal}),onChange:(sources,failures)=>{if(resourceView.current===view)setDisplayState({engine,viewId,sources,failures});},onError:error=>{if(resourceView.current===view)setError(errorText(error));}});
resourceView.current=view;
return ()=>{if(resourceView.current===view)resourceView.current=null;view.dispose();};
}, [engine,viewId,Boolean(liveView)]);
useEffect(()=>{try{resourceView.current?.sync(JSON.parse(resourceKey) as [Id,Id][]);}catch(error){setError(errorText(error));}},[engine,viewId,resourceKey,Boolean(liveView)]);
const sources=useMemo(()=>{if(displayState.engine!==engine||displayState.viewId!==viewId)return new Map<Id,string>();const wanted=new Map(JSON.parse(resourceKey) as [Id,Id][]);return new Map([...displayState.sources].filter(([id,source])=>wanted.get(id)===source.resourceId).map(([id,source])=>[id,source.url]));},[displayState,engine,viewId,resourceKey]);
function removeSelected() { report(engine.editor.transaction(tx => { for (const id of current.current.view.selection) if (tx.getDocument().elements.has(id)) tx.dispatch({ type: 'element.remove', target: target(id) }); }, { label: 'Delete selection' })); }
const pending = [...root.runtime.ledger.operations.values()].filter(operation => operation.phase !== 'resolved');
const blocked = pending.some(operation => operation.phase === 'blocked' && operation.blockers.some(reason => reason !== 'waiting_digest')) || [...root.runtime.ledger.requests.values()].some(request => request.phase !== 'resolved' && request.error && request.retryAt === undefined) || root.runtime.conflicts.size > 0;
useEffect(() => {
const cancel = () => { const state = engine.sessions.getSnapshot(); if (state.gesture?.viewId === viewId || state.connectionGesture?.viewId === viewId) engine.sessions.cancel('view_interrupted'); drag.current = null; };
// Bubble after the target/React handlers: an input owner can claim Escape first.
const escape = (event: KeyboardEvent) => {
if(event.key!=='Escape')return;
const claim=routeCanvasKey({key:event.key,composing:event.isComposing,defaultPrevented:event.defaultPrevented,...canvasKeyContext(event)},{gesture:()=>{const state=engine.sessions.getSnapshot();if(state.gesture?.viewId!==viewId&&state.connectionGesture?.viewId!==viewId)return false;cancel();return true;}});
if(claim==='gesture'){event.preventDefault();event.stopPropagation();}
};
window.addEventListener('blur', cancel); window.addEventListener('keydown', escape);
return () => { window.removeEventListener('blur', cancel); window.removeEventListener('keydown', escape); };
}, [engine, viewId]);
const modes = view.modes;
// Retain explicit owners in the native renderer, leaving the full graph in its
// lookup for edges/queries. No synthetic dragging or altered geometry is used.
const retained=new Set<Id>([...modes.keys(),...paintedNodes,...paintedEdges]);
for(const entry of visibleOverlays)retained.add(entry.target.id);
if(sessions.gesture?.viewId===viewId)for(const target of sessions.gesture.targets)retained.add(target.id);
if(sessions.connectionGesture?.viewId===viewId)retained.add(sessions.connectionGesture.target.id);
const activeEditor=focusedEditor?.isConnected?focusedEditor:null;
const focusedOverlay=activeEditor?.closest<HTMLElement>('[data-canvas-overlay-target]');
const focusedUI=activeEditor?.closest<HTMLElement>('[data-canvas-ui-view]');
const independentFocus=focusedUI?.dataset.canvasUiView===viewId;
const focusedNative=activeEditor?.closest<HTMLElement>('.react-flow__node,.react-flow__edge');
const focusedId=focusedOverlay?.dataset.canvasOverlayView===viewId?focusedOverlay.dataset.canvasOverlayTarget:focusedNative?.closest('[data-headless-canvas]')===surface.current?focusedNative?.dataset.id:undefined;
if(focusedId)retained.add(focusedId);
// Retained connections also keep their measured endpoint owners alive.
for(const id of [...retained]){const element=root.document.elements.get(id);if(element?.structure==='connection'){retained.add(element.source.elementId);retained.add(element.target.elementId);}}
const scoped=!(activeEditor&&!focusedId&&!independentFocus)&&[...retained].every(id=>root.document.elements.has(id));
const cullVisible=!!surfaceWidth&&!!surfaceHeight&&scoped;
const retainedProjection=useMemo(()=>createRetainedProjection(),[engine,viewId]);
const rendered=retainedProjection(projection.nodes,projection.edges,retained);
const selected = view.selection.size === 1 ? root.document.elements.get([...view.selection][0]) : undefined;
const selectedResource=selected?.structure==='node'&&engine.editor.registry.resolve(selected)==='known'&&engine.editor.registry.supports(selected,'geometry.resize')&&typeof selected.data.assetId==='string'&&engine.editor.registry.components.get(selected.componentId)?.resourceFields?.includes('assetId')?root.runtime.resources.get(selected.data.assetId):undefined;
const canFitImage=!!selectedResource&&(selectedResource.image?.version===1&&selectedResource.image.status==='ready'||!!selectedResource.stored)&&!sessions.gesture;
const aiElement = selected?.structure === 'node' && engine.editor.registry.resolve(selected) === 'known' && ['text','plain-text','script','image','plain-image'].includes(selected.componentId) ? selected : undefined;
// Runtime-only publications must not invalidate every card's editing context.
// Actions use current.current / engine snapshots instead of captured document projections.
const rotating = [...modes].find(([, mode]) => mode === 'rotate')?.[0] ?? null;
const resizing = [...modes].find(([, mode]) => mode === 'resize')?.[0] ?? null;
const imagePreviews = useMemo<ContextType<typeof ImagePreviews>>(() => ({sources,loaded:()=>{}}),[sources]);
const nodeEditing = useMemo<ContextType<typeof NodeEditing>>(() => ({ workspace: board.workspace_id, schema: 2,
begin: id => { if (id) { groups.current.set(id,{incarnation:target(id).incarnation,groupId:crypto.randomUUID()}); engine.sessions.mode(viewId, id, 'editing'); } },
endEditing: id => { groups.current.delete(id); engine.sessions.mode(viewId, id, null); },
select: id => engine.sessions.select(viewId, [id]), edit: (id, text) => data(id,{text},groups.current.get(id)?.incarnation===engine.getSnapshot().document.elements.get(id)?.incarnation?groups.current.get(id)?.groupId:undefined), rename: (id, title) => data(id, { title }),
beginTransform, transform, finishTransform: () => { if (engine.sessions.getSnapshot().gesture?.viewId === viewId) report(engine.sessions.commit(viewId)); }, cancelTransform: () => { if (engine.sessions.getSnapshot().gesture?.viewId === viewId) engine.sessions.cancel('pointer_cancel'); },
style: (id, textStyle, fontSize) => data(id, json({ textStyle, ...(fontSize !== undefined ? { fontSize } : {}) })), frame: (id, patch) => data(id, json(patch)),
rotating, rotate: id => { for (const [key, mode] of engine.sessions.getSnapshot().views.get(viewId)?.modes ?? []) if (mode === 'rotate') engine.sessions.mode(viewId, key, null); if (id) engine.sessions.mode(viewId, id, 'rotate'); },
resizing, resize: id => { for (const [key, mode] of engine.sessions.getSnapshot().views.get(viewId)?.modes ?? []) if (mode === 'resize') engine.sessions.mode(viewId, key, null); if (id) engine.sessions.mode(viewId, id, 'resize'); },
branch: (id, direction) => {
const node = projectionCache.current!.getNode(id); if (!node) return;
const dx = direction === 'left' ? -340 : direction === 'right' ? 340 : 0, dy = direction === 'top' ? -280 : direction === 'bottom' ? 280 : 0;
report(engine.editor.transaction(tx => { const created = add('text', undefined, tx, { x: node.position.x + dx, y: node.position.y + dy }); const source = tx.getDocument().elements.get(id)!, next = tx.getDocument().elements.get(created)!; tx.dispatch({ type: 'element.create', element: { id: crypto.randomUUID(), incarnation: 1, componentId: 'connection', dataVersion: 1, structure: 'connection', ownerCanvasId: commonCanvas(tx.getDocument(), source.ownerCanvasId, next.ownerCanvasId), source: { elementId: id, portId: direction }, target: { elementId: created }, data: {}, path: null } }); }, { label: 'Add branch' }));
},
}),[engine,viewId,board.workspace_id,flow,report,rotating,resizing]);
const edgeEditing = useMemo<ContextType<typeof EdgeEditing>>(() => ({ active: new Set(), begin: id => {
if (!id) return; const result = engine.sessions.beginConnection(viewId, target(id)); report(result); if (!result.enabled) return false;
const token = engine.sessions.getSnapshot().connectionGesture!.token, element = engine.getSnapshot().document.elements.get(id)!;
const transform = current.current.projection.transforms.get(element.ownerCanvasId)!;
const ownsGesture = () => engine.sessions.getSnapshot().connectionGesture?.token === token;
return guardEditingLease(ownsGesture, { preview: (path: NonNullable<import('@/lib/canvas-v2').EdgeData['path']>) => report(engine.sessions.updateConnection(viewId, transformPath(path as Json, transform, true))), commit: () => report(engine.sessions.commitConnection(viewId)), cancel: () => engine.sessions.cancel('pointer_cancel') });
},
finish: () => { if (engine.sessions.getSnapshot().connectionGesture?.viewId === viewId) report(engine.sessions.commitConnection(viewId)); }, cancel: () => { if (engine.sessions.getSnapshot().connectionGesture?.viewId === viewId) engine.sessions.cancel('pointer_cancel'); },
patch: (id, value, transient) => {
const element = engine.getSnapshot().document.elements.get(id); if (element?.structure !== 'connection') return;
const t = current.current.projection.transforms.get(element.ownerCanvasId)!;
const path = transformPath((value.path ?? null) as Json, t, true);
const displayedPath = current.current.projection.edges.find(edge => edge.id === id)?.data?.path;
const changedPath = Object.hasOwn(value, 'path') && !equal(value.path, displayedPath);
if (transient) { report(engine.sessions.updateConnection(viewId, path)); return; }
const { path: ignored, ...rest } = value; void ignored;
report(engine.editor.transaction(tx => { tx.dispatch({ type: 'element.data', target: target(id), patch: json(rest) }); if (changedPath) tx.dispatch({ type: 'connection.path', target: target(id), path }); }, { label: 'Edit connection' }));
},
}),[engine,viewId,report]);
const rendererCommands=useMemo<CanvasRendererCommands>(()=>({dispatch(command,options){const result=engine.editor.dispatch(command,{label:options?.label,groupId:options?.groupId});report(result);return result;},transaction(callback,options){const result=engine.editor.transaction(callback,{label:options?.label,groupId:options?.groupId});report(result);return result;}}),[engine,report]);
if (!liveView) return <p role="status">画布已关闭</p>;
return <div ref={surface} className={`${styles.editor} ${view.tool === 'pan' ? styles.handMode : ''}`} data-headless-canvas data-model-loading={loading?"true":undefined} tabIndex={-1}
onFocusCapture={event => { if ((event.target as HTMLElement).closest('input,textarea,[contenteditable="true"],[role="textbox"]')) { focusEpoch.current++; setFocusedEditor(event.target as HTMLElement); } }}
onBlurCapture={() => { const epoch = ++focusEpoch.current; queueMicrotask(() => { if (focusEpoch.current === epoch) setFocusedEditor(null); }); }}
onMouseDownCapture={event => { if (event.button === 0 && (event.target as HTMLElement).closest('.react-flow__node')) pointerStart.current = {...flow.screenToFlowPosition({ x: event.clientX, y: event.clientY }),pointerId:pointerStart.current?.pointerId}; }}
onTouchStartCapture={event => { if (event.touches.length > 1) { const state=engine.sessions.getSnapshot();if(state.gesture?.viewId===viewId||state.connectionGesture?.viewId===viewId)engine.sessions.cancel('multi_touch');drag.current = null;pointerStart.current=null;return; } const touch = event.touches[0]; if (touch && (event.target as HTMLElement).closest('.react-flow__node')) pointerStart.current = {...flow.screenToFlowPosition({ x: touch.clientX, y: touch.clientY }),pointerId:pointerStart.current?.pointerId}; }}
onPointerDownCapture={event => { if (event.button === 0 && (event.target as HTMLElement).closest('.react-flow__node')) pointerStart.current = {...flow.screenToFlowPosition({ x: event.clientX, y: event.clientY }),pointerId:event.pointerId}; }}
onPointerCancelCapture={event => { const currentDrag=drag.current;if(!currentDrag||currentDrag.pointerId!==undefined&&currentDrag.pointerId!==event.pointerId)return;const state=engine.sessions.getSnapshot();if(state.gesture?.viewId===viewId&&state.gesture.token===currentDrag.token)engine.sessions.cancel('pointer_cancel');drag.current=null; }}
onKeyDown={event => {
const claim=routeCanvasKey({key:event.key,composing:event.nativeEvent.isComposing,...canvasKeyContext(event.nativeEvent),defaultPrevented:event.defaultPrevented,meta:event.metaKey,ctrl:event.ctrlKey,alt:event.altKey,shift:event.shiftKey},{
gesture:input=>{const state=engine.sessions.getSnapshot();if(input.key!=='Escape'||state.gesture?.viewId!==viewId&&state.connectionGesture?.viewId!==viewId)return false;engine.sessions.cancel('escape');drag.current=null;return true;},
tool:input=>{if(input.ctrl||input.meta||input.alt||input.shift)return false;const key=input.key.toLowerCase();if(key!=='h'&&key!=='v')return false;engine.sessions.tool(viewId,key==='h'?'pan':'select');return true;},
shortcut:input=>{if(input.alt)return false;if((input.meta||input.ctrl)&&input.key.toLowerCase()==='z'){report(input.shift?engine.editor.history.redo():engine.editor.history.undo());return true;}if(!input.meta&&!input.ctrl&&!input.shift&&(input.key==='Delete'||input.key==='Backspace')&&current.current.view.selection.size){removeSelected();return true;}return false;},
});
if(claim==='gesture'||claim==='tool'||claim==='shortcut'){event.preventDefault();event.stopPropagation();}
}}>
<CanvasOverlays.Provider value={overlayScope}><ImagePreviews.Provider value={imagePreviews}>
<CanvasRendererCommandsContext.Provider value={rendererCommands}>
<NodeEditing.Provider value={nodeEditing}>
<EdgeEditing.Provider value={edgeEditing}>
<ReactFlow<ProjectionNode> elevateEdgesOnSelect onlyRenderVisibleElements={cullVisible} nodes={rendered.nodes} edges={rendered.edges} nodeTypes={nodeTypes} edgeTypes={edgeTypes} connectionMode={ConnectionMode.Loose}
nodesConnectable={view.tool === 'select'} nodesDraggable={view.tool === 'select'} panOnDrag={view.tool === 'pan' ? true : [1, 2]} selectionOnDrag={view.tool === 'select'} panOnScroll zoomOnScroll={false} minZoom={.1} maxZoom={4} deleteKeyCode={null} defaultViewport={view.viewport}
onMoveEnd={(_, viewport) => engine.sessions.viewport(viewId, viewport)}
onNodeDragStart={(event, node, nodes) => {
const ids = new Set((nodes.length ? nodes : [node]).map(node => node.id));
const top = [...ids].filter(id => { const element = current.current.root.document.elements.get(id)!; return !ancestors(current.current.root.document, element.ownerCanvasId).some(canvasId => { const parent = current.current.root.document.canvases.get(canvasId)?.parent; return parent && ids.has(parent.entryElementId); }); });
const point = 'clientX' in event ? event : event.changedTouches[0];
drag.current = { token: 0,pointerId:pointerStart.current?.pointerId, start: pointerStart.current ?? flow.screenToFlowPosition({ x: point.clientX, y: point.clientY }), positions: new Map(top.map(id => [id, projectionCache.current!.getNode(id)!.position])) };
const result = engine.sessions.begin(viewId, top.map(target)); report(result);
if (result.enabled) drag.current.token = engine.sessions.getSnapshot().gesture!.token; else drag.current = null;
}}
onEdgesChange={changes => selectChanges(changes.filter(change => change.type === 'select'))}
onNodesChange={changes => { for (const change of changes) if (change.type === 'dimensions' && change.dimensions) { const node = projectionCache.current!.getNode(change.id); if (node) measurements.current.set(change.id, { width: node.width!, height: node.height!, measured: { ...change.dimensions } }); } selectChanges(changes.filter(change => change.type === 'select')); for (const change of changes) if (change.type === 'position' && change.position && engine.sessions.getSnapshot().gesture?.viewId === viewId && drag.current?.token === engine.sessions.getSnapshot().gesture?.token && engine.sessions.getSnapshot().gesture?.targets.some(target => target.id === change.id)) transform(change.id, { position: change.position }); }}
onNodeDragStop={event => {
const saved = drag.current; drag.current = null; pointerStart.current = null;
if (engine.sessions.getSnapshot().gesture?.viewId !== viewId || !saved || engine.sessions.getSnapshot().gesture?.token !== saved.token) return;
const point = 'clientX' in event ? event : event.changedTouches[0], end = flow.screenToFlowPosition({ x: point.clientX, y: point.clientY });
for (const [id, start] of saved.positions) transform(id, { position: { x: start.x + end.x - saved.start.x, y: start.y + end.y - saved.start.y } });
report(engine.sessions.commit(viewId));
}}
onConnect={connect} onPaneClick={selectAtPane}>
<Background variant={BackgroundVariant.Dots} gap={24} size={1} color="#ded7ce" />
</ReactFlow>
</EdgeEditing.Provider></NodeEditing.Provider></CanvasRendererCommandsContext.Provider></ImagePreviews.Provider></CanvasOverlays.Provider>
<div data-canvas-ui-view={viewId} className={styles.topRight}><div className={`${toolbar.surface} ${styles.toolbar}`} role="toolbar" aria-label="图谱视图控制">
<IconButton icon={Undo2} label="撤销" disabled={!root.history.undo.length} onClick={() => report(engine.editor.history.undo())} /><IconButton icon={Redo2} label="重做" disabled={!root.history.redo.length} onClick={() => report(engine.editor.history.redo())} />
<IconButton icon={Copy} label={copying || preparingCopy ? "正在准备复制" : "深复制选中内容"} disabled={copying || preparingCopy || !copyAvailability.enabled} onClick={() => {
const captured = current.current, selection = [...captured.view.selection].map(id => captured.root.document.elements.get(id)).filter((item): item is ElementRecord => !!item).map(({id, incarnation}) => ({id, incarnation}));
setCopying(true); setError("");
void engine.imports.duplicate(selection, {viewCanvasId: captured.view.canvasId}).catch(error => { if (current.current.engine === engine && current.current.view.id === viewId) setError(errorText(error)); }).finally(() => { if (current.current.engine === engine && current.current.view.id === viewId) setCopying(false); });
}} />
<IconButton icon={Minus} label="缩小" onClick={() => flow.zoomOut()} /><IconButton icon={Plus} label="放大" onClick={() => flow.zoomIn()} /><IconButton icon={Maximize} label="适应画布" onClick={() => flow.fitView({ padding: .2 })} />
<IconButton icon={Download} label="导出图谱" disabled={!!loading} onClick={() => { const snapshot = engine.getSnapshot(), url = URL.createObjectURL(new Blob([JSON.stringify({ documentFormatVersion: 1, projectId: snapshot.document.projectId, canvases: [...snapshot.document.canvases.values()], elements: [...snapshot.document.elements.values()], tombstones: Object.fromEntries(snapshot.document.tombstones) })], { type: 'application/json' })); const a = window.document.createElement('a'); a.href = url; a.download = board.title + '.json'; a.click(); setTimeout(() => URL.revokeObjectURL(url), 1000); }} />
</div><p role="status" style={{ fontSize: 11 }}>{blocked ? '有修改需要处理' : loading ? `正在加载… ${loading.receivedElements}/${loading.totalElements},${loading.version>=3?"按已就绪范围同步":"修改已在本地排队"}` : pending.length ? '正在同步…' : '已同步'}{blocked && <><button onClick={() => void engine.retry().catch(e => setError(errorText(e)))}>重试</button><button aria-label="处理同步修改" onClick={() => setShowRecovery(true)}>处理同步修改</button></>}</p></div>
<div data-canvas-ui-view={viewId} className={`${toolbar.surface} ${toolbar.relaxed} ${styles.toolbar} ${styles.bottomToolbar}`} role="toolbar" aria-label="画布创作工具">
<IconButton icon={MousePointer2} label="选择工具(V)" aria-pressed={view.tool === 'select'} onClick={() => engine.sessions.tool(viewId, 'select')} /><IconButton icon={Hand} label="抓手工具(H)" aria-pressed={view.tool === 'pan'} onClick={() => engine.sessions.tool(viewId, 'pan')} />
<IconButton icon={Ratio} label="按图片比例调整" disabled={!!loading||!canFitImage} onClick={()=>{if(selected&&selectedResource)report(engine.fitImage({id:selected.id,incarnation:selected.incarnation},selectedResource.id));}} />
<IconButton icon={Sparkles} label="AI 创作" disabled={!!loading||!aiElement} aria-pressed={showAI} onClick={() => setShowAI(value => !value)} />
<IconButton icon={Type} label="添加纯文本" disabled={!canCreate} onClick={() => add('plain-text')} /><IconButton icon={StickyNote} label="添加文本卡片" disabled={!canCreate} onClick={() => add('text')} /><IconButton icon={ImagePlus} label="添加图片" disabled={!engine.canStageFiles(view.canvasId).enabled} onClick={() => imageInput.current?.click()} /><IconButton icon={Images} label="复用其它画布图片" disabled={!engine.canStageFiles(view.canvasId).enabled} onClick={()=>{const rect=surface.current?.getBoundingClientRect();setResourcePicker({canvasId:view.canvasId,position:flow.screenToFlowPosition({x:(rect?.left??0)+(rect?.width??window.innerWidth)/2,y:(rect?.top??0)+(rect?.height??window.innerHeight)/2})});}} /><IconButton icon={Frame} label="添加框选区" disabled={!canCreate} onClick={() => add('frame')} />
</div>
<input data-canvas-ui-view={viewId} ref={imageInput} type="file" accept="image/png,image/jpeg,image/gif,image/webp" multiple hidden onChange={event => { const files = [...event.target.files ?? []]; event.target.value = ''; try { report(engine.stageFiles(files.map(file => ({ name: file.name, bytes: file, defaultSize: true })), (refs, tx) => { for (const ref of refs) add('image', ref, tx); })); } catch (e) { setError(errorText(e)); } }} />
{(!loading||loading.version>=5)&&<HeadlessImports viewId={viewId} engine={engine} canvasId={view.canvasId} />}
{resourcePicker&&resourcePicker.canvasId===view.canvasId&&<HeadlessResourcePicker viewId={viewId} engine={engine} workspaceId={board.workspace_id} canvasId={resourcePicker.canvasId} position={resourcePicker.position} close={()=>setResourcePicker(null)} onAdded={id=>engine.sessions.select(viewId,[id])} />}
{showAI && aiElement && <HeadlessAIPanel viewId={viewId} key={`${aiElement.id}:${aiElement.incarnation}:${aiElement.ownerCanvasId}`} engine={engine} element={aiElement} close={() => setShowAI(false)} />}
{showRecovery && <HeadlessRecoveryDialog viewId={viewId} engine={engine} close={() => setShowRecovery(false)} />}
{error && <div className={styles.error} role="alert">{error} {displayState.engine===engine&&displayState.viewId===viewId&&displayState.failures>0&&<button onClick={()=>{setError('');resourceView.current?.retry();}}>重试图片</button>}<button onClick={() => setError('')}>关闭</button></div>}
</div>;
}
export default function HeadlessGraphCanvas({ engine, board, renderers=defaultCanvasRenderers,initialViewport }: { engine: ProjectEngine; board: Board; renderers?:ReactRenderers;initialViewport?:Viewport }) {
const [handoffViewport]=useState(initialViewport);
const [viewId] = useState(() => crypto.randomUUID()), [attached, setAttached] = useState(false);
useEffect(() => { const detach = engine.sessions.attach(viewId, board.id);if(handoffViewport)engine.sessions.viewport(viewId,handoffViewport); setAttached(true); return () => { setAttached(false); detach(); }; }, [engine, viewId, board.id,handoffViewport]);
return attached ? <ReactFlowProvider><CanvasProvider engine={engine} viewId={viewId}><CanvasView engine={engine} board={board} viewId={viewId} renderers={renderers} fitLoading={!handoffViewport} /></CanvasProvider></ReactFlowProvider> : <p role="status">正在准备画布…</p>;
}
@@ -0,0 +1,44 @@
"use client";
import { useCallback, useRef, useState, useSyncExternalStore } from "react";
import { FolderInput } from "lucide-react";
import type { ProjectEngine } from "@/lib/canvas-runtime/workspace";
import { IconButton } from "@/components/ui/icon-button";
import { errorText } from "@/lib/api";
import styles from "./canvas.module.css";
const reason = (code: string) => ({ import_asset_authorization_failed: "资源访问授权失败,此记录未加入", import_asset_capacity: "本组资源过大,无法一次准备", asset_depot_unavailable: "资源仓库暂不可用", import_resource_authorization_required: "资源需要重新授权,此记录尚未加入", invalid_document_import: "导出文档结构不合法", invalid_json_array: "JSON 记录数组格式错误", incomplete_json_array: "JSON 数组尾部不完整", json_nesting_limit: "JSON 嵌套层数过多", invalid_json: "记录格式错误", invalid_json_value: "记录包含不支持的数据", invalid_utf8: "文件编码不是 UTF-8", record_too_large: "单条记录过大", unresolved_dependency: "引用的记录未加入", invalid_native_import_record: "不是画布记录", import_component_unavailable: "元素类型暂不支持", invalid_geometry: "元素尺寸不合法", invalid_endpoint: "连线端点已不存在", duplicate_import_source_key: "记录标识重复", import_source_read_failed: "暂时无法读取源文件,可重试;已加入内容保留", import_source_verification_failed: "源文件校验失败,可重试;已加入内容保留", invalid_import_read_proof: "源文件读取证据不一致,任务已暂停", import_source_unavailable: "导入源文件不可用", import_source_changed: "源文件内容与保存时不一致,任务已暂停", import_source_integrity_unavailable: "旧任务缺少源内容校验,无法安全继续;已加入内容保留", import_source_integrity_missing: "源校验记录缺失,任务已暂停", import_source_integrity_interpreter_unavailable: "源校验版本暂不支持", import_target_missing: "目标分区已不存在", import_target_changed: "目标分区已改变", import_dependency_changed: "引用对象已删除或恢复为新版本", dependency_capacity: "等待引用过多,请调整文件顺序", copy_external_changed: "原图引用已删除、移动或恢复为新版本", invalid_duplicate_source: "复制源数据不可用", invalid_copy_snapshot: "复制快照不可用", invalid_copy_preparation: "复制准备记录不可用", copy_interpreter_changed: "复制规则已变化,需要重新复制", invalid_import_source: "文件过大或格式不支持" } as Record<string,string>)[code] ?? "这条记录暂时无法加入";
export function HeadlessImports({ engine, canvasId, viewId }: { engine: ProjectEngine; canvasId: string; viewId:string }) {
const subscribe = useCallback((notify: () => void) => engine.editor.subscribe(notify), [engine]);
const snapshot = useSyncExternalStore(subscribe, engine.getSnapshot, engine.getSnapshot);
const input = useRef<HTMLInputElement>(null), [open, setOpen] = useState(false), [error, setError] = useState("");
const scope = useRef({ engine, canvasId }); scope.current = { engine, canvasId };
const jobs = [...(snapshot.runtime.imports?.values() ?? [])].filter(job => job.source?.targetCanvasId === canvasId).slice(-3).reverse();
const control = (work: Promise<void>) => { void work.then(() => { if (scope.current.engine === engine && scope.current.canvasId === canvasId) setError(""); }).catch(error => { if (scope.current.engine === engine && scope.current.canvasId === canvasId) setError(errorText(error)); }); };
return <aside data-canvas-ui-view={viewId} className={styles.importPanel} aria-label="画布导入">
<div className={styles.importActions}><IconButton icon={FolderInput} label="导入画布记录" disabled={!engine.imports.canStart(canvasId).enabled} onClick={() => input.current?.click()} /><span>NDJSON / JSON 记录数组</span></div>
<input data-canvas-import ref={input} type="file" accept=".ndjson,.jsonl,.json,application/json,application/x-ndjson" hidden onChange={async event => {
const file = event.target.files?.[0]; event.target.value = ""; if (!file) return;
try { if(file.name.toLowerCase().endsWith('.json')){const before=engine.getSnapshot().document,target=before.canvases.get(canvasId),parent=JSON.stringify(target?.parent),entry=target?.parent?before.elements.get(target.parent.entryElementId)?.incarnation:undefined;const prefix=(await file.slice(0,65536).text()).trimStart()[0];if(scope.current.engine!==engine||scope.current.canvasId!==canvasId)return;const current=engine.getSnapshot().document,now=current.canvases.get(canvasId);if(!target||!now||JSON.stringify(now.parent)!==parent||(now.parent?current.elements.get(now.parent.entryElementId)?.incarnation:undefined)!==entry)throw Error('import_target_changed');if(prefix==='{')engine.imports.startDocument(file,canvasId);else if(prefix==='[')engine.imports.startJsonArray(file,canvasId);else throw Error('invalid_import_source');}else engine.imports.startNdjson(file,canvasId); setOpen(true); setError(""); } catch (error) { if(scope.current.engine===engine&&scope.current.canvasId===canvasId)setError(errorText(error)); }
}} />
{jobs.length > 0 && <details open={open} onToggle={event => setOpen(event.currentTarget.open)}><summary>导入与复制任务 · {jobs.some(job => job.status === "preparing") && "正在准备复制 · "}{jobs.reduce((count, job) => count + job.committedSourceKeys.length, 0)} 项已加入</summary>
{jobs.map((job, index) => <section className={styles.importJob} key={job.id} aria-label={`导入任务 ${index + 1}`}>
<strong>{job.source?.name || `导入任务 ${index + 1}`}</strong>
<p role="status">{["running","preparing"].includes(job.status) && job.source?.integrityVersion === 1 && !job.source.sha256 ? "正在校验源文件" : job.status === "preparing" ? "正在准备复制" : job.status === "running" ? "正在加入" : job.status === "paused" ? "已暂停" : job.status === "cancelled" ? "已停止" : job.status === "partial" ? "部分加入" : "已加入画布"} · {job.committedSourceKeys.length} 项{job.pending.length > 0 && `,${job.pending.length} 项等待`}</p>
{!(job.status==='cancelled'&&!job.committedSourceKeys.length)&&job.source?.integrityVersion===2&&!job.source.sha256&&!job.source.integrityError&&<small>内容可继续编辑,源文件校验完成后同步。</small>}
{job.source?.retirement?.purgedAt !== undefined && <small>本地源文件已清理;画布内容和撤销记录保留。</small>}
{job.error && <p>{reason(job.error)}</p>}
<div className={styles.importActions}>
{job.source?.integrityError&&<button onClick={()=>control(engine.retry())}>重试源文件校验</button>}
{["running","preparing"].includes(job.status) && <button onClick={() => control(engine.imports.pause(job.id))}>暂停导入</button>}
{job.status === "paused" && <button onClick={() => control(engine.imports.resume(job.id))}>继续导入</button>}
{["running","preparing","paused"].includes(job.status) && <button onClick={() => control(engine.imports.cancel(job.id))}>停止导入</button>}
</div>
{job.status === "cancelled" && <small>已加入的内容保留并继续同步。</small>}
{job.failures.length > 0 && <details><summary>{job.failures.length} 条未加入记录</summary><ul>{job.failures.slice(0, 10).map((failure, item) => <li key={`${failure.key}:${item}`}>{failure.key.startsWith("byte:") ? `文件位置 ${failure.cursor}` : failure.key}:{reason(failure.code)}</li>)}</ul></details>}
</section>)}
</details>}
{error && <p role="alert">{error}{jobs.length > 0 && <button onClick={() => control(engine.retry())}>重试保存</button>}</p>}
</aside>;
}
@@ -0,0 +1,87 @@
"use client";
import { useEffect, useRef, useState } from 'react';
import type { Change, Document, Id, Json, OperationIntent } from '@/lib/canvas-core/types';
import type { ProjectEngine } from '@/lib/canvas-runtime/workspace';
import type { RecoverySession } from '@/lib/canvas-runtime/engine';
import type { ConflictPlan, RecoveryChoice } from '@/lib/canvas-runtime/conflicts';
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogTitle } from '@/components/ui/dialog';
function message(error:unknown){
const code=(error as {code?:string;message?:string})?.code??(error as Error)?.message;
if(code==='recovery_waiting_for_receipts')return '还有请求正在确认。请关闭此窗口,重试同步后再处理。';
if(code==='recovery_read_behind_receipt')return '服务端读取尚未包含已确认的修改,请稍后重新检查。';
if(code?.endsWith('receipt_interpreter_unavailable')||code==='recovery_receipt_missing')return '暂时无法验证原成功回执,修改和原请求仍已保留。请稍后重试。';
if(code==='recovery_in_progress')return '上一次处理尚未结束。可先恢复编辑,再重新检查。';
if(code==='recovery_choices_incomplete')return '请为每一项修改选择处理方式。';
if(code==='stale_incarnation')return '服务端已删除或替换了这个元素。可以放弃这项本地修改。';
if(code==='operation_context_changed')return '元素所属分区已改变,请重新预览这项修改。';
if(code==='ownership_conflict')return '元素已移入其它分区,当前坐标需要重新确认,暂时无法直接保留。';
if(code==='recovery_identity_collision')return '这个身份已在服务端使用,不能覆盖另一份内容。可以放弃本地创建,或重新创建一个元素。';
if(code==='stale_endpoint')return '连线所指元素已删除或进入新的生命周期,请重新选择端点。';
if(code==='unsupported_presentation_units'||code==='invalid_presentation_units')return '这个元素的展示单位暂时无法转换,原内容已保留,请确认展示格式后重试。';
if(code==='unsupported_coordinate_path')return '这条曲线使用未知坐标格式,当前无法安全转换归属,原路径仍已保留。';
if(code==='history_replay_interpreter_unavailable'||code==='history_transition_interpreter_unavailable'||code==='operation_interpreter_unavailable')return '这份修改或恢复记录来自当前无法解析的版本,原内容和请求已保留。请更新应用后重试。';
if(code==='recovery_history_adoption_evidence_missing')return '这次恢复缺少完整历史步骤,暂时无法安全继续处理;原内容和请求已保留。';
if(code==='recovery_history_traversal_order')return '这些撤销或重做有先后顺序,不能跳过前一项继续处理后一项。请重新选择。';
if(code==='recovery_history_traversal_conflict')return '服务端内容与这项历史记录已有冲突,暂时无法安全调整撤销或重做;原内容和历史均已保留。';
if(code?.startsWith('recovery_history')||code==='recovery_record_replacement_not_supported')return '这批修改含结构或撤销操作,暂时无法自动处理。原内容和历史均已保留。';
if(code==='recovery_action_requires_new_command')return '生成任务需要重新手动发起。可以在这里放弃尚未执行的请求。';
if(code==='forbidden'||code==='unauthorized'||code==='identity_changed')return '当前账号无法读取或修改此画布,请确认权限后重试。';
return '处理未完成,当前修改仍已保留。请重试,或恢复编辑后稍后处理。';
}
function PreviewValue({value}:{value:Json|undefined}){
if(value===undefined)return <span>未设置</span>;
const text=typeof value==='string'?value:JSON.stringify(value);
return text.length>240?<details><summary>{text.slice(0,240)}…(展开全文)</summary><pre style={{whiteSpace:'pre-wrap',overflowWrap:'anywhere'}}>{text}</pre></details>:<span>{text}</span>;
}
const fieldLabel=(field:string)=>({'data.title':'标题','data.text':'正文',geometry:'位置与尺寸',path:'连线路径'}[field]??(field.startsWith('data.')?'内容字段':'元素关系'));
function StructureReview({change,document}:{change:Exclude<Change,{kind:'field'}>;document:Document}){
const current=change.kind==='element'?document.elements.get(change.id):document.canvases.get(change.id),kind=change.kind==='element'?'元素':'分区';
const title=change.kind==='element'?(change.after?.data.title??change.before?.data.title??change.id):change.id;
return <div style={{fontSize:13,overflowWrap:'anywhere',marginTop:8}}><strong>{change.after===null?'删除':change.before===null?'创建':'调整'}{kind} · {String(title)}</strong>
<div>服务端:{current?<PreviewValue value={current as unknown as Json}/>:<span>不存在</span>}</div><div>本地请求:{change.after?<PreviewValue value={change.after as unknown as Json}/>:<span>删除此{kind}</span>}</div></div>;
}
function PlanStructureReview({plan}:{plan:ConflictPlan}){
const changes=plan.operationIds.flatMap(id=>plan.operations.get(id)!.changes),structural=changes.filter(change=>change.kind!=='field'||['ownerCanvasId','source','target','path'].includes(change.field));
if(!structural.length)return null;
const elements=structural.filter(change=>change.kind==='element'&&change.after===null).length,canvases=structural.filter(change=>change.kind==='canvas'&&change.after===null).length;
return <details><summary>关联结构变更 {structural.length} 项 · 删除 {elements} 个元素、{canvases} 个分区(展开查看)</summary>
{structural.map((change,index)=><div key={index} style={{borderBottom:'1px solid #ddd5ca',fontSize:12,overflowWrap:'anywhere',padding:8}}><strong>{change.kind==='field'?fieldLabel(change.field):change.kind==='element'?'元素':'分区'} · {change.kind==='field'?change.target.id:change.id}</strong><div>修改前:<PreviewValue value={change.before as unknown as Json}/></div><div>修改后:<PreviewValue value={change.after as unknown as Json}/></div></div>)}
</details>;
}
export function HeadlessRecoveryDialog({engine,close,viewId}:{engine:ProjectEngine;close():void;viewId:string}){
const owner=useRef<RecoverySession|undefined>(undefined),[session,setSession]=useState<RecoverySession>(),[records,setRecords]=useState<readonly OperationIntent[]>([]);
const [choices,setChoices]=useState(new Map<Id,RecoveryChoice>()),[plan,setPlan]=useState<ConflictPlan>(),[error,setError]=useState(''),[busy,setBusy]=useState(true),[frozen,setFrozen]=useState(false);
useEffect(()=>{
let live=true;
Promise.resolve().then(()=>live?engine.prepareRecovery():undefined).then(async session=>{if(!session)return;if(!live){await session.cancel();return;}owner.current=session;setSession(session);setRecords([...engine.getSnapshot().runtime.ledger.operations.values()].filter(record=>session.pendingOperationIds.includes(record.intent.id)&&record.intent.operationType!=='asset.upload').sort((a,b)=>a.intent.sequence-b.intent.sequence).map(record=>record.intent));}).catch(error=>{if(live)setError(message(error));}).finally(()=>{if(live)setBusy(false);});
return()=>{live=false;void owner.current?.cancel().catch(()=>{/* Engine retains the failed durable barrier; retry/cancelRecovery can finish it. */});};
},[engine]);
async function cancel(){if(busy&&session)return;setBusy(true);try{await engine.cancelRecovery();close();}catch(error){setError(message(error));}finally{setBusy(false);}}
async function apply(){if(!session||busy)return;setBusy(true);setError('');setFrozen(true);try{const result=plan?await session.resolve(plan):await session.apply();if(result.status==='rejected'){setFrozen(false);setError(message({code:result.code}));return;}close();}catch(error){setError(message(error));}finally{setBusy(false);}}
const canonical=session?.canonical.document;
return <Dialog open onOpenChange={open=>{if(!open)void cancel();}}><DialogContent data-canvas-ui-view={viewId} aria-label="处理同步修改" style={{zIndex:300,maxHeight:'80vh',overflowY:'auto',background:'var(--background, #fffdf9)',color:'var(--foreground, #302d29)'}} onEscapeKeyDown={event=>{if(busy&&session)event.preventDefault();}}>
<DialogTitle>处理同步修改</DialogTitle>
<DialogDescription>检查期间暂停编辑。逐项选择要保留的修改,预览后再应用;原内容和撤销记录会保留备份。</DialogDescription>
{busy&&!session&&<p role="status">正在等待同步并读取最新内容…</p>}
{!!session?.acknowledgedRequestIds.length&&<p>已找回 {session.acknowledgedRequestIds.length} 个成功回执,仅处理后续尚未同步的修改。</p>}
{session&&records.length===0&&<p>没有待处理的本地修改,可以采用服务端最新内容。</p>}
{session&&records.map((intent,index)=><article key={intent.id} style={{borderBottom:'1px solid #ddd5ca',paddingBottom:12}}>
<strong>修改 {index+1}{intent.origin==='undo'?' · 未同步的撤销':intent.origin==='redo'?' · 未同步的重做':''}{intent.operationType==='run.start'?' · 尚未受理的生成':intent.operationType==='run.cancel'?' · 尚未确认的取消':''}</strong>
{intent.changes.map((change,changeIndex)=>{if(change.kind!=='field')return <StructureReview key={changeIndex} change={change} document={canonical!}/>;const element=canonical?.elements.get(change.target.id),key=change.field.startsWith('data.')?change.field.slice(5):undefined,value=key===undefined?(element as unknown as Record<string,Json>|undefined)?.[change.field]:element?.data[key],proposed=plan?.document.elements.get(change.target.id),proposedValue=key===undefined?(proposed as unknown as Record<string,Json>|undefined)?.[change.field]:proposed?.data[key];return <div key={changeIndex} style={{fontSize:13,whiteSpace:'pre-wrap',overflowWrap:'anywhere',marginTop:8}}><span>{fieldLabel(change.field)}</span><div>服务端:{element?<PreviewValue value={value}/>:'元素已不存在'}</div><div>本地:{change.afterPresent===false?'移除此字段':<PreviewValue value={change.after}/>}</div>{plan&&<div>处理后:{proposed?<PreviewValue value={proposedValue}/>:'元素已不存在'}</div>}</div>;})}
<select aria-label={`处理第${index+1}项修改`} value={choices.get(intent.id)??''} disabled={busy||frozen} onChange={event=>{const next=new Map(choices);if(event.target.value)next.set(intent.id,event.target.value as RecoveryChoice);else next.delete(intent.id);setChoices(next);setPlan(undefined);setError('');}}>
<option value="">选择处理方式</option>{['document.change','run.apply'].includes(intent.operationType)&&<option value="keep-local">{intent.origin==='undo'?'保留这次撤销':intent.origin==='redo'?'保留这次重做':'保留这项本地修改'}</option>}<option value="discard-local">{intent.origin==='undo'?'放弃这次撤销':intent.origin==='redo'?'放弃这次重做':'放弃这项本地修改'}</option>
</select>
</article>)}
{plan&&<p role="status">方案已生成:保留 {plan.audit.decisions.filter(item=>item.choice==='keep-local').length} 项,放弃 {plan.audit.decisions.filter(item=>item.choice==='discard-local').length} 项。撤销记录由 {engine.getSnapshot().history.undo.length} 项调整为 {plan.history.undo.length} 项,重做记录由 {engine.getSnapshot().history.redo.length} 项调整为 {plan.history.redo.length} 项。</p>}
{plan&&<PlanStructureReview plan={plan}/>}
{error&&<p role="alert">{error}</p>}
<DialogFooter><button disabled={busy&&!!session} onClick={()=>void cancel()}>{session?'取消并恢复编辑':'恢复编辑'}</button>
{session&&records.length>0&&<button aria-label="预览处理方案" disabled={busy||frozen||choices.size!==records.length} onClick={()=>{try{setPlan(session.plan(choices));setError('');}catch(error){setPlan(undefined);setError(message(error));}}}>预览处理方案</button>}
{session&&<button aria-label="应用处理方案" disabled={busy||records.length>0&&!plan} onClick={()=>void apply()}>{busy?'正在保存…':frozen?'重试保存处理结果':'应用处理方案'}</button>}
</DialogFooter>
</DialogContent></Dialog>;
}
@@ -0,0 +1,64 @@
"use client";
import { useEffect, useId, useRef, useState } from "react";
import { ImageIcon } from "lucide-react";
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { useCanvasRuntimeHost } from "@/components/workspace/canvas-runtime-host";
import type { ProjectEngine } from "@/lib/canvas-runtime/workspace";
import type { Id, Point } from "@/lib/canvas-core/types";
import { fault } from "@/lib/canvas-core/values";
import { errorText } from "@/lib/api";
function Preview({src}:{src:string}){const [failed,setFailed]=useState(false);return failed?<ImageIcon aria-hidden className="h-8 w-8 opacity-50"/>:<img src={src} alt="" loading="lazy" className="h-full w-full object-contain" onError={()=>setFailed(true)}/>;}
type Candidate = { id: Id; title: string };
export function HeadlessResourcePicker({engine,workspaceId,canvasId,position,close,onAdded,viewId}:{engine:ProjectEngine;workspaceId:Id;canvasId:Id;position:Point;close():void;onAdded(id:Id):void;viewId:string}) {
const host=useCanvasRuntimeHost(),[items,setItems]=useState<Candidate[]>([]),[loading,setLoading]=useState(true),[busy,setBusy]=useState<Id>(),[error,setError]=useState("");
const lifecycle=useRef<AbortController|null>(null),requesting=useRef(false),descriptionId=useId();
const dismiss=()=>{lifecycle.current?.abort();close();};
const [target]=useState(()=>{const document=engine.getSnapshot().document,canvas=document.canvases.get(canvasId),entry=canvas?.parent?document.elements.get(canvas.parent.entryElementId):undefined;return {canvas,entry};});
useEffect(()=>{
const controller=new AbortController();lifecycle.current=controller;
const identity=host.identity(),workspace=host.getWorkspaces().get(workspaceId);
void (async()=>{
if(!workspace?.isCurrentIdentity()||workspace.accountId!==identity.accountId)throw fault('identity_changed');
const results=await Promise.allSettled([...workspace.getProjects()].filter(([id])=>id!==engine.getSnapshot().document.projectId).map(([,project])=>project));
if(controller.signal.aborted)return;
if(!workspace.isCurrentIdentity()||host.identity().authEpoch!==identity.authEpoch)throw fault('identity_changed');
const candidates=new Map<Id,Candidate>(),owned=engine.getSnapshot().runtime.resources;
for(const result of results)if(result.status==='fulfilled'){
const snapshot=result.value.getSnapshot();
for(const element of snapshot.document.elements.values()){
const id=element.data.assetId;
if(element.structure!=='node'||typeof id!=='string'||!id||owned.has(id)||candidates.has(id)||snapshot.runtime.resources.has(id)&&!snapshot.runtime.resources.get(id)!.stored)continue;
if(!result.value.editor.registry.components.get(element.componentId)?.resourceFields?.includes('assetId'))continue;
candidates.set(id,{id,title:typeof element.data.title==='string'&&element.data.title.trim()?element.data.title:'未命名图片'});
}
}
setItems([...candidates.values()]);setLoading(false);
})().catch(error=>{if(!controller.signal.aborted){setLoading(false);setError(errorText(error));}});
return()=>{controller.abort();if(lifecycle.current===controller)lifecycle.current=null;};
},[engine,host,workspaceId]);
async function insert(item:Candidate){
const signal=lifecycle.current?.signal;if(!signal||signal.aborted||requesting.current)return;
requesting.current=true;
setBusy(item.id);setError("");
try{
const id=crypto.randomUUID(),result=await engine.adoptResource(item.id,(resource,tx)=>{
const document=tx.getDocument(),canvas=document.canvases.get(canvasId),entry=canvas?.parent?document.elements.get(canvas.parent.entryElementId):undefined;
if(!canvas||canvas.parent?.entryElementId!==target.canvas?.parent?.entryElementId||target.entry&&(entry?.id!==target.entry.id||entry.incarnation!==target.entry.incarnation))throw fault('asset_target_changed');
const scale=Math.min(260/resource.asset.width,200/resource.asset.height),width=resource.asset.width*scale,height=resource.asset.height*scale;
tx.dispatch({type:'element.create',element:{id,incarnation:1,structure:'node',componentId:'image',dataVersion:1,ownerCanvasId:canvasId,geometry:{x:position.x-width/2,y:position.y-height/2,width,height,rotation:0},data:{title:item.title,text:'',assetId:resource.id,src:resource.src}}});
},{signal});
if(result.status==='rejected')throw fault(result.code,result.targets);
if(!signal.aborted){onAdded(id);close();}
}catch(error){if(!signal.aborted)setError(error instanceof Error&&error.message==='not_found'?'无法读取这张图片,请确认仍有原画布的访问权限。':errorText(error));}
finally{requesting.current=false;if(!signal.aborted)setBusy(undefined);}
}
return <Dialog open onOpenChange={open=>{if(!open)dismiss();}}><DialogContent data-canvas-ui-view={viewId} aria-describedby={descriptionId}>
<DialogHeader><DialogTitle>从其它画布添加图片</DialogTitle><DialogDescription id={descriptionId}>选择同一工作区已打开画布中的图片,无需再次上传。</DialogDescription></DialogHeader>
{loading?<p role="status">正在查找图片…</p>:items.length===0?<p>暂无可复用的图片。先打开有已上传图片的画布,再回到这里。</p>:<div className="grid max-h-80 grid-cols-2 gap-3 overflow-y-auto">{items.map(item=><button key={item.id} type="button" aria-label={`添加图片:${item.title}`} disabled={!!busy} onClick={()=>void insert(item)} className="flex min-w-0 flex-col items-center gap-2 rounded-lg border border-border p-3 text-sm hover:bg-muted disabled:opacity-50">
<span className="flex h-20 w-full items-center justify-center overflow-hidden rounded bg-muted"><Preview src={`/api/foundry/workspaces/${encodeURIComponent(workspaceId)}/assets/${encodeURIComponent(item.id)}?variant=preview`}/></span><span className="w-full truncate">{item.title}</span>{busy===item.id&&<span role="status">正在添加…</span>}
</button>)}</div>}
{error&&<p role="alert">{error}</p>}
</DialogContent></Dialog>;
}
@@ -0,0 +1,105 @@
"use client";
import {useEffect,useRef,useState} from 'react';
import type {Registry} from '@/lib/canvas-core/types';
import {claimLegacyCanvasEditing,createBrowserCanvasMigration,type LegacyMigrationContext} from '@/lib/browser-canvas-migration';
import {canvasMigrationStorageKey,migrationBlocksCanvas,readCanvasMigration,type CanvasMigrationJob} from '@/lib/canvas-migration';
import {errorText} from '@/lib/api';
import styles from './canvas.module.css';
function message(error:unknown){
const code=error instanceof Error?error.message:String(error);
return ({migration_busy:'其他旧画布页面仍在编辑或升级,请先完成那里的保存和上传,再关闭页面重试。',migration_lock_unavailable:'此浏览器不支持安全升级,请使用支持 Web Locks 的浏览器。',migration_response_unknown:'升级结果尚未确认,编辑已暂停。可继续查询原请求。',invalid_migration_receipt:'升级回执无法确认,已保留原请求并暂停编辑。请继续查询或联系支持。',migration_pending_uploads:'请先完成或取消图片上传,再升级。',draft_recovery_required:'请先处理本地草稿恢复,再升级。',canvas_busy:'请先完成当前操作与同步,再升级。',migration_waiting_for_runs:'请等待生成任务结束,再重新准备升级。',migration_version_conflict:'画布已发生变化,请重新准备升级。',workspace_migration_pending:'本工作区还有另一画布的升级待确认,请先处理原画布。',invalid_migration_record:'无法读取本地升级进度,已保留记录并暂停编辑。',migration_interpreter_unavailable:'本地升级进度需要其他版本解释,已保留记录并暂停编辑。',migration_not_durable:'无法可靠保存升级进度,请保留页面并重试。'} as Record<string,string>)[code]??errorText(error instanceof Error?error:new Error(code));
}
type Props={context:LegacyMigrationContext;workspaceId:string;canvasId:string;registry:Registry;held:boolean;hold(value:boolean):void;prepare(rootId:string):Promise<void>;check():void;canLeave():boolean;exportDraft():void};
export function LegacyCanvasMigration(props:Props){
const port=useRef(props);port.current=props;
const [job,setJob]=useState<CanvasMigrationJob>(),[error,setError]=useState(''),[checking,setChecking]=useState(true),[running,setRunning]=useState(false),[changedElsewhere,setChangedElsewhere]=useState(false);
const lease=useRef<{release():Promise<void>}|undefined>(undefined),life=useRef<AbortController|undefined>(undefined),attempt=useRef(false),finished=useRef(false),previous=useRef<CanvasMigrationJob|undefined>(undefined);
const scope={accountId:props.context.accountId,workspaceId:props.workspaceId},key=canvasMigrationStorageKey(scope);
const refresh=useRef<()=>Promise<void>>(async()=>{});
useEffect(()=>{
const controller=new AbortController();life.current=controller;
const current=()=>!controller.signal.aborted&&port.current.context.isCurrentIdentity();
const read=()=>readCanvasMigration(localStorage,scope);
async function inspect(){
if(attempt.current||!current())return;
setChecking(true);
try{
const next=read();previous.current=next;setJob(next);
if(migrationBlocksCanvas(next,props.canvasId)||finished.current){
port.current.hold(true);const held=lease.current;lease.current=undefined;await held?.release();setError('');return;
}
if(lease.current){port.current.hold(false);setError('');return;}
port.current.hold(true);
const acquired=await claimLegacyCanvasEditing({scope,canvasId:props.canvasId,signal:controller.signal,isCurrentIdentity:current});
if(!current()){await acquired.release();return;}
lease.current=acquired;port.current.hold(false);setError('');
}catch(error){
if(!current())return;
port.current.hold(true);const held=lease.current;lease.current=undefined;await held?.release();
// Without Web Locks this browser cannot upgrade, but existing legacy
// editing remains supported unless there is an unresolved identity job.
if(error instanceof Error&&error.message==='migration_lock_unavailable'){
try{if(!migrationBlocksCanvas(read(),props.canvasId))port.current.hold(false);}catch{}
}
setError(message(error));
}finally{if(current())setChecking(false);}
}
refresh.current=inspect;void inspect();
const storage=(event:StorageEvent)=>{
if(event.key!==key&&event.key!==null||!current()||attempt.current)return;
// Never discard another view's in-memory content because a record vanished.
if(previous.current?.phase==='pending'&&event.newValue===null){
finished.current=true;port.current.hold(true);setChangedElsewhere(true);setError('升级进度已在其他页面改变。本页内容保留,可导出后重新打开。');
const held=lease.current;lease.current=undefined;void held?.release();return;
}
void inspect();
};
window.addEventListener('storage',storage);
return()=>{controller.abort();window.removeEventListener('storage',storage);const held=lease.current;lease.current=undefined;void held?.release();};
// The port ref follows renders without re-acquiring a shared editing lease.
// eslint-disable-next-line react-hooks/exhaustive-deps
},[key,props.canvasId]);
async function upgrade(){
const controller=life.current;if(!controller||controller.signal.aborted||attempt.current)return;
attempt.current=true;setRunning(true);setError('');
const current=()=>!controller.signal.aborted&&port.current.context.isCurrentIdentity();
let failure='';
try{
const pending=readCanvasMigration(localStorage,scope);
if(pending?.phase==='pending'&&!migrationBlocksCanvas(pending,props.canvasId))throw new Error('workspace_migration_pending');
if(pending?.phase!=='pending')port.current.check();
port.current.hold(true);
// Drain while still holding our shared lease, so losing the subsequent
// exclusive-lock race never abandons a dirty old writer between locks.
if(pending?.phase!=='pending'&&props.context.rootId){await port.current.prepare(props.context.rootId);if(!current())return;}
const held=lease.current;lease.current=undefined;await held?.release();if(!current())return;
const migration=createBrowserCanvasMigration({scope,registry:props.registry,signal:controller.signal,isCurrentIdentity:current});
const result=pending?.phase==='pending'?await migration.resume():props.context.rootId?await migration.start(props.context.rootId,async()=>{port.current.check();await port.current.prepare(props.context.rootId!);port.current.check();}):undefined;
if(!current())return;
if(result?.status==='applied'||result?.status==='already-migrated'){
finished.current=true;port.current.context.onApplied();return;
}
if(result&&'error'in result){failure=message(result.error);setError(failure);}
const next=migration.read();previous.current=next;setJob(next);
}catch(error){if(current()){failure=message(error);setError(failure);}}
finally{
attempt.current=false;
if(current()){
setRunning(false);
try{const next=readCanvasMigration(localStorage,scope);previous.current=next;setJob(next);if(!finished.current&&!migrationBlocksCanvas(next,props.canvasId))await refresh.current();}catch(error){port.current.hold(true);failure=message(error);}
if(failure)setError(failure);
}
}
}
const pending=migrationBlocksCanvas(job,props.canvasId),other=job?.phase==='pending'&&!pending;
if(!props.context.rootId&&!pending&&!error&&!props.held)return null;
return <div className={styles.migrationPanel} aria-label="画布升级">
{checking?<span role="status">正在检查画布编辑权限…</span>:<>
{running?<span role="status">正在保存并升级画布…</span>:changedElsewhere?<button onClick={()=>{try{port.current.check();if(!port.current.canLeave())throw new Error('本页有未同步内容,请先导出备份并手动重新打开画布。');port.current.context.onApplied();}catch(error){setError(message(error));}}}>重新打开画布</button>:props.context.rootId||pending?<button disabled={other} title="保存内容并使用新版编辑器;旧撤销记录不会带入" onClick={()=>void upgrade()}>{pending?'继续查询升级':job?.phase==='rejected'?'重新准备升级':'升级画布'}</button>:props.held?<button onClick={()=>void refresh.current()}>重新检查</button>:null}
{pending&&!running&&<span role="status">结果待确认,旧编辑已暂停。</span>}
{error&&<span role="alert">{error}</span>}
{props.held&&!running&&<button onClick={props.exportDraft}>导出本页备份</button>}
</>}
</div>;
}
@@ -0,0 +1,43 @@
"use client";
import {useEffect,useMemo,useRef} from 'react';
import {Background,BackgroundVariant,ReactFlow,useReactFlow,type Viewport,type NodeTypes, type NodeProps,Handle,Position} from '@xyflow/react';
import type {Registry} from '@/lib/canvas-core/types';
import type {ModelPreview} from '@/lib/canvas-runtime/model-preview';
import type {ReactRenderers} from '@/lib/canvas-bindings/react-flow';
import {createLoadingRendererCatalog} from '@/lib/canvas-bindings/renderers';
import {projectCanvas} from '@/lib/canvas-bindings/projection';
import {CanvasRendererCommandsContext,type CanvasRendererCommands} from '@/lib/canvas-bindings/react-flow';
import {UnsupportedNode,UnsupportedEdge} from './unsupported-elements';
import styles from './canvas.module.css';
function PreparingNode({data}:NodeProps){return <div style={{width:'100%',height:'100%',padding:14,border:'1px solid #ded7ce',borderRadius:8,background:'#fffdfa'}} aria-label="正在准备元素"><span>{typeof data.title==='string'?data.title:'正在准备元素…'}</span>{(data.__previewHandles as {id?:string;type:'source'|'target'}[]??[]).map((handle,index)=><Handle key={index} type={handle.type} id={handle.id} position={handle.type==='source'?Position.Right:Position.Left} isConnectable={false}/>)}</div>;}
const reject=()=>({status:'rejected' as const,code:'model_loading',targets:[]});
const commands:CanvasRendererCommands=Object.freeze({dispatch:reject,transaction:reject});
function InitialPreviewViewport({onViewport,nodeCount}:{onViewport(viewport:Viewport):void;nodeCount:number}){
const flow=useReactFlow(),fitted=useRef(false),notify=useRef(onViewport);notify.current=onViewport;
useEffect(()=>{if(!flow.viewportInitialized||fitted.current||!nodeCount)return;fitted.current=true;let active=true;void flow.fitView({padding:.2,maxZoom:1}).then(()=>{if(active)notify.current(flow.getViewport());});return()=>{active=false;};},[flow,nodeCount]);
return null;
}
/** Render the validated read prefix directly. No temporary editor, ACK, ledger or
* writable engine is constructed, and no partial model enters durable storage. */
export default function LoadingGraphCanvas({preview,canvasId,registry,renderers,workspaceId,onViewport}: {preview:ModelPreview;canvasId:string;workspaceId:string;registry:Registry;renderers:ReactRenderers;onViewport(viewport:Viewport):void}){
// Without a declared reference schema, use the existing opaque placeholder
// until the complete document is available to the installed renderer.
const catalog=useMemo(()=>createLoadingRendererCatalog(registry,renderers.catalog),[registry,renderers]);
const projection=useMemo(()=>{
const value=projectCanvas(preview.document,canvasId,registry,new Set(),()=>undefined,undefined,catalog);
const handles=new Map<string,{id?:string;type:'source'|'target'}[]>();for(const edge of value.edges)for(const type of ['source','target'] as const){const id=edge[type],rows=handles.get(id)??[];rows.push({type,id:edge[type==='source'?'sourceHandle':'targetHandle']??undefined});handles.set(id,rows);}
return{nodes:value.nodes.map(node=>({...node,draggable:false,selectable:false,focusable:false,connectable:false,style:{...node.style,pointerEvents:'none' as const},data:{...node.data,__previewHandles:handles.get(node.id)??[],...(registry.resolve(preview.document.elements.get(node.id)!)==='known'&&typeof node.data.assetId==='string'&&registry.components.get(preview.document.elements.get(node.id)!.componentId)?.resourceFields?.includes('assetId')?{src:`/api/foundry/workspaces/${encodeURIComponent(workspaceId)}/assets/${encodeURIComponent(node.data.assetId)}?variant=preview`}:{})}})),edges:value.edges.map(edge=>({...edge,selectable:false,focusable:false}))};
},[preview.document,canvasId,workspaceId,registry,catalog]);
const nodeTypes=useMemo<NodeTypes>(()=>{const result:NodeTypes={};for(const [id,Renderer]of Object.entries({...Object.fromEntries(Object.keys(renderers.nodeTypes).map(id=>[id,renderers.previewNodeTypes[id]??PreparingNode])),unsupported:UnsupportedNode}))result[id]=props=><div inert style={{width:'100%',height:'100%'}}><Renderer {...props}/></div>;return result;},[renderers]),edgeTypes=useMemo(()=>({...Object.fromEntries(Object.keys(renderers.edgeTypes).map(id=>[id,renderers.previewEdgeTypes[id]??UnsupportedEdge])),unsupported:UnsupportedEdge}),[renderers]);
return <div className={styles.editor} data-canvas-loading-preview aria-label="正在加载的画布,只读预览">
<CanvasRendererCommandsContext.Provider value={commands}>
<ReactFlow nodes={projection.nodes} edges={projection.edges} nodeTypes={nodeTypes} edgeTypes={edgeTypes} nodesDraggable={false} nodesConnectable={false} elementsSelectable={false} nodesFocusable={false} edgesFocusable={false} panOnDrag panOnScroll zoomOnScroll={false} minZoom={.1} maxZoom={4} deleteKeyCode={null} onMoveEnd={(_,viewport)=>onViewport(viewport)}>
<InitialPreviewViewport onViewport={onViewport} nodeCount={projection.nodes.length}/>
<Background variant={BackgroundVariant.Dots} gap={24} size={1} />
</ReactFlow>
</CanvasRendererCommandsContext.Provider>
<p role="status" style={{position:'absolute',left:'50%',transform:'translateX(-50%)',bottom:16,pointerEvents:'none'}}>正在加载… {preview.receivedElements}/{preview.totalElements},加载期间可浏览</p>
</div>;
}
+5 -4
View File
@@ -2,15 +2,16 @@
import { CanvasImage } from "./canvas-image";
import { managedImageSource } from "@/lib/canvas-v2";
import { useContext, useRef } from "react";
import { useRef } from "react";
import { Handle, Position, useViewport, type NodeProps } from "@xyflow/react";
import { FileText, Image as ImageIcon, Type } from "lucide-react";
import { elementCapabilities } from "@/lib/canvas-elements";
import { nodeDirections, type CanvasNode } from "@/lib/canvas";
import { NodeTitle } from "./node-title";
import { CanvasTextEditor } from "./canvas-text-editor";
import {useScopedNodeEditing} from "./node-editing";
import styles from "./canvas.module.css";
import { TransformableNode, NodeEditing } from "./transformable-node";
import { TransformableNode } from "./transformable-node";
export { NodeEditing } from "./transformable-node";
const positions = { top: Position.Top, right: Position.Right, bottom: Position.Bottom, left: Position.Left };
@@ -18,14 +19,14 @@ const labels = { top: "上方", right: "右侧", bottom: "下方", left: "左侧
export function MaterialNode(props: NodeProps<CanvasNode>) {
const { id, data, selected } = props;
const editing = useContext(NodeEditing);
const editing = useScopedNodeEditing(id,data.__headlessTarget);
const { zoom } = useViewport();
const preview = (props.width || 260) * zoom * (typeof window === "undefined" ? 1 : window.devicePixelRatio || 1) <= 1024;
const pointerStart = useRef({ x: 0, y: 0 });
const Icon = data.kind === "image" ? ImageIcon : data.kind === "script" ? FileText : Type;
return (
<TransformableNode {...props} className={`${styles.node} ${selected ? styles.selectedNode : ""}`}>
<header className={styles.nodeHeading}><Icon size={13} /><NodeTitle id={id} title={data.title} selected={selected} /></header>
<header className={styles.nodeHeading}><Icon size={13} /><NodeTitle id={id} renderedTarget={data.__headlessTarget} title={data.title} selected={selected} /></header>
<Handle type="target" position={Position.Left} className={styles.legacyHandle} isConnectable={false} />
{data.kind === "image" ? <CanvasImage id={id} className={styles.image} src={managedImageSource(data.src, preview)} title={data.title} /> : (
<CanvasTextEditor id={id} data={data} selected={selected} />
+45
View File
@@ -0,0 +1,45 @@
"use client";
import {createContext,useContext,useMemo} from 'react';
import type {XYPosition} from '@xyflow/react';
import type {NodeDirection} from '@/lib/canvas';
import type {TextStyle} from '@/lib/canvas-elements';
import type {NodeTransform} from '@/lib/canvas-geometry';
import type {EditingLease} from './editing-lease';
import type {RenderedTarget} from '@/lib/canvas-bindings/compat-types';
import {CanvasOverlays} from './canvas-overlays';
const defaultEditing = {
beginTransform: undefined as undefined | ((id: string) => boolean | void | EditingLease<NodeTransform>), finishTransform: undefined as undefined | (() => void), cancelTransform: undefined as undefined | (() => void),
endEditing: undefined as undefined | ((id: string) => void),
workspace: "", schema: 1 as 1 | 2,
begin: (_id?: string) => {}, select: (_id: string) => {}, edit: (_id: string, _text: string) => {},
branch: (_id: string, _direction: NodeDirection, _point: XYPosition) => {},
transform: (_id: string, _transform: NodeTransform) => {},
style: (_id: string, _style: TextStyle, _fontSize?: number) => {},
rename: (_id: string, _title: string) => {},
frame: (_id: string, _patch: { title?: string; labelMode?: import("@/lib/canvas-elements").FrameLabelMode; presentation?: import("@/lib/canvas-v2").FramePresentation }) => {},
rotating: null as string | null, rotate: (_id: string | null) => {},
resizing: null as string | null, resize: (_id: string | null) => {},
};
type GestureHooks = 'beginTransform' | 'finishTransform' | 'cancelTransform' | 'endEditing';
export const NodeEditing = createContext<Omit<typeof defaultEditing, GestureHooks> & Partial<Pick<typeof defaultEditing, GestureHooks>>>(defaultEditing);
/** Bind legacy renderer actions to the lifetime and placement that rendered them. */
export function useScopedNodeEditing(id:string,rendered?:RenderedTarget){
const editing=useContext(NodeEditing),scope=useContext(CanvasOverlays),incarnation=rendered?.id===id?rendered.incarnation:undefined,owner=rendered?.ownerCanvasId,canvas=rendered?.canvasId;
return useMemo(()=>{if(!scope)return editing;
const current=(candidate:string|null|undefined=id)=>{const durability=scope.engine.getDurability(),view=scope.engine.sessions.getSnapshot().views.get(scope.index.viewId),record=scope.engine.getSnapshot().document.elements.get(id);return !durability.closing&&!durability.closed&&!!view&&view.canvasId===canvas&&incarnation!==undefined&&record?.incarnation===incarnation&&record.ownerCanvasId===owner&&(candidate===undefined||candidate===null||candidate===id);};
const ownGesture=()=>scope.engine.sessions.getSnapshot().gesture?.viewId===scope.index.viewId&&scope.engine.sessions.getSnapshot().gesture?.targets.some(target=>target.id===id&&target.incarnation===incarnation);
const scoped:typeof editing={...editing,
begin:candidate=>{if(current(candidate))editing.begin(candidate??id);},select:candidate=>{if(current(candidate))editing.select(candidate);},
edit:(candidate,text)=>{if(current(candidate))editing.edit(candidate,text);},rename:(candidate,title)=>{if(current(candidate))editing.rename(candidate,title);},
style:(candidate,style,size)=>{if(current(candidate))editing.style(candidate,style,size);},frame:(candidate,patch)=>{if(current(candidate))editing.frame(candidate,patch);},
transform:(candidate,change)=>{if(current(candidate))editing.transform(candidate,change);},branch:(candidate,direction,point)=>{if(current(candidate))editing.branch(candidate,direction,point);},
rotate:candidate=>{if(current(candidate)&&(candidate!==null||scope.engine.sessions.getSnapshot().views.get(scope.index.viewId)?.modes.get(id)==='rotate'))editing.rotate(candidate);},resize:candidate=>{if(current(candidate)&&(candidate!==null||scope.engine.sessions.getSnapshot().views.get(scope.index.viewId)?.modes.get(id)==='resize'))editing.resize(candidate);},
};
if(editing.beginTransform)scoped.beginTransform=candidate=>current(candidate)?editing.beginTransform!(candidate):false;
if(editing.endEditing)scoped.endEditing=candidate=>{if(current(candidate))editing.endEditing!(candidate);};
if(editing.finishTransform)scoped.finishTransform=()=>{if(current()&&ownGesture())editing.finishTransform!();};
if(editing.cancelTransform)scoped.cancelTransform=()=>{if(current()&&ownGesture())editing.cancelTransform!();};
return scoped;
},[editing,scope,id,incarnation,owner,canvas]);
}
+19
View File
@@ -0,0 +1,19 @@
"use client";
import {useCallback,useEffect,useRef} from 'react';
import {useStoreApi,useUpdateNodeInternals} from '@xyflow/react';
import {createNodeInternalsBatch} from '@/lib/canvas-bindings/node-internals';
// useStoreApi wraps the API per hook. Its shared getState function identifies the
// actual store, separating canvases even when they render the same project.
const batches=new WeakMap<object,ReturnType<typeof createNodeInternalsBatch>>();
export function useNodeInternalsUpdate(id:string){
const scope=useStoreApi().getState,update=useUpdateNodeInternals();
const lease=useRef<ReturnType<ReturnType<typeof createNodeInternalsBatch>['acquire']>|undefined>(undefined);
useEffect(()=>{
let batch=batches.get(scope);if(!batch){batch=createNodeInternalsBatch(update);batches.set(scope,batch);}
const owner=batch.acquire(id);lease.current=owner;
return()=>{owner.dispose();if(lease.current===owner)lease.current=undefined;};
},[scope,update,id]);
return useCallback(()=>lease.current?.invalidate(),[]);
}
+17 -9
View File
@@ -1,24 +1,32 @@
"use client";
import { useContext } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { NodeToolbar, Position, type NodeProps } from "@xyflow/react";
import { AlignCenter, AlignLeft, AlignRight, Bold, Italic, RotateCw, Underline } from "lucide-react";
import type { CanvasNode } from "@/lib/canvas";
import type { TextStyle } from "@/lib/canvas-elements";
import { NodeEditing, ResizeToggle } from "./transformable-node";
import { ResizeToggle } from "./transformable-node";
import {useCanvasOverlay} from "./canvas-overlays";
import {useScopedNodeEditing} from "./node-editing";
import {canvasKeyContext} from "@/lib/canvas-bindings/input-router";
import styles from "./canvas.module.css";
export function NodeStyleToolbar({ id, data, selected }: NodeProps<CanvasNode>) {
const overlay=useCanvasOverlay(id,"node-style");
const reduce = useReducedMotion();
const editing = useContext(NodeEditing);
const editing = useScopedNodeEditing(id,data.__headlessTarget);
const style = data.textStyle || {};
const text = data.kind === "text" || data.kind === "script" || data.kind === "plain-text";
const patch = (value: TextStyle, fontSize?: number) => editing.style(id, value, fontSize);
return <NodeToolbar style={{ zIndex: 100, pointerEvents: "auto" }} isVisible={true} position={Position.Top} offset={34}>
<AnimatePresence>{selected && <motion.div initial={{ opacity: 0, y: reduce ? 0 : 5 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: reduce ? 0 : 5 }} transition={{ duration: reduce ? 0 : 0.14 }} className={`${styles.styleToolbar} nodrag nopan nowheel`} data-node-style={id} role="toolbar" aria-label="元素样式"
return <AnimatePresence>{selected && <NodeToolbar style={{ zIndex: 100, pointerEvents: "auto" }} isVisible={true} position={Position.Top} offset={34}>
<motion.div ref={overlay} initial={{ opacity: 0, y: reduce ? 0 : 5 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: reduce ? 0 : 5 }} transition={{ duration: reduce ? 0 : 0.14 }} className={`${styles.styleToolbar} nodrag nopan nowheel`} data-node-style={id} role="toolbar" aria-label="元素样式"
onPointerDown={(event) => event.stopPropagation()} onClick={(event) => event.stopPropagation()} onDoubleClick={(event) => event.stopPropagation()}
onKeyDown={(event) => { if (event.key !== "Escape") event.stopPropagation(); }}>
onKeyDown={(event) => {
if(event.key!=="Escape"){event.stopPropagation();return;}
if(event.defaultPrevented||event.nativeEvent.isComposing||canvasKeyContext(event.nativeEvent).textEditing)return;
// The registered overlay owns its mode dismissal; global shortcuts stay out.
editing.rotate(null);editing.resize(null);event.preventDefault();event.stopPropagation();
}}>
{!(["image", "plain-image"].includes(data.kind)) && <label title="文字或图标颜色" className={styles.colorPicker}><span style={{ background: style.color || "#37352f" }} /><input aria-label="文字或图标颜色" type="color" value={style.color || "#37352f"} onChange={(event) => patch({ color: event.target.value })} /></label>}
{text && <>
<select aria-label="字体" value={style.fontFamily || "sans"} onChange={(event) => patch({ fontFamily: event.target.value as TextStyle["fontFamily"] })}><option value="sans">正文</option><option value="serif">衬线</option><option value="mono">等宽</option></select>
@@ -29,8 +37,8 @@ export function NodeStyleToolbar({ id, data, selected }: NodeProps<CanvasNode>)
</>}
{!(["image", "plain-image"].includes(data.kind)) && <label className={styles.colorPicker} title="背景色"><span style={{ background: style.background && style.background !== "transparent" ? style.background : "#ffffff" }} /><input aria-label="背景色" type="color" value={style.background && style.background !== "transparent" ? style.background : "#ffffff"} onChange={(event) => patch({ background: event.target.value })} /></label>}
{!(["image", "plain-image"].includes(data.kind)) && <button title="清除背景" aria-label="清除背景" onClick={() => patch({ background: "transparent" })}>∅</button>}
{data.kind !== "icon" && <ResizeToggle id={id} />}
{data.kind !== "icon" && <ResizeToggle id={id} renderedTarget={data.__headlessTarget} />}
<button data-rotate-toggle={id} title="旋转模式" aria-label="旋转模式" aria-pressed={editing.rotating === id} onClick={() => editing.rotate(editing.rotating === id ? null : id)}><RotateCw size={16} /></button>
</motion.div>}</AnimatePresence>
</NodeToolbar>;
</motion.div>
</NodeToolbar>}</AnimatePresence>;
}
+4 -4
View File
@@ -1,9 +1,9 @@
"use client";
import { useContext, useEffect, useRef, useState } from "react";
import { NodeEditing } from "./transformable-node";
import { useEffect, useRef, useState } from "react";
import {useScopedNodeEditing} from "./node-editing";
import styles from "./canvas.module.css";
export function NodeTitle({ id, title, selected }: { id: string; title: string; selected: boolean }) {
const actions = useContext(NodeEditing);
export function NodeTitle({ id, title, selected,renderedTarget }: { id: string; title: string; selected: boolean;renderedTarget?:import("@/lib/canvas-bindings/compat-types").RenderedTarget }) {
const actions = useScopedNodeEditing(id,renderedTarget);
const [editing, setEditing] = useState(false), [value, setValue] = useState(title);
const input = useRef<HTMLInputElement>(null), cancelled = useRef(false);
useEffect(() => { if (!editing) setValue(title); }, [title, editing]);
@@ -0,0 +1,16 @@
/** Adapter-only flags consumed by the app's pinned React Flow compiler extension.
* They never enter the document, history, persistence or renderer business data. */
export function createRetainedProjection(){
const tagged=new WeakMap<object,object>();
let previous:{nodes:readonly object[];edges:readonly object[];ids:Set<string>;result:{nodes:object[];edges:object[]}}|undefined;
return <N extends {id:string},E extends {id:string}>(nodes:N[],edges:E[],ids:ReadonlySet<string>):{nodes:N[];edges:E[]}=>{
if(!ids.size){previous=undefined;return{nodes,edges};}
if(previous?.nodes===nodes&&previous.edges===edges&&previous.ids.size===ids.size&&[...ids].every(id=>previous!.ids.has(id)))return previous.result as {nodes:N[];edges:E[]};
const retain=<T extends {id:string}>(row:T):T=>{
if(!ids.has(row.id))return row;
let value=tagged.get(row);if(!value){value={...row,__endoleapRetain:true};tagged.set(row,value);}return value as T;
};
const result={nodes:nodes.map(retain),edges:edges.map(retain)};
previous={nodes,edges,ids:new Set(ids),result};return result;
};
}
+50 -39
View File
@@ -1,82 +1,92 @@
"use client";
import { createContext, useContext, useEffect, useRef, type PointerEvent, type ReactNode } from "react";
import { useReactFlow, useUpdateNodeInternals, useViewport, type NodeProps, type XYPosition } from "@xyflow/react";
import { useEffect, useRef, type PointerEvent, type ReactNode } from "react";
import { useReactFlow, useViewport, type NodeProps, type XYPosition } from "@xyflow/react";
import { Grip, Maximize2, RotateCw } from "lucide-react";
import { elementCapabilities, type TextStyle } from "@/lib/canvas-elements";
import type { CanvasNode, NodeDirection } from "@/lib/canvas";
import { elementCapabilities } from "@/lib/canvas-elements";
import type { CanvasNode } from "@/lib/canvas";
import { resizeFromPointer, rotationFromPointer, type NodeTransform, type ResizeDirection } from "@/lib/canvas-geometry";
import type { EditingLease } from "./editing-lease";
import { NodeStyleToolbar } from "./node-style-toolbar";
import { MembershipBadge } from "./frame-feedback";
import { ContentHandles } from "./content-handles";
import {ownsPointerCapture,acquirePointerCapture,releasePointerCapture} from "@/lib/canvas-bindings/pointer-capture";
import {canvasKeyContext,routeCanvasKey} from "@/lib/canvas-bindings/input-router";
import styles from "./canvas.module.css";
import {useNodeInternalsUpdate} from "./node-internals";
export const NodeEditing = createContext({
workspace: "", schema: 1 as 1 | 2,
begin: (_id?: string) => {}, select: (_id: string) => {}, edit: (_id: string, _text: string) => {},
branch: (_id: string, _direction: NodeDirection, _point: XYPosition) => {},
transform: (_id: string, _transform: NodeTransform) => {},
style: (_id: string, _style: TextStyle, _fontSize?: number) => {},
rename: (_id: string, _title: string) => {},
frame: (_id: string, _patch: { title?: string; labelMode?: import("@/lib/canvas-elements").FrameLabelMode; presentation?: import("@/lib/canvas-v2").FramePresentation }) => {},
rotating: null as string | null, rotate: (_id: string | null) => {},
resizing: null as string | null, resize: (_id: string | null) => {},
});
type Gesture = { node: CanvasNode; start: XYPosition; direction: ResizeDirection | "rotate"; target: HTMLElement; pointerId: number };
export {NodeEditing} from "./node-editing";
import {useScopedNodeEditing} from "./node-editing";
type Gesture = { lease?: EditingLease<NodeTransform>; node: CanvasNode; start: XYPosition; direction: ResizeDirection | "rotate"; capture: HTMLElement; pointerId: number };
export function TransformableNode(props: NodeProps<CanvasNode> & { children: ReactNode; className?: string; hideStyleToolbar?: boolean }) {
const { id, data, selected, width, height, children, className = "" } = props;
const editing = useContext(NodeEditing);
const editing = useScopedNodeEditing(id,data.__headlessTarget);
const flow = useReactFlow<CanvasNode>();
const update = useUpdateNodeInternals();
const update = useNodeInternalsUpdate(id);
const { zoom } = useViewport();
const frame = useRef<HTMLDivElement>(null);
const gesture = useRef<Gesture | null>(null);
const interrupt=useRef(()=>{});interrupt.current=()=>cancelGesture();
useEffect(()=>{const blur=()=>interrupt.current();window.addEventListener('blur',blur);return()=>{window.removeEventListener('blur',blur);interrupt.current();};},[]);
const rotation = data.rotation || 0;
const plain = data.kind === "plain-text" || data.kind === "icon" || data.kind === "plain-image";
const capabilities = elementCapabilities(data.kind);
const resizing = capabilities.resizable && editing.resizing === id && selected;
const rotating = capabilities.rotatable && editing.rotating === id && selected;
useEffect(() => { update(id); }, [id, rotation, width, height, selected, update]);
useEffect(() => { update(); }, [id, rotation, width, height, selected, update]);
useEffect(() => {
if (!rotating && !resizing) return;
const outside = (event: globalThis.PointerEvent) => {
if (!frame.current?.contains(event.target as Node) && !(event.target as Element).closest?.('[data-rotate-toggle="' + id + '"], [data-resize-toggle="' + id + '"]')) { editing.rotate(null); editing.resize(null); }
};
const escape = (event: KeyboardEvent) => { if (event.key === "Escape") { cancelGesture(); editing.rotate(null); editing.resize(null); } };
const escape = (event: KeyboardEvent) => {
if(event.key!=="Escape")return;
const claim=routeCanvasKey({key:event.key,composing:event.isComposing,defaultPrevented:event.defaultPrevented,...canvasKeyContext(event)},{tool:()=>{cancelGesture();editing.rotate(null);editing.resize(null);return true;}});
if(claim==='tool'){event.preventDefault();event.stopPropagation();}
};
window.addEventListener("pointerdown", outside, true);
window.addEventListener("keydown", escape);
return () => { window.removeEventListener("pointerdown", outside, true); window.removeEventListener("keydown", escape); };
}, [rotating, resizing, id, editing]);
function start(event: PointerEvent<HTMLElement>, direction: Gesture["direction"]) {
if (event.button !== 0) return;
event.preventDefault(); event.stopPropagation();
if (event.button !== 0||event.defaultPrevented) return;
if(direction==='rotate'&&event.target instanceof Element&&event.target.closest('input,textarea,[contenteditable]:not([contenteditable="false"]),[role="textbox"]'))return;
if(gesture.current){if(gesture.current.pointerId!==event.pointerId)cancelGesture();return;}
if(event.isPrimary===false)return;
const node = flow.getNode(id);
if (!node) return;
editing.begin(id);
const lease = editing.beginTransform?.(id); if (lease === false) return;
if (!editing.beginTransform) editing.begin(id);
if (!node.selected) editing.select(id);
gesture.current = { node, direction, target: event.currentTarget, pointerId: event.pointerId, start: flow.screenToFlowPosition({ x: event.clientX, y: event.clientY }) };
event.currentTarget.setPointerCapture(event.pointerId);
gesture.current = { lease: lease && typeof lease === 'object' ? lease : undefined, node, direction, capture: event.currentTarget, pointerId: event.pointerId, start: flow.screenToFlowPosition({ x: event.clientX, y: event.clientY }) };
if(!acquirePointerCapture(event.currentTarget,event.pointerId)){cancelGesture();return;}
event.preventDefault();event.stopPropagation();
}
function move(event: PointerEvent<HTMLElement>) {
if (!gesture.current) return;
event.stopPropagation();
if(!gesture.current)return;event.stopPropagation();
if (!ownsPointerCapture(gesture.current,event)) return;
const current = flow.screenToFlowPosition({ x: event.clientX, y: event.clientY });
const { node, start, direction } = gesture.current;
editing.transform(id, direction === "rotate"
const { node, start, direction } = gesture.current!;
const change = direction === "rotate"
? { rotation: rotationFromPointer(node, start, current, event.shiftKey) }
: resizeFromPointer(node, direction, { x: current.x - start.x, y: current.y - start.y }, resizing ? event.shiftKey : undefined));
: resizeFromPointer(node, direction, { x: current.x - start.x, y: current.y - start.y }, resizing ? event.shiftKey : undefined);
if (gesture.current!.lease) gesture.current!.lease!.preview(change); else editing.transform(id, change);
}
function cancelGesture() {
function cancelGesture(event?:{pointerId:number;type?:string;target?:unknown;stopPropagation?():void}) {
if(event&&gesture.current)event.stopPropagation?.();
if(event&&!ownsPointerCapture(gesture.current,event))return;
const current = gesture.current; if (!current) return;
gesture.current = null;
editing.transform(id, current.direction === "rotate" ? { rotation: current.node.data.rotation || 0 } : { position: current.node.position, width: current.node.width, height: current.node.height });
if (current.target.hasPointerCapture(current.pointerId)) current.target.releasePointerCapture(current.pointerId);
if (current.lease) current.lease.cancel(); else if (editing.cancelTransform) editing.cancelTransform(); else editing.transform(current.node.id, current.direction === "rotate" ? { rotation: current.node.data.rotation || 0 } : { position: current.node.position, width: current.node.width, height: current.node.height });
releasePointerCapture(current.capture,current.pointerId);
}
function finish(event: PointerEvent<HTMLElement>) {
event.stopPropagation(); gesture.current = null;
if (event.currentTarget.hasPointerCapture(event.pointerId)) event.currentTarget.releasePointerCapture(event.pointerId);
if(!gesture.current)return;event.stopPropagation();
if (!ownsPointerCapture(gesture.current,event)) return; const current = gesture.current!; move(event); gesture.current = null;
if (current.lease) current.lease.commit(); else editing.finishTransform?.();
releasePointerCapture(current.capture,current.pointerId);
}
return <>
{!props.hideStyleToolbar && <NodeStyleToolbar {...props} />}
@@ -101,8 +111,9 @@ export function TransformableNode(props: NodeProps<CanvasNode> & { children: Rea
onKeyDown={(event) => {
const delta = { ArrowLeft: [-1, 0], ArrowRight: [1, 0], ArrowUp: [0, -1], ArrowDown: [0, 1] }[event.key]; if (!delta) return;
event.preventDefault(); event.stopPropagation(); const node = flow.getNode(id); if (!node) return;
editing.begin(id); const scale = (data.viewScale || 1) * (event.ctrlKey || event.metaKey ? 10 : 1);
editing.transform(id, resizeFromPointer(node, direction, { x: delta[0] * scale, y: delta[1] * scale }, event.shiftKey));
const lease = editing.beginTransform?.(id); if (lease === false) return; if (!editing.beginTransform) editing.begin(id); const scale = (data.viewScale || 1) * (event.ctrlKey || event.metaKey ? 10 : 1);
const change = resizeFromPointer(node, direction, { x: delta[0] * scale, y: delta[1] * scale }, event.shiftKey);
if (lease && typeof lease === 'object') { lease.preview(change); lease.commit(); } else { editing.transform(id, change); editing.finishTransform?.(); }
}} />;
})}
{resizing && <span className={styles.rotationHint}>拖动八向手柄 · Shift 固定比例 · Esc 退出</span>}
@@ -111,7 +122,7 @@ export function TransformableNode(props: NodeProps<CanvasNode> & { children: Rea
</>;
}
export function ResizeToggle({ id }: { id: string }) {
const editing = useContext(NodeEditing);
export function ResizeToggle({ id,renderedTarget }: { id: string;renderedTarget?:import("@/lib/canvas-bindings/compat-types").RenderedTarget }) {
const editing = useScopedNodeEditing(id,renderedTarget);
return <button type="button" data-resize-toggle={id} aria-label="八向拉伸模式" title="八向拉伸 · Shift 固定比例" aria-pressed={editing.resizing === id} onClick={() => editing.resize(editing.resizing === id ? null : id)}><Maximize2 size={16} /></button>;
}
@@ -0,0 +1,21 @@
"use client";
import { BaseEdge, Handle, Position, getBezierPath, type EdgeProps, type NodeProps } from "@xyflow/react";
import type {ReactFlowProjectionNode as ProjectionNode} from "@/lib/canvas-bindings/react-flow";
import type { CanvasEdgeModel } from "@/lib/canvas-v2";
import styles from "./canvas.module.css";
const positions={top:Position.Top,right:Position.Right,bottom:Position.Bottom,left:Position.Left};
/** Render only the known envelope and derived endpoints, never interpret opaque payload fields. */
export function UnsupportedNode({data,selected}:NodeProps<ProjectionNode>){
const fallback=data.__headlessFallback;
return <div className={`${styles.node} ${selected?styles.selectedNode:""}`} style={{width:"100%",height:"100%",padding:14,transform:`rotate(${data.rotation||0}deg)`}} role="note" aria-label="暂不支持编辑的元素">
<span>{data.title}</span><small style={{display:"block"}}>此元素需要兼容的组件版本才能编辑</small>
<small title={fallback?`${fallback.componentId} · v${fallback.dataVersion}`:undefined}>{fallback?.reason==='renderer-unavailable'?'当前视图暂无此类型的编辑器':'原始内容已保留'}</small>
{fallback?.handles.map(handle=><Handle key={handle.id} id={handle.id} type="source" position={positions[handle.side]} className={styles.centerHandle} isConnectable={false} isConnectableStart={false} isConnectableEnd={false} aria-hidden />)}
</div>;
}
export function UnsupportedEdge(props:EdgeProps<CanvasEdgeModel>){
const [path]=getBezierPath(props);
return <g role="img" aria-label="暂不支持编辑的连线"><title>原始连线内容已保留</title><BaseEdge id={props.id} path={path} style={{stroke:'#aa968b',strokeWidth:1.5}} /></g>;
}
+57 -14
View File
@@ -1,9 +1,9 @@
"use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { EdgeChange, NodeChange } from "@xyflow/react";
import { api, ApiError } from "@/lib/api";
import { api as requestAPI, ApiError } from "@/lib/api";
import { canvasReducer, decodeCanvas, type CanvasAction, type CanvasDocument, type CanvasHistory, type CanvasNode } from "@/lib/canvas";
import { CanvasSync, canvasFingerprint, canvasPayload, canvasPath, decodeSnapshot, readLocalRecovery, type CanvasSnapshot } from "@/lib/canvas-sync";
import { CanvasSync, canvasFingerprint, canvasPayload, canvasPath, decodeSnapshot, readLocalRecovery, type CanvasSnapshot, type CanvasRequest } from "@/lib/canvas-sync";
import { CANVAS_ACCEPT, type EdgeData } from "@/lib/canvas-v2";
import { subgraphMovePath, subgraphMoveParticipants } from "@/lib/canvas-subgraphs";
import { mergeCanvasDocuments, projectCanvasTree, transformEdgeData, referenceAfterMoves } from "@/lib/canvas-tree";
@@ -16,7 +16,19 @@ type Entry = LocalHistory | TransactionHistory;
type MemberJob = { source: string; target: string; ids: string[]; parent: string; frame: string; participants: string[]; error?: string; refresh?: boolean; run?: () => Promise<void>; resolve: () => void };
const history = (document: CanvasDocument): CanvasHistory => ({ past: [], present: document, future: [] });
export function useCanvasTree(root: string, workspace: string, initial: CanvasDocument, snapshot?: CanvasSnapshot, paused = false, parentBoardId?: string) {
export function useCanvasTree(root: string, workspace: string, initial: CanvasDocument, snapshot?: CanvasSnapshot, paused = false, parentBoardId?: string, migration?: {accountId:string;isCurrentIdentity():boolean}) {
const migrationGate=useRef(!!migration);
const migrationFlush=useRef(false);
const [migrationHeld,setMigrationState]=useState(!!migration);
const migrationBlocked=()=>migrationGate.current||!!migration&&!migration.isCurrentIdentity();
function setMigrationHeld(value:boolean){migrationGate.current=value;setMigrationState(value);}
const api:CanvasRequest=async <T,>(path:string,options?:RequestInit)=>{
if(migration&&!migration.isCurrentIdentity())throw new Error('identity_changed');
if(migrationBlocked()&&options?.method&&options.method!=='GET'&&!(migrationFlush.current&&options.method==='PATCH'&&path.endsWith('/canvas')))throw new Error('canvas_busy');
const headers=new Headers(options?.headers);if(migration)headers.set('X-Expected-Account',migration.accountId);
const value=await requestAPI<T>(path,{...options,headers:Object.fromEntries(headers)});
if(migration&&!migration.isCurrentIdentity())throw new Error('identity_changed');return value;
};
const [documents, setDocuments] = useState(new Map([[root, initial]]));
const current = useRef(documents); current.current = documents;
const [status, setStatus] = useState("saved");
@@ -45,6 +57,7 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo
if (local) { const next = new Map(pendingRecovery.current).set(boardId, local); pendingRecovery.current = next; setRecoveries(next); }
}
function resolveRecovery(boardId: string, restore: boolean) {
if(migrationBlocked())return;
const draft = pendingRecovery.current.get(boardId);
if (restore && draft) update(boardId, { type: "replace", document: draft });
const next = new Map(pendingRecovery.current); next.delete(boardId); pendingRecovery.current = next; setRecoveries(next);
@@ -68,7 +81,7 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo
if (snapshot && !writers.current.has(root)) install(root, snapshot);
useEffect(() => { mounted.current = true; return () => { mounted.current = false; }; }, []);
const update = (boardId: string, action: CanvasAction) => {
if (locked.current || retryOperation.current) return;
if (migrationBlocked() || locked.current || retryOperation.current) return;
const before = current.current.get(boardId); if (!before) return;
const result = canvasReducer(history(before), action);
const protectedIDs = protectedNodes(boardId);
@@ -84,7 +97,7 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo
const dispatch = (action: CanvasAction, boardId = root) => update(boardId, action);
const nodeAction = (id: string, action: (id: string) => CanvasAction) => { const ref = view.current.nodeRefs.get(id); if (ref) update(ref.boardId, action(ref.id)); };
const nodesChanged = (changes: NodeChange<CanvasNode>[]) => {
if (locked.current || retryOperation.current) return;
if (migrationBlocked() || locked.current || retryOperation.current) return;
const groups = new Map<string, NodeChange<CanvasNode>[]>();
const moves = new Map(changes.flatMap((change) => change.type === "position" && change.position ? [[change.id, change.position] as const] : []));
for (const change of changes) {
@@ -180,8 +193,9 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [documents, retryLoad]);
useEffect(() => {
if (paused) return;
if (paused || migrationBlocked()) return;
const backup = setTimeout(() => {
if(migrationBlocked())return;
for (const [boardId, document] of current.current) try {
if (pendingRecovery.current.has(boardId)) continue;
const writer = writers.current.get(boardId);
@@ -189,14 +203,14 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo
} catch { setError("local_backup_failed"); }
}, 250);
const save = setTimeout(() => {
if (locked.current || retryOperation.current || pause.current || members.current.length || dragging.current) return;
if (migrationBlocked() || locked.current || retryOperation.current || pause.current || members.current.length || dragging.current) return;
for (const [boardId, document] of current.current) if (!pendingRecovery.current.has(boardId)) void writers.current.get(boardId)?.enqueue(document);
}, 800);
return () => { clearTimeout(backup); clearTimeout(save); };
}, [documents, paused, workspace, memberJobs]);
}, [documents, paused, workspace, memberJobs, migrationHeld]);
useEffect(() => {
const backup = () => {
if (pause.current) return;
if (pause.current || migrationBlocked()) return;
for (const [boardId, document] of current.current) {
if (pendingRecovery.current.has(boardId)) continue;
try { const writer = writers.current.get(boardId); localStorage.setItem(`endoleap_canvas_v1:${workspace}:${boardId}:recovery`, JSON.stringify({ document: canvasPayload(document), baseVersion: writer?.version || 1, dirty: writer ? writer.needsSave(document) : false })); } catch { /* Existing draft is retained if storage is full. */ }
@@ -214,8 +228,35 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo
await writer.enqueue(saving); await writer.settled(); if (writer.error) throw new Error(writer.error);
}
}
async function prepareMigration(projectRoot:string){
if(!migrationGate.current)throw new Error('migration_gate_required');
if(locked.current||retryOperation.current||members.current.length||dragging.current||loading.current.size)throw new Error('canvas_busy');
if(pause.current||pendingRecovery.current.size)throw new Error('draft_recovery_required');
await Promise.all([...writers.current.values()].map(writer=>writer.settled()));
await ensure(projectRoot,true);
if(!mounted.current||migration&&!migration.isCurrentIdentity())throw new Error('identity_changed');
if(pendingRecovery.current.size)throw new Error('draft_recovery_required');
if([...current.current.values()].some(document=>document.nodes.some(node=>node.pendingUpload)))throw new Error('migration_pending_uploads');
for(const[boardId,document]of current.current){
const writer=writers.current.get(boardId);
const local=readLocalRecovery(localStorage,`endoleap_canvas_v1:${workspace}:${boardId}`,writer?{version:writer.version,document:writer.document}:undefined);
if(local&&canvasFingerprint(local,false)!==canvasFingerprint(document,false)){
const next=new Map(pendingRecovery.current).set(boardId,local);pendingRecovery.current=next;setRecoveries(next);throw new Error('draft_recovery_required');
}
// Preserve the current view even if it closes while PATCH is in flight.
localStorage.setItem(`endoleap_canvas_v1:${workspace}:${boardId}:recovery`,JSON.stringify({document:canvasPayload(document),baseVersion:writer?.version||1,dirty:writer?writer.needsSave(document):false}));
}
migrationFlush.current=true;
try{await flush();}finally{migrationFlush.current=false;}
if(!mounted.current||migration&&!migration.isCurrentIdentity())throw new Error('identity_changed');
for(const[boardId,document]of current.current){
const writer=writers.current.get(boardId);
if(writer?.error||writer?.needsSave(document))throw new Error(writer.error||'canvas_busy');
localStorage.setItem(`endoleap_canvas_v1:${workspace}:${boardId}:recovery`,JSON.stringify({document:canvasPayload(document),baseVersion:writer?.version||1,dirty:false}));
}
}
async function exclusive<T>(operation: () => Promise<T>) {
if (locked.current || pause.current || members.current.length) throw new Error("canvas_busy");
if (migrationBlocked() || locked.current || pause.current || members.current.length) throw new Error("canvas_busy");
locked.current = true; setBusy(true);
try { return await operation(); } catch (error) { setError(error instanceof Error ? error.message : "request_failed"); throw error; }
finally { locked.current = false; if (mounted.current) setBusy(false); }
@@ -280,7 +321,7 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo
// Ownership changes pause autosaves, not unrelated local editing. Server
// acknowledgements merge against the persisted base, preserving intervening edits.
async function runMembers() {
if (memberRunning.current || !members.current.length) return;
if (migrationBlocked() || memberRunning.current || !members.current.length) return;
const job = members.current[0]; memberRunning.current = true; job.error = undefined; showMembers();
try {
if (!job.run) {
@@ -345,12 +386,13 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo
}
}
function moveMembers(parent: string, frame: string, source: string, target: string, ids: string[], participants: string[]) {
if (locked.current || retryOperation.current || pause.current || members.current.some((job) => job.ids.some((id) => ids.includes(id)) && [job.source, job.target].includes(source))) return Promise.reject(new Error("canvas_busy"));
if (migrationBlocked() || locked.current || retryOperation.current || pause.current || members.current.some((job) => job.ids.some((id) => ids.includes(id)) && [job.source, job.target].includes(source))) return Promise.reject(new Error("canvas_busy"));
return new Promise<void>((resolve) => {
members.current.push({ parent, frame, source, target, ids, participants, resolve }); showMembers(); void runMembers();
});
}
function resolveImageUpload(id: string, asset?: { url: string; id: string }, size?: {width:number;height:number}) {
if(migrationBlocked())return;
const resolve = (document: CanvasDocument): CanvasDocument => ({ ...document, nodes: document.nodes.map((node) => {
if (node.pendingUpload !== id) return node;
const fitted=node.data.kind==="plain-image"&&node.width===320&&node.height===240?size:undefined;
@@ -362,6 +404,7 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo
publish(new Map([...current.current].map(([board,doc])=>[board,resolve(doc)])));
}
function movePendingImage(source: string, target: string, id: string) {
if(migrationBlocked())throw new Error("canvas_busy");
const from = current.current.get(source)!, to = current.current.get(target)!;
const node = from.nodes.find((node)=>node.id===id); if (!node?.pendingUpload || to.nodes.length>=500 || to.nodes.some((item)=>item.id===id)) throw new Error("invalid_reference");
const projected=projectCanvasTree(root,current.current), a=projected.boards.get(source)!,b=projected.boards.get(target)!;
@@ -395,7 +438,7 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo
}
async function undoAction(isRedo = false) {
const stack = isRedo ? redo.current : undo.current, destination = isRedo ? undo.current : redo.current;
const original = stack.at(-1); if (!original || locked.current || retryOperation.current || members.current.length) return;
const original = stack.at(-1); if (!original || migrationBlocked() || locked.current || retryOperation.current || members.current.length) return;
const alias = (id: string) => { const seen = new Set<string>(); while (boardAliases.current.has(id) && !seen.has(id)) { seen.add(id); id = boardAliases.current.get(id)!; } return id; };
const remapDocument = (document: CanvasDocument) => ({ ...document, nodes: document.nodes.map((node) => node.data.subgraph ? { ...node, data: { ...node.data, subgraph: { boardId: alias(node.data.subgraph.boardId) } } } : node) });
const entry: Entry = isRedo ? "id" in original ? { ...original, boardId: alias(original.boardId), participants: original.participants.map(alias), payload: { ...original.payload, ...(typeof original.payload.source_board_id === "string" ? { source_board_id: alias(original.payload.source_board_id) } : {}), ...(typeof original.payload.target_board_id === "string" ? { target_board_id: alias(original.payload.target_board_id) } : {}), ...Object.fromEntries(["source", "target"].flatMap((key) => { const endpoint = original.payload[key] as { board_id?: string } | undefined; return endpoint?.board_id ? [[key, { ...endpoint, board_id: alias(endpoint.board_id) }]] : []; })) } } : { ...original, boardId: alias(original.boardId), before: remapDocument(original.before), ...(original.related?{related:new Map([...original.related].map(([id,doc])=>[alias(id),remapDocument(doc)]))}:{}) } : original;
@@ -430,5 +473,5 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo
tick((v) => v + 1);
}
const retry = async () => { setError(""); setRetryLoad((v) => v + 1); for (const writer of writers.current.values()) await writer.retry(); };
return { documents, current, views, resolveImageUpload, movePendingImage, memberJobs, protectedNodes, setDragging, moveMembers, retryMembers: () => void runMembers(), transactionBusy: !!memberJobs.length, pendingCommand, retryPending: () => retryOperation.current ? exclusive(retryOperation.current) : Promise.resolve(null), prepareApply, recoveries, resolveRecovery, ensure, exportDocuments, dispatch, nodeAction, nodesChanged, edgesChanged, patchEdge, select, flush, command, refresh, read, busy: busy || pendingCommand, error, status, retry, undo: () => undoAction(), redo: () => undoAction(true), canUndo: !memberJobs.length && !!undo.current.length, canRedo: !memberJobs.length && !!redo.current.length, writers, rootDocument: documents.get(root)!, setError };
return { migrationHeld,setMigrationHeld,prepareMigration,isMigrationHeld:migrationBlocked, documents, current, views, resolveImageUpload, movePendingImage, memberJobs, protectedNodes, setDragging, moveMembers, retryMembers: () => void runMembers(), transactionBusy: !!memberJobs.length, pendingCommand, retryPending: () => retryOperation.current ? exclusive(retryOperation.current) : Promise.resolve(null), prepareApply, recoveries, resolveRecovery, ensure, exportDocuments, dispatch, nodeAction, nodesChanged, edgesChanged, patchEdge, select, flush, command, refresh, read, busy: busy || pendingCommand || migrationHeld, error, status, retry, undo: () => undoAction(), redo: () => undoAction(true), canUndo: !memberJobs.length && !!undo.current.length, canRedo: !memberJobs.length && !!redo.current.length, writers, rootDocument: documents.get(root)!, setError };
}
+1 -1
View File
@@ -43,5 +43,5 @@ export function useImageUploads(workspace: string, commit: (draft: ImageUploadDr
void run(job);
return job;
}
return { jobs, active: jobs.some((job) => job.status === "uploading"), start, retry: (id: string) => { const job = pending.current.get(id); if (job) void run(job); }, remove, loaded: (id: string) => { if (pending.current.get(id)?.status === "uploaded") remove(id); } };
return { jobs, hasPending:()=>[...pending.current.values()].some(job=>job.status!=="uploaded"), active: jobs.some((job) => job.status === "uploading"), start, retry: (id: string) => { const job = pending.current.get(id); if (job) void run(job); }, remove, loaded: (id: string) => { if (pending.current.get(id)?.status === "uploaded") remove(id); } };
}
@@ -0,0 +1,46 @@
"use client";
import { createContext, useContext, useEffect, useRef, useState, type ReactNode } from "react";
import { defaultCanvasRenderers } from "@/components/canvas/canvas-renderers";
import { createReactRenderers, type CanvasRuntimeExtension } from "@/lib/canvas-bindings/react-flow";
import { notify } from "@/lib/notifications";
import { errorText } from "@/lib/api";
import { createRegistry, builtinComponents } from "@/lib/canvas-core/index";
import { createRuntimeHost, createWorkspaceRuntime, type RuntimeHost } from "@/lib/canvas-runtime/index";
import { createBrowserDigests, createBrowserImageDecoder, createBrowserStorage } from "@/lib/canvas-runtime/browser";
import { createFoundryAssets, createFoundryPersistence, createFoundryTransport, createFoundryRuns,createFoundryModelLoader } from "@/lib/canvas-service/index";
const Context = createContext<RuntimeHost | null>(null);
const Renderers=createContext(defaultCanvasRenderers);
export function useCanvasRenderers(){return useContext(Renderers);}
export function CanvasRuntimeHost({ children,extensions=[] }: { children: ReactNode;extensions?:readonly CanvasRuntimeExtension[] }) {
const [{host,renderers}] = useState(() => {
const registry = createRegistry([...builtinComponents(),...extensions.flatMap(extension=>extension.components??[])]),renderers=createReactRenderers([...defaultCanvasRenderers.definitions,...extensions.flatMap(extension=>extension.renderers??[])],registry);
const host=createRuntimeHost({ registry,
modelLoader(accountId,workspaceId,_authEpoch,current){return createFoundryModelLoader({workspaceId,registry,transport:createFoundryTransport({accountId,isCurrentIdentity:current})});},
workspace(accountId, workspaceId, authEpoch, isCurrentIdentity) {
return createWorkspaceRuntime({ accountId, workspaceId, authEpoch, isCurrentIdentity, registry, storage: createBrowserStorage(),digests:createBrowserDigests(),images:createBrowserImageDecoder(),
adapters(rootId, current) {
const transport = createFoundryTransport({ accountId, isCurrentIdentity: current });
return { runs: createFoundryRuns({ workspaceId, rootId, registry, transport }), persistence: createFoundryPersistence({ workspaceId, rootId, registry, transport, resolvedReceiptPolicy:'summary' }), assets: createFoundryAssets({ workspaceId, transport, project: process.env.NEXT_PUBLIC_FOUNDRY_PROJECT || "endoleap", origin: process.env.NEXT_PUBLIC_FOUNDRY_API_ORIGIN }) };
},
onError: error => notify({ id: `canvas-runtime:${accountId}:${workspaceId}`, title: "画布同步需要处理", description: errorText(error), level: "error" }),
});
},
onError: error => notify({ id: "canvas-host-close", title: "本地修改尚未保存成功", description: errorText(error), level: "error" }),
});
return {host,renderers};
});
const lifetime = useRef(0);
useEffect(() => {
const generation = ++lifetime.current;
// Strict Mode replays effects synchronously; only a real detach retires the host.
return () => { queueMicrotask(() => { if (lifetime.current === generation) void host.close().catch(error => console.error("[canvas-host]", error.message)); }); };
}, [host]);
return <Context.Provider value={host}><Renderers.Provider value={renderers}>{children}</Renderers.Provider></Context.Provider>;
}
export function useCanvasRuntimeHost() {
const host = useContext(Context);
if (!host) throw new Error("CanvasRuntimeHost is missing");
return host;
}
+52 -13
View File
@@ -1,30 +1,41 @@
"use client";
import { useEffect, useState } from "react";
import { useEffect, useRef, useState } from "react";
import type {Viewport} from '@xyflow/react';
import type {LiveModelPreview} from '@/lib/canvas-runtime/model-preview';
import { useRouter, useSearchParams } from "next/navigation";
import { api, errorText } from "@/lib/api";
import { readDemoBoards } from "@/lib/demo-workspace";
import { matchesRef, type Board } from "@/lib/workspace";
import { loadBoardCanvas } from "@/lib/canvas-loader";
import { loadCanvasEntry } from "@/lib/canvas-entry";
import {createBrowserCanvasMigration,type LegacyMigrationContext} from "@/lib/browser-canvas-migration";
import {readCanvasMigration} from "@/lib/canvas-migration";
import type { ProjectEngine } from "@/lib/canvas-runtime/workspace";
import { useCanvasRuntimeHost, useCanvasRenderers } from "./canvas-runtime-host";
import type { CanvasSnapshot } from "@/lib/canvas-sync";
import styles from "./workspace.module.css";
import { useActiveBoard } from "./active-board";
import dynamic from "next/dynamic";
const GraphCanvas = dynamic(() => import("@/components/canvas/graph-canvas"), { ssr: false, loading: () => <p role="status">正在准备画布…</p> });
type Loaded = { board: Board; snapshot?: CanvasSnapshot; route: string };
const HeadlessGraphCanvas = dynamic(() => import("@/components/canvas/headless-graph-canvas"), { ssr: false, loading: () => <p role="status">正在准备画布…</p> });
const LoadingGraphCanvas=dynamic(()=>import("@/components/canvas/loading-graph-canvas"),{ssr:false});
type Loaded = { board: Board; snapshot?: CanvasSnapshot; route: string; engine?: ProjectEngine; migration?:LegacyMigrationContext };
export default function GraphPage({ workspaceId, boardId }: { workspaceId: string; boardId: string }) {
const renderers=useCanvasRenderers();
const demo = useSearchParams().get("demo") === "1";
const router = useRouter();
const runtimeHost = useCanvasRuntimeHost();
const route = workspaceId + ":" + boardId;
const { board: activeBoard, setBoard: setActiveBoard } = useActiveBoard();
const [loaded, setLoaded] = useState<Loaded | null>(null);
const [preview,setPreview]=useState<{board:Board;value:LiveModelPreview;route:string}|null>(null);
const previewViewport=useRef<Viewport|undefined>(undefined);
const [error, setError] = useState("");
const [retry, setRetry] = useState(0);
const navigationGeneration=useRef(0);
useEffect(() => {
setLoaded(null); setError("");
// Fetch data while the much larger editor chunk loads.
void import("@/components/canvas/graph-canvas").catch(() => {});
setLoaded(null);setPreview(null);previewViewport.current=undefined; setError("");
if (demo) {
const item = readDemoBoards(workspaceId).find((board) => matchesRef(board, boardId));
if (item) setLoaded({ board: item, route });
@@ -33,20 +44,48 @@ export default function GraphPage({ workspaceId, boardId }: { workspaceId: strin
}
let active = true;
const controller = new AbortController();
loadBoardCanvas(api, workspaceId, boardId, controller.signal)
.then((result) => { if (active) setLoaded({ ...result, route }); })
const startingIdentity=runtimeHost.identity();let currentIdentity:(()=>boolean)|undefined;
let latest:{value:LiveModelPreview;board:Board;accountId:string}|undefined,pumping:Promise<void>|undefined,partialEngine:ProjectEngine|undefined,partialRuntime:ReturnType<typeof runtimeHost.workspace>|undefined;
const reportBackground=(error:unknown)=>{if(active)setError(errorText(error));};
const completeInBackground=()=>{if(partialEngine?.loading.getState()&&currentIdentity?.())void partialRuntime?.resumeLoading(partialEngine.getSnapshot().document.projectId).catch(reportBackground);};
function pump(){
if(pumping)return pumping;
const task=(async()=>{while(latest){const next=latest;latest=undefined;if(!currentIdentity?.())return;const runtime=runtimeHost.workspace(next.accountId,next.board.workspace_id);partialRuntime=runtime;const engine=await runtime.openPartial(next.value.document,next.value.versions,next.value.loading);partialEngine=engine;if(active&&currentIdentity?.()){setLoaded(previous=>previous?.route===route&&previous.engine===engine?previous:{board:next.board,engine,route});setPreview(null);}else{completeInBackground();return;}await engine.persist();}})();
pumping=task.finally(()=>{pumping=undefined;if(latest&&active)void pump().catch(reportBackground);});return pumping;
}
loadCanvasEntry({ request: api, workspaceRef: workspaceId, boardRef: boardId, registry: runtimeHost.registry, signal: controller.signal,navigationGeneration:++navigationGeneration.current,
onPreview:(value,board,profile)=>{if(active&&currentIdentity?.()&&value.document.canvases.has(board.id)){if(!partialEngine)setPreview({board,value,route});if(value.document.elements.size){latest={value,board,accountId:profile.id};void pump().catch(reportBackground);}}},
readModel:input=>{const identity=runtimeHost.identity();if(identity.authEpoch!==startingIdentity.authEpoch&&identity.accountId!==input.accountId)throw new Error('identity_changed');currentIdentity=runtimeHost.bindAccount(input.accountId);return runtimeHost.loadModel(input.accountId,input.workspaceId,input.canvasId,{signal:input.signal,navigationGeneration:input.navigationGeneration,onPreview:input.onPreview});},
}).then(async result => {
if (!active||currentIdentity&&!currentIdentity()) return;
if (result.mode === 'legacy') {
const migration:LegacyMigrationContext={accountId:result.profile.id,rootId:result.migration?.rootId,registry:runtimeHost.registry,isCurrentIdentity:()=>!!currentIdentity?.(),onApplied:()=>{if(active&&currentIdentity?.())setRetry(value=>value+1);}};
setLoaded({board:result.board,snapshot:result.snapshot,route,migration});setPreview(null);return;
}
const pending=readCanvasMigration(localStorage,{accountId:result.profile.id,workspaceId:result.board.workspace_id});
if(pending?.phase==='pending'&&pending.rootId===result.model.document.projectId){
try{await createBrowserCanvasMigration({scope:{accountId:result.profile.id,workspaceId:result.board.workspace_id},registry:runtimeHost.registry,isCurrentIdentity:()=>active&&!!currentIdentity?.(),signal:controller.signal}).resume();}
catch(error){reportBackground(error);}
}
while(pumping)await pumping;
if(!active||!currentIdentity?.())return;
const engine = await runtimeHost.workspace(result.profile.id, result.board.workspace_id).open(result.model.document, result.model.versions,{sourceToken:result.model.sourceToken});
if (active&&(!currentIdentity||currentIdentity())){setLoaded({ board: result.board, engine, route });setPreview(null);}
})
.catch((error) => { if (active) { setError(errorText(error)); if (error.message === "invalid_session") router.replace("/login"); } });
return () => { active = false; controller.abort(); };
}, [workspaceId, boardId, route, demo, retry, router]);
return () => { active = false;latest=undefined; controller.abort();completeInBackground(); };
}, [workspaceId, boardId, route, demo, retry, router, runtimeHost]);
const current = loaded?.route === route ? loaded : null;
const board = current?.board;
const currentPreview=preview?.route===route?preview:null;
const board = current?.board??currentPreview?.board;
useEffect(() => {
if (board) setActiveBoard({ id: board.id, slug: board.slug, workspace_id: board.workspace_id, workspace_ref: workspaceId, title: board.title });
return () => setActiveBoard(null);
}, [board, workspaceId, setActiveBoard]);
const displayBoard = board && activeBoard?.id === board.id && activeBoard.workspace_id === board.workspace_id ? { ...board, title: activeBoard.title } : board;
return <main className={styles.graph} aria-label={displayBoard ? displayBoard.title + " · 无限图谱" : "无限图谱"} aria-busy={!board && !error}>
{displayBoard ? <GraphCanvas key={displayBoard.workspace_id + ":" + displayBoard.id} board={displayBoard} snapshot={current?.snapshot} /> : <div className={styles.graphSurface} aria-label="正在读取画布" />}
return <main className={styles.graph} aria-label={displayBoard ? displayBoard.title + " · 无限图谱" : "无限图谱"} aria-busy={!current && !error}>
{displayBoard && current?.engine ? <HeadlessGraphCanvas key={displayBoard.workspace_id + ":" + displayBoard.id} board={displayBoard} engine={current.engine} renderers={renderers} initialViewport={previewViewport.current} /> : displayBoard&&currentPreview ? <LoadingGraphCanvas key={displayBoard.workspace_id+":"+displayBoard.id} preview={currentPreview.value} canvasId={displayBoard.id} workspaceId={displayBoard.workspace_id} registry={runtimeHost.registry} renderers={renderers} onViewport={viewport=>{previewViewport.current=viewport;}} /> : displayBoard ? <GraphCanvas key={(current?.migration?.accountId??"demo")+":"+displayBoard.workspace_id+":"+displayBoard.id} board={displayBoard} snapshot={current?.snapshot} migration={current?.migration} /> : <div className={styles.graphSurface} aria-label="正在读取画布" />}
{error ? <div className={styles.graphStatus} role="alert">{error} <button onClick={() => setRetry((value) => value + 1)}>重新加载</button></div> : !board && <p className={styles.graphStatus} role="status">正在打开图谱…</p>}
</main>;
}
@@ -94,7 +94,8 @@
.newWorkspace { color: #2383e2; }
.logout { color: #78746d; font-size: 12px; }
.separator { height: 1px; margin: 6px 7px; background: #eeece8; }
.floating { position: fixed; left: 12px; top: 12px; max-width: calc(100vw - 24px); z-index: 40; }
.floating { --floating-left: 12px; position: fixed; left: var(--floating-left); top: 12px; max-width: calc(100vw - var(--floating-left) - 12px); z-index: 40; transition: left 240ms cubic-bezier(0.22, 1, 0.36, 1), max-width 240ms cubic-bezier(0.22, 1, 0.36, 1); }
.floating[data-sidebar-collapsed="false"], .accountPopup[data-sidebar-collapsed="false"] { --floating-left: calc(min(var(--sidebar-width), calc(100vw - 40px)) + 12px); }
.floatingError { position: absolute; left: 0; top: 42px; width: 240px; padding: 10px; background: white; border: 1px solid #e7e2db; border-radius: 8px; color: #b14b3c; font-size: 12px; }
.settingsPanel { color: #37352f; border-color: #e6e2dc; border-radius: 12px; }
.settingsForm { display: flex; flex-direction: column; gap: 16px; padding-top: 4px; }
@@ -119,11 +120,17 @@
.floatingAvatarButton { display: grid; place-items: center; flex: 0 0 auto; box-sizing: border-box; width: var(--toolbar-control-width, 32px); height: var(--toolbar-control-height, 32px); border-radius: 5px; }
.floatingAvatarButton:hover { background: #e9e7e3; }
.accountPopup[data-floating="true"] { inset: 62px auto auto 12px; max-height: calc(100dvh - 76px); transform-origin: top left; }
.accountPopup[data-floating="true"] { inset: 62px auto auto clamp(8px, var(--floating-left, 12px), max(8px, calc(100vw - 308px))); max-height: calc(100dvh - 76px); transform-origin: top left; }
.floatingAvatarButton .avatar { flex-basis: var(--toolbar-icon-size, 20px); width: var(--toolbar-icon-size, 20px); height: var(--toolbar-icon-size, 20px); font-size: 14px; }
.floatingTitle { flex: 1 1 auto; min-width: 0; max-width: 240px; margin: 0; color: #37352f; font-size: 14px; line-height: var(--toolbar-control-height, 32px); font-weight: 500; overflow: visible; }
@media (max-width: 700px) { .floatingTitle { max-width: 180px; } }
@media (max-width: 420px) {
.floating[data-sidebar-collapsed="false"] .logoLink,
.floating[data-sidebar-collapsed="false"] .floatingTitle,
.floating[data-sidebar-collapsed="false"] > span[aria-hidden="true"] { display: none; }
}
@media (prefers-reduced-motion: reduce) { .floating { transition: none; } }
.directoryList { list-style: none; padding: 0; margin: 0; }
.directoryRow { display: flex; align-items: center; min-width: 0; }
+30
View File
@@ -59,6 +59,34 @@ export async function startOAuth(provider: "google" | "slack") {
export const errorText = (error: unknown) => {
const code = error instanceof Error ? error.message : "";
const messages: Record<string, string> = {
invalid_model_stream: "画布返回的数据无法解析,请重新加载。",
incomplete_model_stream: "画布数据未加载完整,请重新加载。",
unsupported_model_stream: "当前画布加载格式暂不受支持,请更新后重试。",
model_stream_budget: "画布数据量超过当前加载上限,请联系支持。",
load_snapshot_changed: "加载期间画布快照发生变化,请重新加载。",
invalid_model_identity: "画布的元素身份清单不一致,请重新加载。",
loading_reference_incomplete: "相关引用尚未完整加载,暂时无法修改。",
invalid_model_coverage: "画布的加载范围信息不一致,请重新加载。",
loading_waiting_for_receipts: "正在确认已发送的修改,请稍后重试。",
invalid_loading_asset: "图片任务的加载范围记录不一致,已保留本地内容并停止同步。",
loading_asset_interpreter_unavailable: "此图片任务需要更新版本后才能恢复。",
asset_source_changed: "图片资源的地址规则发生变化,请使用原配置恢复后重试。",
invalid_model_topology: "画布的分区关系不一致,请重新加载。",
loading_scope_incomplete: "所需分区或上级引用尚未完整加载,请稍后重试。",
invalid_loading_import: "导入任务的加载范围记录不完整,任务已停止。",
loading_copy_scope_changed: "复制源的范围校验失败,请重新复制。",
loading_import_interpreter_unavailable: "此导入任务需要更新版本后才能恢复。",
model_loading: "画布尚在加载,请等待加载完成后编辑。",
identity_changed: "账户已变化,当前画布已停止同步,请重新打开。",
engine_closing: "画布正在关闭,本地修改会保留用于恢复。",
storage_scope_in_use: "这张图谱已在另一个标签页中打开,请先关闭那里的编辑器。",
persistent_storage_unavailable: "浏览器无法保存本地恢复记录,请检查存储权限后重试。",
workspace_close_failed: "本地修改尚未保存成功,请释放浏览器存储空间后重试退出。",
host_close_failed: "部分本地修改尚未保存成功,请释放浏览器存储空间后重试退出。",
resource_bytes_unavailable: "此图片的本地文件暂不可用,相关修改已保留,请重新选择图片。",
recovery_checksum_mismatch: "本地恢复记录校验失败,原记录已保留,请先备份再处理。",
recovery_journal_gap: "本地恢复记录不完整,原记录已保留,请先备份再处理。",
unsupported_component: "这个元素需要更新版本或安装对应功能后才能编辑。",
invalid_credentials: "邮箱或密码不正确,或邮箱尚未验证。",
rate_limited: "请求太频繁,请稍后再试。",
invalid_session: "登录已过期,请重新登录。",
@@ -72,6 +100,8 @@ export const errorText = (error: unknown) => {
invalid_canvas: "画布数据未通过服务端校验,请导出备份后检查节点内容。",
response_interrupted: "画布数据传输中断或超时,请重新加载;当前内容未被覆盖。",
invalid_api_response: "接口返回了不完整或无法解析的数据,请重新加载。",
asset_target_changed: "目标分区已变化,请重新选择添加位置。",
asset_authorization_failed: "暂时无法读取这张图片,请确认仍有原画布的访问权限。",
invalid_asset: "图片格式无法识别,请选择有效的 PNG、JPEG、WebP 或 GIF。",
asset_too_large: "图片超出上传限制,请选择小于 20 MiB 的文件。",
invalid_asset_response: "图片上传结果无效,画布内容未替换,请重试。",
+45
View File
@@ -0,0 +1,45 @@
import type {Registry} from './canvas-core/types.ts';
import {fault} from './canvas-core/values.ts';
import {createFoundryTransport} from './canvas-service/foundry.ts';
import {canvasMigrationStorageKey,createCanvasMigration,migrationBlocksCanvas,readCanvasMigration,type CanvasMigrationScope} from './canvas-migration.ts';
export type LegacyMigrationContext={accountId:string;rootId?:string;registry:Registry;isCurrentIdentity():boolean;onApplied():void};
type BrowserScope={scope:CanvasMigrationScope;isCurrentIdentity():boolean;signal:AbortSignal;locks?:LockManager;storage?:Storage};
const lockKey=(scope:CanvasMigrationScope)=>canvasMigrationStorageKey(scope)+':editing';
function ports(options:BrowserScope){
const locks=options.locks??globalThis.navigator?.locks;
if(!locks)throw fault('migration_lock_unavailable');
return{locks,storage:options.storage??globalThis.localStorage};
}
const check=(options:BrowserScope)=>{if(!options.isCurrentIdentity())throw fault('identity_changed');options.signal.throwIfAborted();};
/** Held for the entire lifetime of an editable legacy view, not just each PATCH. */
export async function claimLegacyCanvasEditing(options:BrowserScope&{canvasId:string}){
check(options);const{locks,storage}=ports(options);
let resolve!:(value:{release():Promise<void>})=>void,reject!:(error:unknown)=>void;
const ready=new Promise<{release():Promise<void>}>((yes,no)=>{resolve=yes;reject=no;});
const request=locks.request(lockKey(options.scope),{mode:'shared',ifAvailable:true},async lock=>{
try{
check(options);if(!lock)throw fault('migration_busy');
if(migrationBlocksCanvas(readCanvasMigration(storage,options.scope),options.canvasId))throw fault('migration_response_unknown');
check(options);
let unlock!:()=>void;const released=new Promise<void>(yes=>{unlock=yes;});
const release=async()=>{unlock();await request;};
const abort=()=>{void release().catch(()=>{});};options.signal.addEventListener('abort',abort,{once:true});
resolve({release});
try{await released;}finally{options.signal.removeEventListener('abort',abort);}
}catch(error){reject(error);}
});
void request.catch(reject);return ready;
}
export function createBrowserCanvasMigration(options:BrowserScope&{registry:Registry}){
check(options);const{locks,storage}=ports(options);
return createCanvasMigration({scope:options.scope,registry:options.registry,storage,
transport:createFoundryTransport({accountId:options.scope.accountId,isCurrentIdentity:options.isCurrentIdentity}),
isCurrentIdentity:options.isCurrentIdentity,signal:options.signal,createId:()=>crypto.randomUUID(),
lock:work=>locks.request(lockKey(options.scope),{mode:'exclusive',ifAvailable:true},async lock=>{
check(options);if(!lock)throw fault('migration_busy');return work();
}),
});
}
+43
View File
@@ -0,0 +1,43 @@
type Point={x:number;y:number};
/** Compatibility renderer data only; never the authoritative element schema. */
export type AIConfig = { enabled?: boolean; modelId?: string; prompt?: string; propagate?: boolean; parameters?: Record<string, unknown> };
export type FramePort = { id: string; direction: "input" | "output"; nodeId: string; handle: string };
export type FramePresentation = { collapsed?: boolean; contentScale?: number; expandedSize?: { width: number; height: number }; collapsedSize?: { width: number; height: number } };
export type ElementKind = "text" | "image" | "script" | "plain-text" | "icon" | "frame" | "plain-image";
export type FrameLabelMode = "hidden" | "fixed" | "scaled";
export type TextStyle = { color?: string; background?: string; bold?: boolean; italic?: boolean; underline?: boolean; align?: "left" | "center" | "right"; fontFamily?: "sans" | "serif" | "mono"; lineHeight?: number };
export type RenderedTarget={readonly id:string;readonly incarnation:number;readonly ownerCanvasId:string;readonly canvasId:string};
export type CanvasNodeData = {
__headlessTarget?:RenderedTarget;
kind: ElementKind;
title: string;
text: string;
src?: string;
rotation?: number;
fontSize?: number;
textStyle?: TextStyle;
icon?: string;
frameId?: string;
labelMode?: FrameLabelMode;
ai?: AIConfig;
assetId?: string;
subgraph?: { boardId: string };
ports?: FramePort[];
presentation?: FramePresentation;
viewScale?: number;
};
export type MarkerShape = "none" | "open-arrow" | "triangle" | "circle" | "diamond" | "square";
export type EdgeMarker = { type: MarkerShape; size?: number; filled?: boolean };
export type BezierKnot = { id: string; position: Point; in: Point; out: Point; mode: "corner" | "smooth" | "symmetric" };
export type EdgeData = {
appearance?: { color?: string; width?: number; opacity?: number; line?: "solid" | "dashed" | "dotted"; head?: EdgeMarker; tail?: EdgeMarker };
animation?: { mode?: "off" | "always" | "executing"; effect?: "flow" | "dash" | "pulse"; direction?: "forward" | "reverse"; durationMs?: number; color?: string };
path?: { type: "bezier-chain"; startControl?: Point; endControl?: Point; knots: BezierKnot[] };
execution?: { enabled?: boolean };
};
/** Geometry input consumed by the compatibility layout, independent of UI nodes. */
export type CanvasGeometryNode={position:Point;width?:number;height?:number;style?:{width?:unknown;height?:unknown};measured?:{width?:number;height?:number};data:{kind:string;rotation?:number;fontSize?:number;viewScale?:number}};
@@ -0,0 +1,46 @@
import type {Registry} from '../canvas-core/types.ts';
import type {projectCanvas} from './projection.ts';
import type {HitServices} from './hit-query.ts';
import {createPaintIndex,type PaintRegion} from './paint-index.ts';
import {equal} from '../canvas-core/values.ts';
import {compatConnector} from './connectors.ts';
/** Conservative cubic control hull + half stroke, in the displayed scene space.
* Missing/custom geometry stays unknown so a host cannot interpret it as invisible. */
function connectionPaint(projection:ReturnType<typeof projectCanvas>,registry:Registry,services:HitServices={},maxEdges=4096,maxSegments=4096,cache?:Map<string,{signature:unknown;segments:number;region?:PaintRegion}>){
if(![maxEdges,maxSegments].every(n=>Number.isSafeInteger(n)&&n>0&&n<=1000000))throw Error('invalid_paint_budget');
const regions:PaintRegion[]=[],unknown:string[]=[],nodes=new Map<string,typeof projection.nodes[number]>(),endpoints=new Set<string>();
// Only inspected edges need node entries. Preserve last-ID-wins behavior without
// allocating an entry and pair array for every unrelated projected node.
for(let i=0;i<Math.min(maxEdges,projection.edges.length);i++){const edge=projection.edges[i];endpoints.add(edge.source);endpoints.add(edge.target);}
if(endpoints.size)for(const node of projection.nodes)if(endpoints.has(node.id))nodes.set(node.id,node);
let remainingSegments=maxSegments,computed=0;
for(let index=0;index<projection.edges.length;index++){
if(index>=maxEdges){unknown.push('paint_budget');break;}const edge=projection.edges[index];
try{const record=projection.document.elements.get(edge.id),source=nodes.get(edge.source),target=nodes.get(edge.target);if(!record||record.structure!=='connection'||!source||!target)throw Error();
const anchor=(node:typeof source,handle:string|null|undefined,end:'source'|'target')=>services.anchor?services.anchor(node,handle,end,edge):node.data.__headlessRecord?undefined:compatConnector(node,handle,end==='source'?'right':'left');
const from=anchor(source,edge.sourceHandle,'source'),to=anchor(target,edge.targetHandle,'target'),transform=projection.transforms.get(record.ownerCanvasId);if(!from||!to||!transform||![from.point,from.normal,to.point,to.normal].every(point=>point&&Number.isFinite(point.x)&&Number.isFinite(point.y))||![transform.x,transform.y,transform.scale,transform.rotation].every(Number.isFinite)||transform.scale<=0)throw Error();
const protocol=edge.type!=='unsupported'&&registry.resolve(record)==='known'?registry.components.get(record.componentId)?.geometry:undefined;
const signature={record,edge,from:{point:{...from.point},normal:{...from.normal}},to:{point:{...to.point},normal:{...to.normal}},transform:{...transform},source:{position:{...source.position},width:source.width,height:source.height},target:{position:{...target.position},width:target.width,height:target.height}},old=cache?.get(edge.id);
if(old&&equal(old.signature,signature)){if(old.segments>remainingSegments){unknown.push('paint_budget');break;}remainingSegments-=old.segments;if(old.region)regions.push(old.region);continue;}
computed++;
const segments=services.edgeGeometry?.(edge,from,to)??(protocol?.kind==='connection'?protocol.segments({element:record,source:from,target:to,displayPath:edge.data?.__headlessRecord?undefined:edge.data?.path??null,transform}):undefined),stroke=services.strokeWidth?.(edge)??(protocol?.kind==='connection'?protocol.strokeWidth({element:record,transform}):undefined);
if(!segments||!Array.isArray(segments)||segments.length>maxSegments||typeof stroke!=='number'||!Number.isFinite(stroke)||stroke<0)throw Error();if(!segments.length){cache?.set(edge.id,{signature,segments:0});continue;}if(segments.length>remainingSegments){unknown.push('paint_budget');break;}remainingSegments-=segments.length;
let left=Infinity,top=Infinity,right=-Infinity,bottom=-Infinity;
for(const segment of segments){if(!Array.isArray(segment)||segment.length!==4)throw Error();for(const point of segment){if(!point||!Number.isFinite(point.x)||!Number.isFinite(point.y))throw Error();left=Math.min(left,point.x);top=Math.min(top,point.y);right=Math.max(right,point.x);bottom=Math.max(bottom,point.y);}}
const half=stroke/2,x=left-half,y=top-half,width=right-left+stroke,height=bottom-top+stroke;if(![x,y,width,height,x+width,y+height].every(Number.isFinite))throw Error();
const native={left:Math.min(source.position.x,target.position.x),top:Math.min(source.position.y,target.position.y),right:Math.max(source.position.x+source.width!,target.position.x+target.width!),bottom:Math.max(source.position.y+source.height!,target.position.y+target.height!)};
if(!Object.values(native).every(Number.isFinite))throw Error();const region=x<native.left||y<native.top||x+width>native.right||y+height>native.bottom?{id:edge.id,bounds:{x,y,width,height}}:undefined;if(region)regions.push(region);cache?.set(edge.id,{signature,segments:segments.length,region});
}catch{cache?.delete(edge.id);unknown.push(edge.id);}
}
return{regions,unknown,computed};
}
export function projectedConnectionPaintOverflow(projection:ReturnType<typeof projectCanvas>,registry:Registry,services:HitServices={},maxEdges=4096,maxSegments=4096){return connectionPaint(projection,registry,services,maxEdges,maxSegments);}
/** Host revision invalidates geometry services whose external configuration changes. */
export function createConnectionPaintIndex(){
const cache=new Map<string,{signature:unknown;segments:number;region?:PaintRegion}>(),index=createPaintIndex();let owner:Registry|undefined,project:string|undefined,canvas:string|undefined,revision:unknown,computed=0;
return Object.freeze({sync(projection:ReturnType<typeof projectCanvas>,registry:Registry,services:HitServices={},geometryRevision?:unknown,budget:{maxEdges?:number;maxSegments?:number}={}){
if(owner!==registry||project!==projection.document.projectId||canvas!==projection.canvasId||!Object.is(revision,geometryRevision)){cache.clear();index.clear();owner=registry;project=projection.document.projectId;canvas=projection.canvasId;revision=geometryRevision;}
const result=connectionPaint(projection,registry,services,budget.maxEdges??4096,budget.maxSegments??4096,cache),seen=new Set(projection.edges.map(edge=>edge.id));for(const id of cache.keys())if(!seen.has(id))cache.delete(id);index.sync(result.regions);computed+=result.computed;return Object.freeze({query:index.query,unknown:Object.freeze(result.unknown)});
},stats:()=>Object.freeze({computed,entries:cache.size}),clear(){cache.clear();index.clear();owner=undefined;project=undefined;canvas=undefined;revision=undefined;}});
}
+16
View File
@@ -0,0 +1,16 @@
import type {CanvasGeometryNode} from './compat-types.ts';
import type {ProjectionNode} from './projection.ts';
import {nodeConnector,type Connector,type ConnectorSide} from '../canvas-geometry.ts';
type CompatNode=CanvasGeometryNode&{data:ProjectionNode['data']};
/** Same card-border and port layout used by CanvasEdge. Never infer it for record-v1 DOM. */
export function compatConnector(node:CompatNode,handle:string|null|undefined,fallback:ConnectorSide):Connector{
const port=Array.isArray(node.data.ports)?node.data.ports.find(port=>port&&`port:${port.id}`===handle):undefined,hints=(node.data as ProjectionNode['data']&{__headlessFallback?:{handles?:{id:string;side:ConnectorSide}[]}}).__headlessFallback?.handles,projected=Array.isArray(hints)?hints.find(hint=>hint?.id===handle&&['top','right','bottom','left'].includes(hint.side))?.side:undefined,side=projected??(['top','right','bottom','left'].includes(handle??'')?handle as ConnectorSide:port?.direction==='input'?'left':port?'right':fallback),result=nodeConnector(node,side);
if(port){const peers=node.data.ports!.filter(item=>item.direction===port.direction);result.point.y+=((peers.indexOf(port)+1)/(peers.length+1)-.5)*(node.measured?.height||node.height||400);}return result;
}
export type MeasuredNode={readonly internals:{readonly positionAbsolute:{x:number;y:number};readonly handleBounds?:{readonly source?:readonly MeasuredHandle[]|null;readonly target?:readonly MeasuredHandle[]|null}}};
type MeasuredHandle={readonly id?:string|null;readonly position:ConnectorSide;readonly x:number;readonly y:number;readonly width:number;readonly height:number};
/** Native Handle outer edge, matching the installed React Flow loose connection mode. */
export function measuredConnector(node:MeasuredNode|undefined,handleId:string|null|undefined,end:'source'|'target'):Connector|undefined{
const bounds=node?.internals.handleBounds;if(!node||!bounds)return;const handles=end==='source'?bounds.source??[]:[...(bounds.target??[]),...(bounds.source??[])],handle=handleId?handles.find(handle=>handle.id===handleId):handles[0];if(!handle)return;
const {x,y}=node.internals.positionAbsolute,side=handle.position,normals={top:{x:0,y:-1},right:{x:1,y:0},bottom:{x:0,y:1},left:{x:-1,y:0}},point={x:x+handle.x+(side==='left'?0:side==='right'?handle.width:handle.width/2),y:y+handle.y+(side==='top'?0:side==='bottom'?handle.height:handle.height/2)};return{point,normal:normals[side]};
}
@@ -0,0 +1,52 @@
import type {Commit,Id} from '../canvas-core/types.ts';
import type {ElementLookup} from '../canvas-core/queries.ts';
import type {EngineSnapshot} from '../canvas-runtime/contracts.ts';
import {equal} from '../canvas-core/values.ts';
import type {ExternalSource} from './selectors.ts';
export type ElementSourcePorts={
getSnapshot():EngineSnapshot;
subscribe(listener:(commit:Commit,snapshot:EngineSnapshot)=>void):()=>void;
lookup(snapshot:EngineSnapshot,id:Id):ElementLookup;
};
/** One upstream subscription, ID buckets and cached lookup values, never another document.
* Explicit changes use ID routing; replacement/coverage cuts retain a checked fallback. */
export function createElementSources(ports:ElementSourcePorts){
type Bucket={value:ElementLookup;listeners:Set<()=>void>;failed?:boolean};
const buckets=new Map<Id,Bucket>();let stop:(()=>void)|undefined,previous:EngineSnapshot|undefined;
function changed(commit:Commit,next:EngineSnapshot){
// The editor snapshots its listeners; this callback can already be unsubscribed.
if(!previous||!buckets.size)return;
const before=previous!;previous=next;
const ids=new Set<Id>();
if(before.document!==next.document){
if(commit.changes.length){for(const change of commit.changes)if(change.kind==='field')ids.add(change.target.id);else if(change.kind==='element')ids.add(change.id);}
else for(const id of buckets.keys())ids.add(id);
}
const coverageChanged=before.runtime.loading!==next.runtime.loading||!!next.runtime.loading&&(before.runtime.loadingRead?.document??before.runtime.acknowledged)!==(next.runtime.loadingRead?.document??next.runtime.acknowledged);
if(coverageChanged)for(const[id,bucket]of buckets)if(bucket.value.status!=='present')ids.add(id);
const notifications=new Set<()=>void>();
for(const id of ids){
const bucket=buckets.get(id);if(!bucket)continue;
try{const value=ports.lookup(next,id);if(!bucket.failed&&equal(bucket.value,value))continue;bucket.value=value;bucket.failed=false;}catch{bucket.failed=true;/* Snapshot readers retry/raise through their own boundary. */}
for(const notify of bucket.listeners)notifications.add(notify);
}
// All bucket values advance before observers run; a failing observer cannot starve others.
let failed=false,error:unknown;
for(const notify of notifications)try{notify();}catch(caught){if(!failed){failed=true;error=caught;}}
if(failed)throw error;
}
return Object.freeze({forElement(id:Id):ExternalSource<ElementLookup>{
let value:ElementLookup|undefined;
return Object.freeze({
getSnapshot(){const next=ports.lookup(ports.getSnapshot(),id);if(!value||!equal(value,next))value=next;return value;},
subscribe(notify:()=>void){
let bucket=buckets.get(id);if(!bucket){bucket={value:ports.lookup(ports.getSnapshot(),id),listeners:new Set()};buckets.set(id,bucket);}
// Distinct subscriptions may intentionally share the same callback identity.
const listener=()=>notify();bucket.listeners.add(listener);
if(!stop){previous=ports.getSnapshot();try{stop=ports.subscribe(changed);}catch(error){bucket.listeners.delete(listener);if(!bucket.listeners.size)buckets.delete(id);if(!buckets.size)previous=undefined;throw error;}}
let active=true;return()=>{if(!active)return;active=false;bucket!.listeners.delete(listener);if(!bucket!.listeners.size)buckets.delete(id);if(!buckets.size){stop?.();stop=undefined;previous=undefined;}};
},
});
}});
}
+22
View File
@@ -0,0 +1,22 @@
import type {ExternalSource} from './selectors.ts';
/** Presentation invalidations only. Commands, history and network work never wait here. */
export type FrameClock={request(callback:()=>void):unknown;cancel(handle:unknown):void};
export function createFrameQueue<K>(clock:FrameClock,publish:(dirty:ReadonlySet<K>)=>void){
let dirty=new Set<K>(),scheduled:{handle?:unknown}|undefined,disposed=false;
function flush(){if(disposed)return;const pending=scheduled;scheduled=undefined;if(pending)clock.cancel(pending.handle);if(!dirty.size)return;const batch=dirty;dirty=new Set();publish(batch);}
return Object.freeze({
invalidate(keys:Iterable<K>){if(disposed)return;for(const key of keys)dirty.add(key);if(!dirty.size||scheduled)return;const pending: {handle?:unknown}={};scheduled=pending;try{pending.handle=clock.request(()=>{if(scheduled!==pending)return;scheduled=undefined;flush();});}catch(error){scheduled=undefined;throw error;}},
flush,
dispose(){if(disposed)return;disposed=true;if(scheduled)clock.cancel(scheduled.handle);scheduled=undefined;dirty.clear();},
});
}
/** Coalesce presentation notifications; immediate invalidations flush pending work synchronously. */
export function createFrameSource<S>(source:{getSnapshot():S;subscribe(notify:(immediate?:boolean)=>void):()=>void},clock:FrameClock):ExternalSource<S>{
return Object.freeze({getSnapshot:()=>source.getSnapshot(),subscribe(notify:()=>void){
const queue=createFrameQueue<number>(clock,()=>notify());
let stop:()=>void;
try{stop=source.subscribe(immediate=>{queue.invalidate([0]);if(immediate)queue.flush();});}catch(error){queue.dispose();throw error;}
return()=>{try{stop();}finally{queue.dispose();}};
}});
}
+21
View File
@@ -0,0 +1,21 @@
import type {Geometry,Id,Json} from '../canvas-core/types.ts';
import {childCanvasTransform,toWorld,transformPath,type Transform} from '../canvas-core/coordinates.ts';
import {equal,clone,freeze} from '../canvas-core/values.ts';
const sameTransform=(a:Transform,b:Transform)=>a.x===b.x&&a.y===b.y&&a.scale===b.scale&&a.rotation===b.rotation;
const sameGeometry=(a:Geometry,b:Geometry)=>a.x===b.x&&a.y===b.y&&a.width===b.width&&a.height===b.height&&a.rotation===b.rotation;
const units=(value:Json|undefined)=>value&&typeof value==='object'&&!Array.isArray(value)?[value.contentScale,value.unitScale]:[];
/** View-local numeric/path projection only. No editable records, history, visibility or task facts. */
export function createGeometryProjectionCache(){
let owner:object|undefined,project:Id|undefined,canvas:Id|undefined,nodesComputed=0,childrenComputed=0,edgesComputed=0;
type NodeResult={position:{x:number;y:number};width:number;height:number;rotation:number};
const nodes=new Map<Id,{incarnation:number;geometry:Geometry;transform:Transform;value:NodeResult}>(),children=new Map<Id,{incarnation:number;geometry:Geometry;transform:Transform;units:unknown;value:Transform}>(),edges=new Map<Id,{incarnation:number;path:Json;transform:Transform;value:Json}>();let seenNodes=new Set<Id>(),seenChildren=new Set<Id>(),seenEdges=new Set<Id>();
return Object.freeze({
begin(scope:object,projectId:Id,canvasId:Id){if(owner!==scope||project!==projectId||canvas!==canvasId){nodes.clear();children.clear();edges.clear();owner=scope;project=projectId;canvas=canvasId;}seenNodes=new Set();seenChildren=new Set();seenEdges=new Set();},
node(id:Id,incarnation:number,g:Geometry,t:Transform):NodeResult{seenNodes.add(id);const before=nodes.get(id);if(before&&before.incarnation===incarnation&&sameGeometry(before.geometry,g)&&sameTransform(before.transform,t))return before.value;const center=toWorld(t,{x:g.x+g.width/2,y:g.y+g.height/2}),width=g.width*t.scale,height=g.height*t.scale,value=freeze({position:{x:center.x-width/2,y:center.y-height/2},width,height,rotation:(g.rotation+t.rotation)%360});nodes.set(id,{incarnation,geometry:{...g},transform:{...t},value});nodesComputed++;return value;},
child(id:Id,incarnation:number,g:Geometry,t:Transform,presentation:Json|undefined):Transform{seenChildren.add(id);const before=children.get(id),currentUnits=units(presentation);if(before&&before.incarnation===incarnation&&sameGeometry(before.geometry,g)&&sameTransform(before.transform,t)&&equal(before.units,currentUnits))return before.value;const value=freeze(childCanvasTransform(t,g,presentation));children.set(id,{incarnation,geometry:{...g},transform:{...t},units:currentUnits,value});childrenComputed++;return value;},
edge(id:Id,incarnation:number,path:Json,t:Transform):Json{seenEdges.add(id);const before=edges.get(id);if(before&&before.incarnation===incarnation&&equal(before.path,path)&&sameTransform(before.transform,t))return before.value;const captured=freeze(clone(path)),value=freeze(transformPath(captured,t));edges.set(id,{incarnation,path:captured,transform:{...t},value});edgesComputed++;return value;},
end(){for(const id of nodes.keys())if(!seenNodes.has(id))nodes.delete(id);for(const id of children.keys())if(!seenChildren.has(id))children.delete(id);for(const id of edges.keys())if(!seenEdges.has(id))edges.delete(id);},
stats:()=>Object.freeze({nodesComputed,childrenComputed,edgesComputed,nodes:nodes.size,children:children.size,edges:edges.size}),
});
}
export type GeometryProjectionCache=ReturnType<typeof createGeometryProjectionCache>;
+41
View File
@@ -0,0 +1,41 @@
import type {DocumentQueries} from '../canvas-core/queries.ts';
import type {ElementRecord,Geometry,Id,Point,Registry,Target} from '../canvas-core/types.ts';
import type {Connector,Cubic} from '../canvas-core/geometry.ts';
import {rectangleGeometry} from '../canvas-core/geometry.ts';
import {hitCubic} from '../canvas-core/curve-hit.ts';
import {toLocal,type Transform} from '../canvas-core/coordinates.ts';
import {fault,freeze} from '../canvas-core/values.ts';
import {compatConnector} from './connectors.ts';
import type {projectCanvas,ProjectionNode,ProjectionEdge} from './projection.ts';
type Projection=ReturnType<typeof projectCanvas>;
export type SceneHit={readonly target:Target;readonly kind:'node'|'connection';readonly ownerCanvasId:Id;readonly displayPath:{readonly viewSessionId:Id;readonly canvasIds:readonly Id[];readonly entryIds:readonly Id[]};readonly layer:number;readonly certainty:'confirmed'|'possible';readonly distanceScene?:number;readonly errorScene?:number;readonly displayedEndpoints?:{readonly source:Id;readonly target:Id}};
export type HitServices={readonly anchor?:(node:ProjectionNode,handle:string|null|undefined,end:'source'|'target',edge:ProjectionEdge)=>Connector|undefined;readonly edgeGeometry?:(edge:ProjectionEdge,source:Connector,target:Connector)=>readonly Cubic[]|undefined;readonly strokeWidth?:(edge:ProjectionEdge)=>number|undefined;readonly layer?:(id:Id,kind:'node'|'connection')=>number|undefined;readonly previewIds?:ReadonlySet<Id>};
export type HitBudget={readonly maxCandidates?:number;readonly maxCurveSteps?:number;readonly maxCurveDepth?:number;readonly maxSegments?:number;readonly errorPx?:number};
export type HitPoint=({readonly space:'scene';readonly point:Point;readonly zoom:number}|{readonly space:'screen';readonly point:Point;readonly viewport:{readonly x:number;readonly y:number;readonly zoom:number;readonly origin:Point}})&{readonly tolerancePx?:number};
const finitePoint=(point:Point)=>!!point&&Number.isFinite(point.x)&&Number.isFinite(point.y);
function geometry(node:ProjectionNode,t:Transform):Geometry{const width=node.width!/t.scale,height=node.height!/t.scale,center=toLocal(t,{x:node.position.x+node.width!/2,y:node.position.y+node.height!/2});return{x:center.x-width/2,y:center.y-height/2,width,height,rotation:((node.data.rotation??0)-t.rotation+360)%360};}
/** Query the captured display frame. It neither edits nor replaces the native input router. */
export function hitProjectedCanvas(projection:Projection,queries:DocumentQueries,registry:Registry,viewSessionId:Id,query:HitPoint,services:HitServices={},budget:HitBudget={}){
if(projection.document!==queries.document||!viewSessionId)throw fault('stale_query_snapshot');if(queries.registry!==registry)throw fault('query_registry_changed');const zoom=query.space==='scene'?query.zoom:query.viewport.zoom,tolerancePx=query.tolerancePx??6,errorPx=budget.errorPx??.25,maxCandidates=budget.maxCandidates??4096,maxSteps=budget.maxCurveSteps??8192,maxDepth=budget.maxCurveDepth??20,maxSegments=budget.maxSegments??4096;
if(!finitePoint(query.point)||!Number.isFinite(zoom)||zoom<=0||!Number.isFinite(tolerancePx)||tolerancePx<0||!Number.isFinite(errorPx)||errorPx<=0||![maxCandidates,maxSteps,maxDepth,maxSegments].every(n=>Number.isSafeInteger(n)&&n>=1&&n<=1000000)||maxDepth>40)throw fault('invalid_hit_query');
if(query.space==='screen'&&(!finitePoint(query.viewport.origin)||!Number.isFinite(query.viewport.x)||!Number.isFinite(query.viewport.y)))throw fault('invalid_hit_query');
const point=query.space==='scene'?query.point:{x:(query.point.x-query.viewport.origin.x-query.viewport.x)/zoom,y:(query.point.y-query.viewport.origin.y-query.viewport.y)/zoom},tolerance=tolerancePx/zoom,document=projection.document,nodes=new Map(projection.nodes.map(node=>[node.id,node])),rank=new Map(queries.orderedElements([...nodes.keys(),...projection.edges.map(edge=>edge.id)]).map((id,index)=>[id,index])),candidates=new Set<Id>(services.previewIds??[]),hits:SceneHit[]=[],diagnostics:{target?:Id;code:string}[]=[];let work=0,remaining=maxSteps;
if(!finitePoint(point)||!Number.isFinite(tolerance)||!Number.isFinite(errorPx/zoom))throw fault('invalid_hit_query');
for(const[id,t]of projection.transforms){if(!Number.isFinite(t.scale)||t.scale<=0)throw fault('invalid_hit_transform');const p=toLocal(t,point),r=tolerance/t.scale;for(const candidate of queries.candidateNodes(id,{x:p.x-r,y:p.y-r,width:2*r,height:2*r}))candidates.add(candidate);}
function path(owner:Id){const canvases:Id[]=[],entries:Id[]=[],seen=new Set<Id>();let id=owner;while(id!==projection.canvasId){if(seen.has(id))throw fault('canvas_cycle');seen.add(id);const parent=document.canvases.get(id)?.parent;if(!parent)throw fault('invalid_display_path');canvases.push(id);entries.push(parent.entryElementId);id=parent.canvasId;}return{viewSessionId,canvasIds:[projection.canvasId,...canvases.reverse()],entryIds:entries.reverse()};}
const layer=(id:Id,kind:'node'|'connection',fallback:number)=>{const value=services.layer?.(id,kind)??fallback;if(!Number.isFinite(value))throw fault('invalid_hit_layer');return value;};
for(const id of candidates){const node=nodes.get(id);if(!node)continue;if(++work>maxCandidates){diagnostics.push({code:'geometry_query_budget'});break;}try{const record=document.elements.get(id);if(!record||record.structure!=='node')throw fault('invalid_hit_projection');const t=projection.transforms.get(record.ownerCanvasId);if(!t)throw fault('invalid_hit_transform');const g=geometry(node,t),resolved=!node.data.__headlessFallback&&registry.resolve(record)==='known'?registry.components.get(record.componentId)?.geometry:undefined,protocol=resolved?.kind==='node'?resolved:rectangleGeometry,p=toLocal(t,point),localTolerance=tolerance/t.scale;
const bounds=protocol.bounds({element:record,geometry:g,role:'hit'});if(![g.x,g.y,g.width,g.height,g.rotation,bounds.x,bounds.y,bounds.width,bounds.height].every(Number.isFinite)||g.width<0||g.height<0||bounds.width<0||bounds.height<0)throw fault('invalid_geometry_bounds');
const result=protocol.hit({element:record,geometry:g,point:p,tolerance:localTolerance});if(typeof result!=='boolean')throw fault('invalid_geometry_response');if(result)hits.push({target:{id,incarnation:record.incarnation},kind:'node',ownerCanvasId:record.ownerCanvasId,displayPath:path(record.ownerCanvasId),layer:layer(id,'node',node.zIndex??0),certainty:'confirmed'});
}catch(error){diagnostics.push({target:id,code:(error as{code?:string}).code??'geometry_handler_failed'});}}
const anchor=(node:ProjectionNode,handle:string|null|undefined,end:'source'|'target',edge:ProjectionEdge)=>services.anchor?services.anchor(node,handle,end,edge):node.data.__headlessRecord?undefined:compatConnector(node,handle,end==='source'?'right':'left');
for(const edge of projection.edges){if(++work>maxCandidates){diagnostics.push({code:'geometry_query_budget'});break;}try{const record=document.elements.get(edge.id);if(!record||record.structure!=='connection')throw fault('invalid_hit_projection');const source=nodes.get(edge.source),target=nodes.get(edge.target);if(!source||!target)throw fault('geometry_anchor_unavailable');const from=anchor(source,edge.sourceHandle,'source',edge),to=anchor(target,edge.targetHandle,'target',edge);if(!from||!to||![from.point,from.normal,to.point,to.normal].every(finitePoint))throw fault('geometry_anchor_unavailable');
const transform=projection.transforms.get(record.ownerCanvasId);if(!transform)throw fault('invalid_hit_transform');const protocol=edge.type!=='unsupported'&&registry.resolve(record)==='known'?registry.components.get(record.componentId)?.geometry:undefined,context={element:record,source:from,target:to,displayPath:edge.data?.__headlessRecord?undefined:edge.data?.path??null,transform};
const segments=services.edgeGeometry?.(edge,from,to)??(protocol?.kind==='connection'?protocol.segments(context):undefined);if(!segments)throw fault('geometry_unavailable');if(!Array.isArray(segments)||segments.length>maxSegments||segments.some(segment=>!Array.isArray(segment)||segment.length!==4||!segment.every(finitePoint)))throw fault('invalid_geometry_response');
const width=services.strokeWidth?.(edge)??(protocol?.kind==='connection'?protocol.strokeWidth({element:record,transform}):undefined);if(typeof width!=='number'||!Number.isFinite(width)||width<0)throw fault('invalid_geometry_paint');let possible:ReturnType<typeof hitCubic>|undefined,confirmed:ReturnType<typeof hitCubic>|undefined;
for(const segment of segments){if(remaining<=0){possible??={hit:true,certainty:'possible',steps:0,distance:Infinity,error:Infinity,budget:true};break;}const result=hitCubic(segment,point,tolerance+width/2,{error:errorPx/zoom,maxDepth,maxSteps:remaining});remaining-=result.steps;if(result.hit){if(result.certainty==='confirmed'){confirmed=result;break;}possible=result;}}
const result=confirmed??possible;if(result?.hit){if(!confirmed)diagnostics.push({target:edge.id,code:result.budget?'geometry_query_budget':'geometry_precision'});hits.push({target:{id:edge.id,incarnation:record.incarnation},kind:'connection',ownerCanvasId:record.ownerCanvasId,displayPath:path(record.ownerCanvasId),displayedEndpoints:{source:edge.source,target:edge.target},layer:layer(edge.id,'connection',edge.zIndex??0),certainty:confirmed?'confirmed':'possible',...(Number.isFinite(result.distance)?{distanceScene:result.distance}:{}),...(Number.isFinite(result.error)?{errorScene:result.error}:{})});}
}catch(error){diagnostics.push({target:edge.id,code:(error as{code?:string}).code??'geometry_handler_failed'});}}
if([...projection.transforms.keys()].some(id=>!queries.coverage.completeCanvasIds.includes(id)))diagnostics.push({code:'model_scope_incomplete'});
hits.sort((a,b)=>b.layer-a.layer||(a.kind===b.kind?0:a.kind==='node'?-1:1)||(rank.get(b.target.id)??0)-(rank.get(a.target.id)??0));return freeze({projectId:document.projectId,revision:queries.revision,viewSessionId,pointScene:{...point},toleranceScene:tolerance,hits,complete:diagnostics.length===0,diagnostics});
}
+25
View File
@@ -0,0 +1,25 @@
/** Portable scene projection and hit queries: no React or React Flow imports. */
export * from './projection.ts';
export * from './geometry-cache.ts';
export * from './projection-cache.ts';
export * from './hit-query.ts';
export * from './renderers.ts';
export * from './connectors.ts';
export * from './resource-view.ts';
export type * from './compat-types.ts';
export {createSelectorStore} from './selectors.ts';
export type {ExternalSource} from './selectors.ts';
export {routeCanvasKey,canvasKeyContext} from './input-router.ts';
export type {CanvasKeyInput,InputClaim,KeyHandler} from './input-router.ts';
export {createFrameQueue,createFrameSource} from './frame-queue.ts';
export type {FrameClock} from './frame-queue.ts';
export {createSessionFrameSource} from './session-frames.ts';
export {createPaintIndex} from './paint-index.ts';
export type {PaintRegion} from './paint-index.ts';
export {projectedPaintOverflow,createProjectedPaintIndex} from './projected-paint.ts';
export {projectedConnectionPaintOverflow,createConnectionPaintIndex} from './connection-paint.ts';
export {createOverlayIndex} from './overlays.ts';
export type {OverlayEntry,OverlayIndex} from './overlays.ts';
export {ownsPointerCapture,acquirePointerCapture,releasePointerCapture} from './pointer-capture.ts';
export type {PointerCapturePort} from './pointer-capture.ts';
+25
View File
@@ -0,0 +1,25 @@
/** Host normalizes DOM/native input; only the winning layer consumes an event. */
export type CanvasKeyInput={readonly key:string;readonly composing?:boolean;readonly textEditing?:boolean;readonly overlay?:boolean;readonly defaultPrevented?:boolean;readonly ctrl?:boolean;readonly meta?:boolean;readonly alt?:boolean;readonly shift?:boolean};
export type InputClaim='text'|'overlay'|'gesture'|'tool'|'shortcut'|'unclaimed';
export type KeyHandler=(input:CanvasKeyInput)=>boolean;
const textTarget = 'input,textarea,select,[contenteditable]:not([contenteditable="false"]),[role="textbox"]';
const overlayTarget = '[data-canvas-overlay-view],[role="dialog"],[role="menu"],[role="listbox"],[role="combobox"]';
/** Inspect the composed path as well as the retargeted host (portals/shadow DOM).
* This uses a structural DOM port so pure consumers need no browser globals. */
export function canvasKeyContext(event:{readonly target?:unknown;composedPath?:()=>readonly unknown[]}):Pick<CanvasKeyInput,'textEditing'|'overlay'>{
let textEditing=false,overlay=false;
for(const target of [event.target,...(event.composedPath?.()??[])]){
if(!target||typeof (target as {closest?:unknown}).closest!=='function')continue;
const element=target as {closest(selector:string):unknown};
textEditing ||= !!element.closest(textTarget);
overlay ||= !!element.closest(overlayTarget);
}
return {textEditing,overlay};
}
export function routeCanvasKey(input:CanvasKeyInput,handlers:{gesture?:KeyHandler;tool?:KeyHandler;shortcut?:KeyHandler}):InputClaim{
if(input.defaultPrevented)return 'unclaimed';
if(input.composing||input.textEditing)return 'text';
if(input.overlay)return 'overlay';
for(const layer of ['gesture','tool','shortcut'] as const)if(handlers[layer]?.(input))return layer;
return 'unclaimed';
}
+17
View File
@@ -0,0 +1,17 @@
/** One renderer store's transient measurement requests. Not model or history state. */
export function createNodeInternalsBatch(update:(ids:string[])=>void,defer:(flush:()=>void)=>void=queueMicrotask){
const owners=new Map<symbol,string>(),dirty=new Set<symbol>();let queued=false;
function flush(){
queued=false;const ids=new Set<string>();for(const owner of dirty){const id=owners.get(owner);if(id!==undefined)ids.add(id);}dirty.clear();
if(ids.size)update([...ids]);
}
return Object.freeze({
acquire(id:string){
const owner=Symbol(id);owners.set(owner,id);
return Object.freeze({
invalidate(){if(!owners.has(owner))return;dirty.add(owner);if(!queued){queued=true;defer(flush);}},
dispose(){owners.delete(owner);dirty.delete(owner);},
});
},
});
}
+17
View File
@@ -0,0 +1,17 @@
import type {Target} from '../canvas-core/types.ts';
import type {Rect} from '../canvas-core/geometry.ts';
import {createPaintIndex} from './paint-index.ts';
import {fault} from '../canvas-core/values.ts';
export type OverlayEntry={readonly id:string;readonly viewId:string;readonly placement:string;readonly target:Target;readonly bounds:Rect;readonly focused:boolean;readonly composing:boolean};
/** Ephemeral screen-space placements. A lease prevents late cleanup from removing a replacement. */
export function createOverlayIndex(viewId:string){
if(typeof viewId!=='string'||!viewId)throw fault('view_session_required');let serial=0,snapshot:readonly OverlayEntry[]=Object.freeze([]);const entries=new Map<string,{token:number;entry:OverlayEntry}>(),listeners=new Set<()=>void>(),index=createPaintIndex();
function publish(){snapshot=Object.freeze([...entries.values()].map(row=>row.entry));index.sync(snapshot.map(entry=>({id:entry.id,bounds:entry.bounds})));for(const listener of [...listeners])listener();}
function capture(id:string,value:Omit<OverlayEntry,'id'|'viewId'>):OverlayEntry{if(typeof id!=='string'||!id||typeof value.placement!=='string'||!value.placement||typeof value.target.id!=='string'||!value.target.id||!Number.isSafeInteger(value.target.incarnation)||value.target.incarnation<1)throw fault('invalid_overlay');const entry=Object.freeze({...value,id,viewId,target:Object.freeze({...value.target}),bounds:Object.freeze({...value.bounds})});const check=createPaintIndex();check.sync([{id,bounds:entry.bounds}]);return entry;}
return Object.freeze({viewId,getSnapshot:()=>snapshot,subscribe(listener:()=>void){listeners.add(listener);return()=>{listeners.delete(listener);};},
register(id:string,value:Omit<OverlayEntry,'id'|'viewId'>){const token=++serial,entry=capture(id,value);entries.set(id,{token,entry});publish();return Object.freeze({update(value:Omit<OverlayEntry,'id'|'viewId'>){if(entries.get(id)?.token!==token)return;const entry=capture(id,value),old=entries.get(id)!.entry;if(JSON.stringify(old)===JSON.stringify(entry))return;entries.set(id,{token,entry});publish();},release(){if(entries.get(id)?.token!==token)return;entries.delete(id);publish();}});},
visible(bounds:Rect,current:(target:Target)=>boolean){const ids=index.query(bounds);return snapshot.filter(entry=>current(entry.target)&&(entry.focused||entry.composing||ids.has(entry.id)));},
clear(){entries.clear();publish();},
});
}
export type OverlayIndex=ReturnType<typeof createOverlayIndex>;
+18
View File
@@ -0,0 +1,18 @@
import type {Id} from '../canvas-core/types.ts';
import type {Rect} from '../canvas-core/geometry.ts';
import {fault} from '../canvas-core/values.ts';
export type PaintRegion={readonly id:Id;readonly bounds:Rect};
const valid=(r:Rect)=>r&&[r.x,r.y,r.width,r.height,r.x+r.width,r.y+r.height].every(Number.isFinite)&&r.width>=0&&r.height>=0;
const intersects=(a:Rect,b:Rect)=>a.x<=b.x+b.width&&b.x<=a.x+a.width&&a.y<=b.y+b.height&&b.y<=a.y+a.height;
/** Rebuildable view-space grid. Oversized regions/queries fall back without false negatives. */
export function createPaintIndex(cellSize=512,maxCells=256){
if(!Number.isFinite(cellSize)||cellSize<=0||!Number.isSafeInteger(maxCells)||maxCells<1)throw fault('invalid_paint_index');
const rows=new Map<Id,Rect>(),cells=new Map<string,Set<Id>>(),members=new Map<Id,readonly string[]>(),overflow=new Set<Id>();
function keys(r:Rect){const x=Math.floor(r.x/cellSize),y=Math.floor(r.y/cellSize),right=Math.floor((r.x+r.width)/cellSize),bottom=Math.floor((r.y+r.height)/cellSize);if(![x,y,right,bottom].every(Number.isSafeInteger)||(right-x+1)*(bottom-y+1)>maxCells)return;const result:string[]=[];for(let a=x;a<=right;a++)for(let b=y;b<=bottom;b++)result.push(`${a},${b}`);return result;}
function remove(id:Id){for(const key of members.get(id)??[]){const bucket=cells.get(key)!;bucket.delete(id);if(!bucket.size)cells.delete(key);}members.delete(id);overflow.delete(id);rows.delete(id);}
return Object.freeze({
sync(regions:readonly PaintRegion[]){const incoming=new Map<Id,Rect>();for(const region of regions){if(!region.id||incoming.has(region.id)||!valid(region.bounds))throw fault('invalid_paint_region');incoming.set(region.id,{...region.bounds});}for(const id of rows.keys())if(!incoming.has(id))remove(id);for(const [id,r]of incoming){const old=rows.get(id);if(old&&old.x===r.x&&old.y===r.y&&old.width===r.width&&old.height===r.height)continue;remove(id);rows.set(id,r);const list=keys(r);if(!list){overflow.add(id);continue;}members.set(id,list);for(const key of list){let bucket=cells.get(key);if(!bucket)cells.set(key,bucket=new Set());bucket.add(id);}}},
query(region:Rect):ReadonlySet<Id>{if(!valid(region))throw fault('invalid_paint_region');const list=keys(region),candidates=list?new Set([...overflow,...list.flatMap(key=>[...cells.get(key)??[]])]):new Set(rows.keys());return new Set([...candidates].filter(id=>intersects(rows.get(id)!,region)));},
clear(){rows.clear();cells.clear();members.clear();overflow.clear();},
});
}
@@ -0,0 +1,5 @@
/** Transport ownership only: document geometry and gesture tokens stay in the editor. */
export type PointerCapturePort={setPointerCapture(id:number):void;hasPointerCapture(id:number):boolean;releasePointerCapture(id:number):void};
export function ownsPointerCapture(owner:{pointerId:number;capture:unknown}|null|undefined,input:{pointerId:number;type?:string;target?:unknown}){if(!owner||owner.pointerId!==input.pointerId)return false;if(input.type!=='lostpointercapture')return true;if(input.target!==owner.capture)return false;const capture=owner.capture as Partial<PointerCapturePort>|undefined;try{if(capture?.hasPointerCapture?.(owner.pointerId))return false;}catch{/* A detached matching target may still need cancellation. */}return true;}
export function acquirePointerCapture(target:PointerCapturePort,id:number){try{target.setPointerCapture(id);return true;}catch{return false;}}
export function releasePointerCapture(target:PointerCapturePort,id:number){try{if(target.hasPointerCapture(id))target.releasePointerCapture(id);}catch{/* Detached DOM or an already released platform capture has no editor authority. */}}
@@ -0,0 +1,60 @@
import {clone,equal} from '../canvas-core/values.ts';
import {rectangleGeometry} from '../canvas-core/geometry.ts';
import {toLocal,toWorld,type Transform} from '../canvas-core/coordinates.ts';
import type {ElementRecord,Geometry,GeometryProtocol,Registry} from '../canvas-core/types.ts';
import type {projectCanvas,ProjectionNode} from './projection.ts';
import {createPaintIndex,type PaintRegion} from './paint-index.ts';
/** Extra node paint extents beyond native unrotated React Flow bounds. */
export function projectedPaintOverflow(projection:ReturnType<typeof projectCanvas>,registry:Registry){
const regions:PaintRegion[]=[],unknown:string[]=[];
for(const node of projection.nodes)try{
const element=projection.document.elements.get(node.id),transform=element&&projection.transforms.get(element.ownerCanvasId);if(!element||element.structure!=='node'||!transform)throw Error('missing_geometry');
const width=node.width!/transform.scale,height=node.height!/transform.scale,center=toLocal(transform,{x:node.position.x+node.width!/2,y:node.position.y+node.height!/2}),geometry={x:center.x-width/2,y:center.y-height/2,width,height,rotation:((node.data.rotation??0)-transform.rotation+360)%360};
const definition=!node.data.__headlessFallback&&registry.resolve(element)==='known'?registry.components.get(element.componentId)?.geometry:undefined,protocol=definition?.kind==='node'?definition:rectangleGeometry;
const local=protocol.bounds({element,geometry,role:'paint'});if(![local.x,local.y,local.width,local.height].every(Number.isFinite)||local.width<0||local.height<0)throw Error('invalid_paint_bounds');
const points=[{x:local.x,y:local.y},{x:local.x+local.width,y:local.y},{x:local.x,y:local.y+local.height},{x:local.x+local.width,y:local.y+local.height}].map(p=>toWorld(transform,p)),x=Math.min(...points.map(p=>p.x)),y=Math.min(...points.map(p=>p.y)),right=Math.max(...points.map(p=>p.x)),bottom=Math.max(...points.map(p=>p.y));
if(![x,y,right,bottom,right-x,bottom-y].every(Number.isFinite))throw Error('invalid_paint_bounds');
if(x<node.position.x||y<node.position.y||right>node.position.x+node.width!||bottom>node.position.y+node.height!)regions.push({id:node.id,bounds:{x,y,width:right-x,height:bottom-y}});
}catch{unknown.push(node.id);}
return{regions,unknown};
}
type NodeProtocol=Extract<GeometryProtocol,{kind:'node'}>;
type PaintSignature={incarnation?:number;owner?:string;component?:string;version?:number;geometry?:Geometry;transform?:Transform;x:number;y:number;width?:number;height?:number;rotation:unknown;protocol:NodeProtocol;data:unknown};
const sameGeometry=(a:Geometry|undefined,b:Geometry|undefined)=>a===undefined?b===undefined:b!==undefined&&a.x===b.x&&a.y===b.y&&a.width===b.width&&a.height===b.height&&a.rotation===b.rotation;
const sameTransform=(a:Transform|undefined,b:Transform|undefined)=>a===undefined?b===undefined:b!==undefined&&a.x===b.x&&a.y===b.y&&a.scale===b.scale&&a.rotation===b.rotation;
function sameSignature(old:PaintSignature,node:ProjectionNode,element:ElementRecord|undefined,t:Transform|undefined,protocol:NodeProtocol){
if(old.incarnation!==element?.incarnation||old.owner!==element?.ownerCanvasId||old.component!==element?.componentId||old.version!==element?.dataVersion||old.protocol!==protocol||!sameGeometry(old.geometry,element?.structure==='node'?element.geometry:undefined)||!sameTransform(old.transform,t)||old.x!==node.position.x||old.y!==node.position.y||old.width!==node.width||old.height!==node.height||old.rotation!==node.data.rotation)return false;
const dependencies=protocol.dataDependencies;
if(dependencies===undefined)return equal(old.data,element?.data);
const values=old.data as unknown[];
for(let i=0;i<dependencies.length;i++)if(!equal(values[i],element?.data[dependencies[i]]))return false;
return true;
}
function captureSignature(node:ProjectionNode,element:ElementRecord|undefined,t:Transform|undefined,protocol:NodeProtocol):PaintSignature{
const dependencies=protocol.dataDependencies;
return{incarnation:element?.incarnation,owner:element?.ownerCanvasId,component:element?.componentId,version:element?.dataVersion,geometry:element?.structure==='node'?{...element.geometry}:undefined,transform:t?{...t}:undefined,x:node.position.x,y:node.position.y,width:node.width,height:node.height,rotation:node.data.rotation,protocol,data:dependencies===undefined?clone(element?.data):dependencies.map(key=>clone(element?.data[key]))};
}
/** View-owned incremental paint grid; unchanged rows retain their captured dependencies. */
export function createProjectedPaintIndex(){
let registryOwner:Registry|undefined,canvas:string|undefined,project:string|undefined,computed=0,captured=0;
type Projection=ReturnType<typeof projectCanvas>;
type Entry={signature:PaintSignature;result:ReturnType<typeof projectedPaintOverflow>};
const entries=new Map<string,Entry>(),index=createPaintIndex();
return Object.freeze({
sync(projection:Projection,registry:Registry){
if(registryOwner!==registry||canvas!==projection.canvasId||project!==projection.document.projectId){entries.clear();index.clear();registryOwner=registry;canvas=projection.canvasId;project=projection.document.projectId;}
const seen=new Set<string>(),regions:PaintRegion[]=[],unknown:string[]=[];
for(const node of projection.nodes){seen.add(node.id);const element=projection.document.elements.get(node.id),t=element&&projection.transforms.get(element.ownerCanvasId),old=entries.get(node.id),declared=element&&!node.data.__headlessFallback&&registry.resolve(element)==='known'?registry.components.get(element.componentId)?.geometry:undefined,protocol=declared?.kind==='node'?declared:rectangleGeometry;
let entry=old;
if(!entry||!sameSignature(entry.signature,node,element,t,protocol)){
const signature=captureSignature(node,element,t,protocol),result=projectedPaintOverflow({...projection,nodes:[node]},registry);captured++;computed++;entry={signature,result};entries.set(node.id,entry);
}
regions.push(...entry.result.regions);unknown.push(...entry.result.unknown);
}
for(const id of entries.keys())if(!seen.has(id))entries.delete(id);index.sync(regions);return Object.freeze({query:index.query,unknown:Object.freeze(unknown)});
},
stats:()=>Object.freeze({computed,captured,entries:entries.size}),
clear(){entries.clear();index.clear();registryOwner=undefined;canvas=undefined;project=undefined;},
});
}
@@ -0,0 +1,40 @@
import type {Document,Id} from '../canvas-core/types.ts';
import {equal,fault} from '../canvas-core/values.ts';
import type {projectCanvas} from './projection.ts';
type Projection=ReturnType<typeof projectCanvas>;
type Retained<T>={incarnation:number;ownerCanvasId:Id;value:T};
/** View-owned rendering cache, never a document store. Keep unchanged React Flow input identities. */
export function createProjectionCache(){
let scope:object|undefined,projectId:Id|undefined,canvasId:Id|undefined;
let nodeArray:Projection['nodes']|undefined,edgeArray:Projection['edges']|undefined;
const stable=<T>(previous:T[]|undefined,next:T[])=>previous?.length===next.length&&next.every((value,index)=>value===previous[index])?previous:next;
let nodes=new Map<Id,Retained<Projection['nodes'][number]>>(),edges=new Map<Id,Retained<Projection['edges'][number]>>();
function retain<T extends{id:Id}>(document:Document,values:T[],previous:Map<Id,Retained<T>>){
const next=new Map<Id,Retained<T>>();
for(let index=0;index<values.length;index++){
const value=values[index],record=document.elements.get(value.id);if(!record)throw fault('projection_identity_missing',[value.id]);
const old=previous.get(value.id),sameLifetime=old&&old.incarnation===record.incarnation&&old.ownerCanvasId===record.ownerCanvasId;
if(sameLifetime&&old.value===value){next.set(value.id,old);continue;}
let shared=value;
if(sameLifetime)for(const key of ['data','style','position'] as const){const incoming=(shared as Record<string,unknown>)[key],before=(old.value as Record<string,unknown>)[key];if(Object.hasOwn(shared,key)&&Object.hasOwn(old.value,key)&&!Object.is(incoming,before)&&equal(incoming,before))shared={...shared,[key]:before};}
const retained=sameLifetime&&equal(old.value,shared)?old.value:shared;
values[index]=retained;next.set(value.id,{incarnation:record.incarnation,ownerCanvasId:record.ownerCanvasId,value:retained});
}
return next;
}
return Object.freeze({
reuse(owner:object,document:Document,viewCanvasId:Id,projection:Projection):Projection{
if(scope!==owner||projectId!==document.projectId||canvasId!==viewCanvasId){nodes.clear();edges.clear();nodeArray=undefined;edgeArray=undefined;scope=owner;projectId=document.projectId;canvasId=viewCanvasId;}
const nextNodes=retain(document,projection.nodes,nodes),nextEdges=retain(document,projection.edges,edges);
nodes=nextNodes;edges=nextEdges;nodeArray=stable(nodeArray,projection.nodes);edgeArray=stable(edgeArray,projection.edges);
return{...projection,nodes:nodeArray,edges:edgeArray};
},
getNode(id:Id){return nodes.get(id)?.value;},
canRetainMeasurement(owner:object,document:Document,viewCanvasId:Id,value:Projection['nodes'][number]){
const old=nodes.get(value.id),record=document.elements.get(value.id);
return scope===owner&&projectId===document.projectId&&canvasId===viewCanvasId&&!!old&&!!record&&old.incarnation===record.incarnation&&old.ownerCanvasId===record.ownerCanvasId&&old.value.type===value.type&&old.value.width===value.width&&old.value.height===value.height&&equal(old.value.data,value.data);
},
clear(){nodes.clear();edges.clear();nodeArray=undefined;edgeArray=undefined;scope=undefined;projectId=undefined;canvasId=undefined;},
});
}
+97
View File
@@ -0,0 +1,97 @@
import type {DocumentQueries} from '../canvas-core/queries.ts';
import type {GeometryProjectionCache} from './geometry-cache.ts';
import type { Document, ElementRecord, Geometry, Id, Json, Point, Registry } from '../canvas-core/types.ts';
import { builtinRendererCatalog, type RendererCatalog } from './renderers.ts';
import { connectionPathValid } from '../canvas-core/features.ts';
import { unitScale } from '../canvas-core/units.ts';
import { fault } from '../canvas-core/values.ts';
import type {CanvasNodeData,EdgeData} from './compat-types.ts';
import { childCanvasTransform, toWorld, transformPath, type Transform } from '../canvas-core/coordinates.ts';
export { toWorld, toLocal, transformPath, type Transform } from '../canvas-core/coordinates.ts';
const origin:Transform={x:0,y:0,scale:1,rotation:0};
export type FallbackProjection = {reason:'unknown-component'|'unsupported-version'|'renderer-unavailable';componentId:string;dataVersion:number;handles:{id:string;side:'top'|'right'|'bottom'|'left'}[]};
export type ProjectionNode = {id:Id;type:string;position:Point;width?:number;height?:number;selected?:boolean;draggable?:boolean;zIndex?:number;measured?:{width?:number;height?:number};style?:{width?:number|string;height?:number|string;pointerEvents?:'none'|'auto'};data:CanvasNodeData&{__headlessFallback?:FallbackProjection;__headlessRecord?:ElementRecord;__headlessTransform?:Transform}};
export type ProjectionEdge={id:Id;type?:string;source:Id;target:Id;sourceHandle?:string|null;targetHandle?:string|null;selected?:boolean;zIndex?:number;data:EdgeData&{__headlessRecord?:ElementRecord;__headlessTransform?:Transform}};
const object = (value: Json | undefined) => value && typeof value === 'object' && !Array.isArray(value) ? value : {};
/** Construct one node. Scene updates and full traversal share exactly the same projection rules. */
export function projectNode(element:Extract<ElementRecord,{structure:'node'}>,canvasId:Id,registry:Registry,selected:{has(id:Id):boolean},geometry:Geometry|undefined,transform:Transform,depth:number,child:Id|undefined,renderers:RendererCatalog,cache?:GeometryProjectionCache):ProjectionNode{
const g = geometry ?? element.geometry, projectedGeometry=cache?.node(element.id,element.incarnation,g,transform),center = projectedGeometry?undefined:toWorld(transform, { x: g.x + g.width / 2, y: g.y + g.height / 2 });
const width = projectedGeometry?.width??g.width * transform.scale, height = projectedGeometry?.height??g.height * transform.scale, resolved=registry.resolve(element),known=resolved==='known';
const renderer=renderers.resolve(element);
const fallback:FallbackProjection|undefined=!known||!renderer?{reason:resolved==='known'?'renderer-unavailable':resolved,componentId:element.componentId,dataVersion:element.dataVersion,handles:[]}:undefined;
const type = fallback ? 'unsupported' : renderer!.rendererId;
const data = element.data;
const projected: ProjectionNode = { id: element.id, type, position: projectedGeometry?{...projectedGeometry.position}:{ x: center!.x - width / 2, y: center!.y - height / 2 }, width, height, selected: selected.has(element.id), draggable: known && registry.supports(element, 'geometry.move'), zIndex: element.componentId === 'frame' ? depth - 100 : depth * 2,
style: { width, height, ...(element.componentId === 'frame'&&!fallback&&renderer?.projection==='compat-v1' ? { pointerEvents: 'none' as const } : {}) },
data: { ...(fallback?{}:data), __headlessTarget:Object.freeze({id:element.id,incarnation:element.incarnation,ownerCanvasId:element.ownerCanvasId,canvasId:canvasId}), kind: element.componentId as CanvasNodeData['kind'], title: typeof data.title === 'string' ? data.title : element.componentId, text: !fallback&&typeof data.text === 'string' ? data.text : '', __headlessFallback:fallback, __headlessRecord:!fallback&&renderer?.projection==='record-v1'?element:undefined,__headlessTransform:!fallback&&renderer?.projection==='record-v1'?transform:undefined, rotation: projectedGeometry?.rotation??(g.rotation + transform.rotation) % 360, viewScale: transform.scale*(!fallback?unitScale(data.presentation):1), ...(child ? { subgraph: { boardId: child } } : {}) } as CanvasNodeData,
};
return projected;
}
/** Endpoints are already resolved to their displayed node IDs by the caller. */
export function projectEdge(element:Extract<ElementRecord,{structure:'connection'}>,source:Id,target:Id,registry:Registry,selected:{has(id:Id):boolean},transform:Transform,edgePreview:{target:{id:Id};path:Json}|null|undefined,renderers:RendererCatalog,cache?:GeometryProjectionCache):ProjectionEdge{
const renderer=renderers.resolve(element),fallbackEdge=registry.resolve(element)!=='known'||!renderer;
const path = fallbackEdge||renderer?.projection==='record-v1' ? undefined : (cache?cache.edge(element.id,element.incarnation,edgePreview?.target.id===element.id?edgePreview.path:element.path,transform):transformPath(edgePreview?.target.id === element.id ? edgePreview.path : element.path, transform));
const renderPath = path!==undefined&&connectionPathValid(path) ? path : undefined;
const { path: unusedPath, ...edgeData } = element.data; void unusedPath;
return { id: element.id, ...(fallbackEdge?{type:'unsupported'}:renderer!.rendererId==='default'?{}:{type:renderer!.rendererId}), source, target, sourceHandle: source === element.source.elementId ? element.source.portId : undefined, targetHandle: target === element.target.elementId ? element.target.portId : undefined, selected: selected.has(element.id), data: fallbackEdge?{}:renderer?.projection==='record-v1'?{__headlessRecord:element,__headlessTransform:transform}:{ ...edgeData, ...(renderPath ? { path: renderPath } : {}) } as EdgeData };
}
/** Renderer projection retains identity/owner in the model, including nested and collapsed canvases. */
export function projectCanvas(document: Document, canvasId: Id, registry: Registry, selected: ReadonlySet<Id> | { has(id: Id): boolean }, geometry: (id: Id) => Geometry | undefined, edgePreview?: { target: { id: Id }; path: Json } | null, renderers:RendererCatalog=builtinRendererCatalog,services:{queries?:DocumentQueries;geometry?:GeometryProjectionCache;owner?:object}={}) {
if (!document.canvases.has(canvasId)) throw fault('canvas_not_loaded', [canvasId]);
const queries=services.queries,cache=services.geometry;if(queries&&queries.document!==document)throw fault('stale_query_snapshot');if(queries&&queries.registry!==registry)throw fault('query_registry_changed');cache?.begin(services.owner??cache,document.projectId,canvasId);
const transforms = new Map<Id, Transform>([[canvasId, origin]]), hiddenBy = new Map<Id, Id>(), childByEntry = new Map<Id, Id>();
if(!queries)for (const canvas of document.canvases.values()) if (canvas.parent) childByEntry.set(canvas.parent.entryElementId, canvas.id);
const members = new Map<Id, ElementRecord[]>();
if(!queries)for (const element of document.elements.values()) { const list = members.get(element.ownerCanvasId) ?? []; list.push(element); members.set(element.ownerCanvasId, list); }
const getMembers=(id:Id)=>queries?queries.members(id).map(id=>document.elements.get(id)!):members.get(id)??[];
const nodes: ProjectionNode[] = [], visible = new Set<Id>();
const visit = (id: Id, depth: number) => {
const transform = transforms.get(id)!;
for (const element of getMembers(id)) if (element.structure === 'node') {
const g=geometry(element.id)??element.geometry,child=queries?queries.relations.childCanvasByEntry.get(element.id):childByEntry.get(element.id),data=element.data;
const projected=projectNode(element,canvasId,registry,selected,g,transform,depth,child,renderers,cache),fallback=projected.data.__headlessFallback;
nodes.push(projected); visible.add(element.id);
if (child) {
const presentation = object(data.presentation);
if (!!fallback || presentation.collapsed === true) {
const stack = [child]; while (stack.length) { const hidden = stack.pop()!; hiddenBy.set(hidden, element.id); if(queries)stack.push(...queries.childCanvases(hidden));else for (const canvas of document.canvases.values()) if (canvas.parent?.canvasId === hidden) stack.push(canvas.id); }
} else {
transforms.set(child, cache?cache.child(child,element.incarnation,g,transform,data.presentation):childCanvasTransform(transform,g,data.presentation)); visit(child, depth + 1);
}
}
}
};
visit(canvasId, 0);
const edges: ProjectionEdge[] = [];
for (const element of queries?queries.orderedElements([...transforms.keys()].flatMap(id=>queries.members(id).filter(id=>document.elements.get(id)?.structure==='connection'))).map(id=>document.elements.get(id)!):document.elements.values()) if (element.structure === 'connection' && transforms.has(element.ownerCanvasId)) {
const endpoint = (id: Id) => visible.has(id) ? id : hiddenBy.get(document.elements.get(id)?.ownerCanvasId ?? '');
const source = endpoint(element.source.elementId), target = endpoint(element.target.elementId);
if (!source || !target || source === target) continue;
edges.push(projectEdge(element,source,target,registry,selected,transforms.get(element.ownerCanvasId)!,edgePreview,renderers,cache));
}
// Handles come from known envelope endpoints, never from opaque component payloads.
const nodesById=new Map(nodes.map(node=>[node.id,node]));
const fallbackNodes=new Map(nodes.filter(node=>node.data.__headlessFallback).map(node=>[node.id,node.data.__headlessFallback!]));
const handleIds=new Map<Id,Set<string>>();
for(const [id,fallback] of fallbackNodes){fallback.handles=(['top','right','bottom','left'] as const).map(side=>({id:side,side}));handleIds.set(id,new Set(fallback.handles.map(handle=>handle.id)));}
for(const edge of edges)for(const end of ['source','target'] as const){
const fallback=fallbackNodes.get(edge[end]);
if(!fallback){
if(edge.type==='unsupported'&&!nodesById.get(edge[end])?.data.__headlessRecord){
const node=nodesById.get(edge[end]),field=end==='source'?'sourceHandle':'targetHandle',handle=edge[field];
if(!handleIds.has(edge[end])){const ids=new Set(['top','right','bottom','left']);if(Array.isArray(node?.data.ports))for(const port of node.data.ports)if(port&&typeof port.id==='string')ids.add(`port:${port.id}`);handleIds.set(edge[end],ids);}
if(handle&&!handleIds.get(edge[end])!.has(handle))edge[field]=undefined;
}
continue;
}
const field=end==='source'?'sourceHandle':'targetHandle',handle=edge[field]|| (end==='source'?'right':'left');edge[field]=handle;
const seen=handleIds.get(edge[end])!;if(!seen.has(handle)){seen.add(handle);fallback.handles.push({id:handle,side:end==='source'?'right':'left'});}
}
cache?.end();
return { document,canvasId,nodes, edges, transforms };
}
+11
View File
@@ -0,0 +1,11 @@
"use client";
/** Explicit framework boundary; consumers install the declared optional peers. */
export * from './react-renderers.ts';
export * from './renderer-context.ts';
export * from './react.ts';
import type {Node,Edge} from '@xyflow/react';
import type {ProjectionNode,ProjectionEdge} from './projection.ts';
/** Structural adapter types add framework-owned optional metadata, never model state. */
export type ReactFlowProjectionNode=Node<ProjectionNode['data'],string>&ProjectionNode;
export type ReactFlowProjectionEdge=Edge<ProjectionEdge['data']>&ProjectionEdge;
@@ -0,0 +1,27 @@
import {memo} from 'react';
import type { NodeTypes, EdgeTypes } from '@xyflow/react';
import type { ComponentDefinition, Registry } from '../canvas-core/types.ts';
import { fault } from '../canvas-core/values.ts';
import { createRendererCatalog, type RendererDescriptor, type RendererCatalog } from './renderers.ts';
export type ReactRendererDefinition=RendererDescriptor&({readonly structure:'node';readonly render:NodeTypes[string];readonly preview?:NodeTypes[string]}|{readonly structure:'connection';readonly render:EdgeTypes[string];readonly preview?:EdgeTypes[string]});
export type ReactRenderers={readonly catalog:RendererCatalog;readonly definitions:readonly ReactRendererDefinition[];readonly nodeTypes:NodeTypes;readonly edgeTypes:EdgeTypes;readonly previewNodeTypes:NodeTypes;readonly previewEdgeTypes:EdgeTypes};
export type CanvasRuntimeExtension={readonly components?:readonly ComponentDefinition[];readonly renderers?:readonly ReactRendererDefinition[]};
const memoizedRenderers=new WeakMap<object,NodeTypes[string]|EdgeTypes[string]>();
function compiledRenderer(render:NodeTypes[string]|EdgeTypes[string]):NodeTypes[string]|EdgeTypes[string]{
if((render as {$$typeof?:symbol}).$$typeof===Symbol.for('react.memo'))return render;
const key=render as object,previous=memoizedRenderers.get(key);if(previous)return previous;
const compiled=memo(render as NodeTypes[string]);memoizedRenderers.set(key,compiled);return compiled;
}
export function createReactRenderers(definitions:readonly ReactRendererDefinition[],registry?:Registry):ReactRenderers{
const catalog=createRendererCatalog(definitions),nodes:NodeTypes=Object.create(null),edges:EdgeTypes=Object.create(null),previewNodes:NodeTypes=Object.create(null),previewEdges:EdgeTypes=Object.create(null),captured:ReactRendererDefinition[]=[];
for(let index=0;index<definitions.length;index++){
const definition=definitions[index],descriptor=catalog.definitions[index],render=definition.render,preview=definition.preview,tag=render&&typeof render==='object'?(render as {$$typeof?:symbol}).$$typeof:undefined;
if(typeof render!=='function'&&![Symbol.for('react.memo'),Symbol.for('react.forward_ref'),Symbol.for('react.lazy')].includes(tag as symbol))throw fault('invalid_react_renderer');
const previewTag=preview&&typeof preview==='object'?(preview as {$$typeof?:symbol}).$$typeof:undefined;if(preview!==undefined&&typeof preview!=='function'&&![Symbol.for('react.memo'),Symbol.for('react.forward_ref'),Symbol.for('react.lazy')].includes(previewTag as symbol))throw fault('invalid_react_preview');
if(registry){const model=registry.components.get(descriptor.componentId);if(!model||model.dataVersion!==descriptor.dataVersion||model.structure!==descriptor.structure)throw fault('renderer_model_mismatch',[descriptor.componentId]);}
const table=descriptor.structure==='node'?nodes:edges,previews=descriptor.structure==='node'?previewNodes:previewEdges,compiled=compiledRenderer(render),compiledPreview=preview?compiledRenderer(preview):undefined;
if(Object.hasOwn(table,descriptor.rendererId)&&(table[descriptor.rendererId]!==compiled||previews[descriptor.rendererId]!==compiledPreview))throw fault('renderer_id_conflict',[descriptor.rendererId]);
Object.defineProperty(table,descriptor.rendererId,{value:compiled,enumerable:true,configurable:true});if(compiledPreview)Object.defineProperty(previews,descriptor.rendererId,{value:compiledPreview,enumerable:true,configurable:true});captured.push(Object.freeze({...descriptor,render,...(preview?{preview}:{})}) as ReactRendererDefinition);
}
return Object.freeze({catalog,definitions:Object.freeze(captured),nodeTypes:Object.freeze(nodes),edgeTypes:Object.freeze(edges),previewNodeTypes:Object.freeze(previewNodes),previewEdgeTypes:Object.freeze(previewEdges)});
}
+93
View File
@@ -0,0 +1,93 @@
"use client";
import {createContext,createElement,useCallback,useContext,useEffect,useMemo,useRef,useState,useSyncExternalStore,type ReactNode} from 'react';
import {createSelectorStore,type ExternalSource} from './selectors.ts';
import {createElementSources} from './element-sources.ts';
import {createSessionSources,type GestureSelection} from './session-sources.ts';
import {createRuntimeSources,type OperationView} from './runtime-sources.ts';
export type {OperationView} from './runtime-sources.ts';
import type {ProjectEngine} from '../canvas-runtime/workspace.ts';
import type {EngineSnapshot} from '../canvas-runtime/contracts.ts';
import type {Target,Id,Data,Geometry,ElementRecord,Availability,Result} from '../canvas-core/types.ts';
import type {ElementLookup} from '../canvas-core/queries.ts';
import {equal,fault} from '../canvas-core/values.ts';
import type {DisplayHandle} from '../canvas-runtime/resources.ts';
export type CanvasBinding={readonly engine:ProjectEngine;readonly viewId?:Id};
const CanvasContext=createContext<CanvasBinding|null>(null);
/** Borrows the engine and an already attached view. Unmount never closes the project. */
export function CanvasProvider({engine,viewId,children}:{engine:ProjectEngine;viewId?:Id;children?:ReactNode}){const value=useMemo(()=>Object.freeze({engine,viewId}),[engine,viewId]);return createElement(CanvasContext.Provider,{value},children);}
export function useCanvasBinding(){const binding=useContext(CanvasContext);if(!binding)throw fault('canvas_provider_missing');return binding;}
function useSelected<S,T>(source:ExternalSource<S>,selector:(snapshot:S)=>T,isEqual:(a:T,b:T)=>boolean=Object.is):T{
const committed=useRef<{value:T}|null>(null);
const store=useMemo(()=>createSelectorStore(source,(snapshot:S)=>{const selected=selector(snapshot);return committed.current&&isEqual(committed.current.value,selected)?committed.current.value:selected;},isEqual),[source,selector,isEqual]);
const value=useSyncExternalStore(store.subscribe,store.getSnapshot,store.getSnapshot);
useEffect(()=>{committed.current={value};},[value]);return value;
}
function useRootSource(){const {engine}=useCanvasBinding();return useMemo(()=>({getSnapshot:engine.getSnapshot,subscribe:(notify:()=>void)=>engine.getDurability().closed?()=>{}:engine.editor.subscribe(notify)}),[engine]);}
export function useCanvas<T>(selector:(snapshot:EngineSnapshot)=>T,isEqual:(a:T,b:T)=>boolean=Object.is):T{return useSelected(useRootSource(),selector,isEqual);}
export type ElementView=ElementLookup|{readonly status:'stale';readonly element:ElementRecord};
const elementSources=new WeakMap<ProjectEngine,ReturnType<typeof createElementSources>>();
function elementSourcesFor(engine:ProjectEngine){let sources=elementSources.get(engine);if(!sources){sources=createElementSources({getSnapshot:engine.getSnapshot,subscribe:notify=>engine.getDurability().closed?()=>{}:engine.editor.subscribe(notify),lookup:(root,id)=>engine.editor.getQueries(root.document).lookupElement(id)});elementSources.set(engine,sources);}return sources;}
export function useElement(ref:Id|Target):ElementView;
export function useElement<T>(ref:Id|Target,selector:(view:ElementView)=>T,isEqual?:(a:T,b:T)=>boolean):T;
export function useElement<T>(ref:Id|Target,selector?:(view:ElementView)=>T,isEqual?:(a:T,b:T)=>boolean):T|ElementView{
const {engine}=useCanvasBinding(),id=typeof ref==='string'?ref:ref.id,incarnation=typeof ref==='string'?undefined:ref.incarnation;
const source=useMemo(()=>elementSourcesFor(engine).forElement(id),[engine,id]);
return useSelected(source,useCallback((found:ElementLookup)=>{const view=found.status==='present'&&incarnation!==undefined&&found.element.incarnation!==incarnation?{status:'stale' as const,element:found.element}:found;return selector?selector(view):view;},[incarnation,selector]),(isEqual as ((a:T|ElementView,b:T|ElementView)=>boolean)|undefined)??(selector?Object.is:equal));
}
export type ActionOptions=Parameters<ProjectEngine['editor']['dispatch']>[1];
export function useActions(target:Target){const {engine}=useCanvasBinding(),{id,incarnation}=target;return useMemo(()=>{const ref=Object.freeze({id,incarnation});return Object.freeze({
rename:(title:string,options?:ActionOptions)=>engine.editor.dispatch({type:'element.data',target:ref,patch:{title}},options),
setData:(patch:Data,options?:ActionOptions)=>engine.editor.dispatch({type:'element.data',target:ref,patch},options),
resize:(patch:Partial<Geometry>,options?:ActionOptions)=>engine.editor.dispatch({type:'element.geometry',target:ref,patch},options),
remove:(options?:ActionOptions)=>engine.editor.dispatch({type:'element.remove',target:ref},options),
moveTo:(canvasId:Id,geometry?:Geometry,options?:ActionOptions)=>engine.editor.dispatch({type:'element.move',target:ref,canvasId,...(geometry?{geometry}:{})},options),
});},[engine,id,incarnation]);}
const sessionSources=new WeakMap<ProjectEngine,ReturnType<typeof createSessionSources>>();
function sessionSourcesFor(engine:ProjectEngine){let sources=sessionSources.get(engine);if(!sources){sources=createSessionSources({getSnapshot:engine.sessions.getSnapshot,subscribe:notify=>engine.getDurability().closed?()=>{}:engine.sessions.subscribe(notify)});sessionSources.set(engine,sources);}return sources;}
function pairedSource<A,B>(left:ExternalSource<A>,right:ExternalSource<B>):ExternalSource<readonly[A,B]>{
let previous:readonly[A,B]|undefined;
return{getSnapshot(){const a=left.getSnapshot(),b=right.getSnapshot();if(!previous||!Object.is(previous[0],a)||!Object.is(previous[1],b))previous=Object.freeze([a,b] as const);return previous;},subscribe(notify){const a=left.subscribe(notify);try{const b=right.subscribe(notify);return()=>{a();b();};}catch(error){a();throw error;}}};
}
function useView(){const binding=useCanvasBinding();if(!binding.viewId)throw fault('view_session_required');return {...binding,viewId:binding.viewId};}
function available(error:unknown):Availability{return{enabled:false,code:(error as {code?:string}).code??'binding_failed',targets:(error as {targets?:readonly Id[]}).targets??[]};}
export function useMode(target:Target){
const {engine,viewId}=useView(),{id,incarnation}=target;
const source=useMemo(()=>pairedSource(elementSourcesFor(engine).forElement(id),sessionSourcesFor(engine).mode(viewId,id)),[engine,viewId,id]);
const mode=useSelected(source,useCallback(([found,mode]:readonly[ElementLookup,string|null])=>found.status==='present'&&found.element.incarnation===incarnation?mode:null,[incarnation]));
const setMode=useCallback((mode:string|null):Availability=>{try{if(engine.getSnapshot().document.elements.get(id)?.incarnation!==incarnation)throw fault('stale_incarnation',[id]);engine.sessions.mode(viewId,id,mode);return{enabled:true};}catch(error){return available(error);}},[engine,viewId,id,incarnation]);
return useMemo(()=>Object.freeze({mode,setMode}),[mode,setMode]);
}
export function useGestureBindings(target:Target){
const {engine,viewId}=useView(),{id,incarnation}=target;
const source=useMemo(()=>pairedSource(elementSourcesFor(engine).forElement(id),sessionSourcesFor(engine).gesture(viewId,{id,incarnation})),[engine,viewId,id,incarnation]);
const state=useSelected(source,useCallback(([found,transient]:readonly[ElementLookup,GestureSelection])=>({geometry:found.status==='present'&&found.element.incarnation===incarnation&&found.element.structure==='node'?transient.preview??found.element.geometry:undefined,active:transient.active}),[incarnation]),equal);
const controls=useMemo(()=>{
let lease:number|null=null;
const owned=()=>lease!==null&&engine.sessions.getSnapshot().gesture?.token===lease&&engine.sessions.getSnapshot().gesture?.viewId===viewId&&engine.sessions.getSnapshot().gesture?.phase==='active';
return Object.freeze({begin():Availability{const result=engine.sessions.begin(viewId,[{id,incarnation}]);if(result.enabled)lease=engine.sessions.getSnapshot().gesture!.token;return result;},update(patch:Partial<Geometry>):Availability{return owned()?engine.sessions.update(viewId,{id,incarnation},patch):{enabled:false,code:'gesture_not_owned',targets:[id]};},commit():Result{if(!owned())return{status:'rejected',code:'gesture_not_owned',targets:[id]};try{return engine.sessions.commit(viewId);}finally{lease=null;}},cancel(){if(owned())engine.sessions.cancel('binding_canceled');lease=null;}});
},[engine,viewId,id,incarnation]);
useEffect(()=>()=>controls.cancel(),[controls]);return useMemo(()=>Object.freeze({...state,...controls}),[state,controls]);
}
const runtimeSources=new WeakMap<ProjectEngine,ReturnType<typeof createRuntimeSources>>();
function runtimeSourcesFor(engine:ProjectEngine){let sources=runtimeSources.get(engine);if(!sources){sources=createRuntimeSources({getSnapshot:engine.getSnapshot,subscribe:notify=>engine.getDurability().closed?()=>{}:engine.editor.subscribe(notify)});runtimeSources.set(engine,sources);}return sources;}
export function useOperation(id:Id):OperationView{const {engine}=useCanvasBinding(),source=useMemo(()=>runtimeSourcesFor(engine).operation(id),[engine,id]);return useSyncExternalStore(source.subscribe,source.getSnapshot,source.getSnapshot);}
type Durability=ReturnType<ProjectEngine['getDurability']>;
const syncDurability=(value:Durability)=>({dirty:value.rootRevision>value.durableRootRevision,error:value.error,closing:value.closing,closed:value.closed});
const inactiveOwner=(value:Durability)=>value.closing||value.closed;
function useDurability<T>(selector:(value:Durability)=>T,isEqual:(a:T,b:T)=>boolean=Object.is){const {engine}=useCanvasBinding();const source=useMemo(()=>({getSnapshot:engine.getDurability,subscribe:engine.subscribeDurability}),[engine]);return useSelected(source,selector,isEqual);}
export function useSyncStatus(canvasId?:Id){
const {engine}=useCanvasBinding(),source=useMemo(()=>runtimeSourcesFor(engine).counts(canvasId),[engine,canvasId]);
const durable=useDurability(syncDurability,equal),counts=useSyncExternalStore(source.subscribe,source.getSnapshot,source.getSnapshot);
const dirty=durable.dirty;
return useMemo(()=>Object.freeze({...counts,dirty,error:durable.error,status:durable.closed?'closed' as const:durable.error||counts.blocked?'blocked' as const:durable.closing?'closing' as const:dirty?'saving' as const:counts.pending?'syncing' as const:counts.loading?'loading' as const:'synced' as const}),[counts,dirty,durable.error,durable.closing,durable.closed]);
}
export function useAsset(id:Id){
const {engine}=useCanvasBinding(),inactive=useDurability(inactiveOwner);
const source=useMemo(()=>runtimeSourcesFor(engine).asset(id),[engine,id]),metadata=useSyncExternalStore(source.subscribe,source.getSnapshot,source.getSnapshot);
const key=JSON.stringify([metadata.record?.operationId,metadata.record?.retirement?.purgedAt,metadata.binding?.authority,metadata.binding?.digest]);
const [attempt,setAttempt]=useState(0),[state,setState]=useState<{engine:ProjectEngine;id:Id;key:string;status:'loading'|'ready'|'error';url?:string;error?:unknown}>();
useEffect(()=>{if(inactive){setState({engine,id,key,status:'error',error:fault('engine_closing')});return;}const controller=new AbortController();let handle:DisplayHandle|undefined;setState({engine,id,key,status:'loading'});void engine.displayResource(id,{signal:controller.signal}).then(value=>{if(controller.signal.aborted){value.release();return;}handle=value;setState({engine,id,key,status:'ready',url:value.url});},error=>{if(!controller.signal.aborted)setState({engine,id,key,status:'error',error});});return()=>{controller.abort();handle?.release();};},[engine,id,key,attempt,inactive]);
const retry=useCallback(()=>setAttempt(value=>value+1),[]);
return useMemo(()=>Object.freeze({...metadata,...(inactive?{status:'error' as const,url:undefined,error:fault('engine_closing')}:state?.engine===engine&&state.id===id&&state.key===key?{status:state.status,url:state.url,error:state.error}:{status:'loading' as const,url:undefined,error:undefined}),retry}),[metadata,state,engine,id,key,retry,inactive]);
}
@@ -0,0 +1,9 @@
"use client";
import { createContext, useContext } from 'react';
import type { Transaction } from '../canvas-core/editor.ts';
import type { Command, Result } from '../canvas-core/types.ts';
export type RendererCommandOptions={label?:string;groupId?:string};
export type CanvasRendererCommands={dispatch(command:Command,options?:RendererCommandOptions):Result;transaction(callback:(transaction:Transaction)=>unknown,options?:RendererCommandOptions):Result};
export const CanvasRendererCommandsContext=createContext<CanvasRendererCommands|null>(null);
/** Trusted installed renderers submit normal validated commands, never write a second model. */
export function useCanvasRendererCommands(){const commands=useContext(CanvasRendererCommandsContext);if(!commands)throw Error('Canvas renderer commands are unavailable');return commands;}
+22
View File
@@ -0,0 +1,22 @@
import type { ElementRecord,Registry } from '../canvas-core/types.ts';
import { fault } from '../canvas-core/values.ts';
export type RendererDescriptor={readonly componentId:string;readonly dataVersion:number;readonly structure:'node'|'connection';readonly rendererId:string;readonly projection?:'compat-v1'|'record-v1'};
export type RendererCatalog={readonly definitions:readonly RendererDescriptor[];resolve(element:Pick<ElementRecord,'componentId'|'dataVersion'|'structure'>):RendererDescriptor|undefined};
const key=(value:Pick<RendererDescriptor,'componentId'|'dataVersion'|'structure'>)=>JSON.stringify([value.componentId,value.dataVersion,value.structure]);
export function createRendererCatalog(definitions:readonly RendererDescriptor[]):RendererCatalog{
const entries=new Map<string,RendererDescriptor>();
for(const value of definitions){
if(!value||typeof value.componentId!=='string'||!value.componentId||!Number.isSafeInteger(value.dataVersion)||value.dataVersion<1||!['node','connection'].includes(value.structure)||typeof value.rendererId!=='string'||!value.rendererId||['unsupported','__proto__','prototype','constructor'].includes(value.rendererId)||value.projection!==undefined&&!['compat-v1','record-v1'].includes(value.projection))throw fault('invalid_renderer_definition');
const id=key(value);if(entries.has(id))throw fault('duplicate_renderer_definition',[value.componentId]);entries.set(id,Object.freeze({componentId:value.componentId,dataVersion:value.dataVersion,structure:value.structure,rendererId:value.rendererId,projection:value.projection??'record-v1'}));
}
return Object.freeze({definitions:Object.freeze([...entries.values()]),resolve:(element:Pick<ElementRecord,'componentId'|'dataVersion'|'structure'>)=>entries.get(key(element))});
}
export const builtinRendererDefinitions:readonly RendererDescriptor[]=Object.freeze([
...['text','image','script'].map(componentId=>Object.freeze({componentId,dataVersion:1,structure:'node' as const,rendererId:'material',projection:'compat-v1' as const})),
...Object.entries({'plain-text':'plainText','plain-image':'plainImage',frame:'frame',icon:'icon'}).map(([componentId,rendererId])=>Object.freeze({componentId,dataVersion:1,structure:'node' as const,rendererId,projection:'compat-v1' as const})),
Object.freeze({componentId:'connection',dataVersion:1,structure:'connection',rendererId:'default',projection:'compat-v1'}),
]);
export const builtinRendererCatalog=createRendererCatalog(builtinRendererDefinitions);
/** Installed renderers without a reference declaration wait for full context. */
export function createLoadingRendererCatalog(registry:Registry,catalog:RendererCatalog):RendererCatalog{return Object.freeze({definitions:catalog.definitions,resolve:(element:Parameters<RendererCatalog['resolve']>[0])=>registry.components.get(element.componentId)?.copy?catalog.resolve(element):undefined});}
+49
View File
@@ -0,0 +1,49 @@
import type { Id } from '../canvas-core/types.ts';
import { fault } from '../canvas-core/values.ts';
import type { DisplayHandle } from '../canvas-runtime/resources.ts';
type Record = { readonly resource: Id; readonly controller: AbortController; handle?: DisplayHandle; failed: boolean };
export type ResourceViewSource = { readonly resourceId: Id; readonly url: string };
/** A view owns subscriptions/leases, never a resource or document store. Retain
* unchanged nodes, publish each ready source independently, and fence late work.
*/
export function createResourceView(options: {
resolve(resource: Id, signal: AbortSignal): Promise<DisplayHandle>;
onChange(sources: Map<Id, ResourceViewSource>, failures: number): void;
onError?(error: unknown): void;
}) {
const records = new Map<Id, Record>(); let targets = new Map<Id, Id>(), disposed = false;
const report = (error: unknown) => { try { options.onError?.(error); } catch { /* Observer errors do not own leases. */ } };
const publish = () => { try { options.onChange(new Map([...records].flatMap(([id, record]) => record.handle ? [[id, {resourceId:record.resource,url:record.handle.url}] as [Id, ResourceViewSource]] : [])), [...records.values()].filter(record => record.failed).length); } catch (error) { report(error); } };
const releaseHandle = (handle: DisplayHandle) => { try { handle.release(); } catch (error) { report(error); } };
const release = (record: Record) => { record.controller.abort(); if(record.handle)releaseHandle(record.handle); };
function sync(next: readonly (readonly [Id, Id])[]) {
if (disposed) throw fault('resource_view_disposed');
const desired = new Map<Id, Id>();
for (const [id, resource] of next) {
if (typeof id !== 'string' || !id || typeof resource !== 'string' || !resource || desired.has(id)) throw fault('invalid_resource_view');
desired.set(id, resource);
}
targets = desired; let changed = false;
for (const [id, record] of records) if (desired.get(id) !== record.resource) { records.delete(id); release(record); changed = true; }
for (const [id, resource] of desired) if (!records.has(id)) {
const record: Record = { resource, controller: new AbortController(), failed: false }; records.set(id, record); changed = true;
void Promise.resolve().then(() => {
if (disposed || records.get(id) !== record) throw fault('display_aborted');
return options.resolve(resource, record.controller.signal);
}).then(handle => {
if (disposed || records.get(id) !== record) { releaseHandle(handle); return; }
record.handle = handle; publish();
}, error => {
if (disposed || records.get(id) !== record || record.controller.signal.aborted) return;
record.failed = true; publish(); report(error);
});
}
if (changed) publish();
}
return Object.freeze({
sync,
retry() { if (disposed) throw fault('resource_view_disposed'); for (const [id, record] of records) if (record.failed) { records.delete(id); release(record); } sync([...targets]); },
dispose() { if (disposed) return; disposed = true; for (const record of records.values()) release(record); records.clear(); targets.clear(); publish(); },
});
}
@@ -0,0 +1,79 @@
import type {Id} from '../canvas-core/types.ts';
import type {EngineSnapshot} from '../canvas-runtime/contracts.ts';
import type {OperationRecord,FrozenRequest} from '../canvas-runtime/ledger.ts';
import type {ResourceRecord} from '../canvas-runtime/resources.ts';
import type {SharedAssetBinding} from '../canvas-runtime/asset-depot.ts';
import {equal} from '../canvas-core/values.ts';
import type {ExternalSource} from './selectors.ts';
export type OperationView={readonly operation:OperationRecord;readonly request?:FrozenRequest}|undefined;
export type AssetMetadata={readonly record:ResourceRecord|undefined;readonly binding:SharedAssetBinding|undefined};
export type SyncCounts={readonly pending:number;readonly blocked:number;readonly unknown:number;readonly loading:boolean};
type Dependencies=readonly(object|boolean|undefined)[];
/** Read-only table projections: one upstream listener, no per-hook Root/table snapshots. */
export function createRuntimeSources(source:ExternalSource<EngineSnapshot>){
type Group={update(root:EngineSnapshot,notifications:Set<()=>void>):void};
const active=new Set<Group>(),identities=new WeakMap<object,number>();let nextIdentity=0,stop:(()=>void)|undefined;
function signature(values:Dependencies){return values.map(value=>{if(value===undefined)return'u';if(typeof value==='boolean')return value?'t':'f';let id=identities.get(value);if(id===undefined){id=++nextIdentity;identities.set(value,id);}return`o${id}`;}).join(':');}
function changed(){
if(!active.size)return;const root=source.getSnapshot(),notifications=new Set<()=>void>();
for(const group of active)group.update(root,notifications);
let failed=false,error:unknown;for(const notify of notifications)try{notify();}catch(caught){if(!failed){failed=true;error=caught;}}if(failed)throw error;
}
function group<K,V>(dependencies:(root:EngineSnapshot)=>Dependencies,read:(root:EngineSnapshot,key:K)=>V){
// Direct reads may precede notification, so read caches cannot replace observed baselines.
type Bucket={cached:V;cachedSignature:string;observed:V;listeners:Set<()=>void>};
const buckets=new Map<K,Bucket>(),failed=new Set<K>();let observedSignature:string|undefined;
function readCached(root:EngineSnapshot,key:K,bucket:Bucket,stamp:string){
if(bucket.cachedSignature!==stamp){const value=read(root,key);if(!equal(bucket.cached,value))bucket.cached=value;bucket.cachedSignature=stamp;}
return bucket.cached;
}
const owner:Group={update(root,notifications){
const stamp=signature(dependencies(root)),all=stamp!==observedSignature;if(!all&&!failed.size)return;observedSignature=stamp;
for(const key of all?buckets.keys():[...failed]){
const bucket=buckets.get(key);if(!bucket)continue;
try{const value=readCached(root,key,bucket,stamp);if(!failed.has(key)&&equal(bucket.observed,value))continue;bucket.observed=value;failed.delete(key);}catch{failed.add(key);}
for(const notify of bucket.listeners)notifications.add(notify);
}
}};
return{forKey(key:K):ExternalSource<V>{
let initialized=false,local:V,localSignature:string;
function getSnapshot(){
const root=source.getSnapshot(),stamp=signature(dependencies(root)),bucket=buckets.get(key);if(bucket)return readCached(root,key,bucket,stamp);
if(!initialized||stamp!==localSignature){const value=read(root,key);if(!initialized||!equal(local,value))local=value;localSignature=stamp;initialized=true;}
return local;
}
return Object.freeze({getSnapshot,subscribe(notify:()=>void){
let bucket=buckets.get(key);if(!bucket){const value=getSnapshot();if(!buckets.size)observedSignature=signature(dependencies(source.getSnapshot()));bucket={cached:value,cachedSignature:signature(dependencies(source.getSnapshot())),observed:value,listeners:new Set()};buckets.set(key,bucket);active.add(owner);}
const listener=()=>notify();bucket.listeners.add(listener);
const remove=()=>{bucket!.listeners.delete(listener);if(!bucket!.listeners.size){buckets.delete(key);failed.delete(key);}if(!buckets.size){active.delete(owner);observedSignature=undefined;}};
if(!stop)try{stop=source.subscribe(changed);}catch(error){remove();throw error;}
let live=true;return()=>{if(!live)return;live=false;remove();if(!active.size){stop?.();stop=undefined;}};
}});
}};
}
const operations=group<Id,OperationView>(root=>[root.runtime.ledger.operations,root.runtime.ledger.requests],(root,id)=>{const operation=root.runtime.ledger.operations.get(id);return operation?Object.freeze({operation,...(operation.requestId?{request:root.runtime.ledger.requests.get(operation.requestId)}:{})}):undefined;});
const assets=group<Id,AssetMetadata>(root=>[root.runtime.resources,root.runtime.assetBindings],(root,id)=>Object.freeze({record:root.runtime.resources.get(id),binding:root.runtime.assetBindings?.get(id)}));
let countStamp:string|undefined,projectCounts:SyncCounts,emptyCounts:SyncCounts,canvasCounts=new Map<Id,SyncCounts>();
const countDependencies=(root:EngineSnapshot):Dependencies=>[root.runtime.ledger.operations,root.runtime.ledger.requests,!!root.runtime.loading];
const counts=group<Id|undefined,SyncCounts>(countDependencies,(root,canvasId)=>{
const stamp=signature(countDependencies(root));
if(stamp!==countStamp){
const loading=!!root.runtime.loading,empty=()=>({pending:0,blocked:0,unknown:0,loading}),project=empty(),byCanvas=new Map<Id,ReturnType<typeof empty>>();
const scope=(id:Id)=>{let row=byCanvas.get(id);if(!row){row=empty();byCanvas.set(id,row);}return row;};
for(const operation of root.runtime.ledger.operations.values())if(operation.phase!=='resolved'){
const ids=operation.intent.canvasIds.length>1?new Set(operation.intent.canvasIds):operation.intent.canvasIds,blocked=operation.phase==='blocked'&&operation.blockers.some(reason=>reason!=='waiting_digest');project.pending++;if(blocked)project.blocked++;
for(const id of ids){const row=scope(id);row.pending++;if(blocked)row.blocked++;}
}
for(const request of root.runtime.ledger.requests.values())if(request.phase!=='resolved'){
let matched=false;const ids=new Set<Id>();for(const id of request.operationIds){const operation=root.runtime.ledger.operations.get(id);if(operation&&operation.phase!=='resolved'){matched=true;for(const canvas of operation.intent.canvasIds)ids.add(canvas);}}
if(!matched)continue;const blocked=!!request.error&&request.retryAt===undefined,unknown=request.outcome==='unresolved'&&['dispatch-marked','waiting'].includes(request.phase);
if(blocked)project.blocked++;if(unknown)project.unknown++;for(const id of ids){const row=scope(id);if(blocked)row.blocked++;if(unknown)row.unknown++;}
}
projectCounts=Object.freeze(project);emptyCounts=Object.freeze(empty());canvasCounts=new Map([...byCanvas].map(([id,row])=>[id,Object.freeze(row)]));countStamp=stamp;
}
return canvasId===undefined?projectCounts:canvasCounts.get(canvasId)??emptyCounts;
});
return Object.freeze({operation:operations.forKey,asset:assets.forKey,counts:(canvasId?:Id)=>counts.forKey(canvasId)});
}
+70
View File
@@ -0,0 +1,70 @@
import type {DocumentQueries} from '../canvas-core/queries.ts';
import type {Document,Id,Registry} from '../canvas-core/types.ts';
import type {SessionSnapshot,ViewState} from '../canvas-core/gestures.ts';
import {equal} from '../canvas-core/values.ts';
import {projectNode,projectEdge,type projectCanvas} from './projection.ts';
import type {RendererCatalog} from './renderers.ts';
type Projection=ReturnType<typeof projectCanvas>;
export type SceneChangeHint={from:Document;to:Document;ids:Set<Id>;topology:boolean};
type Preview=NonNullable<SessionSnapshot['gesture']>['preview']|undefined;
export type SceneInputs={owner:object;document:Document;registry:Registry;catalog:RendererCatalog;canvasId:Id;selection:ViewState['selection'];preview:Preview;edgeId:Id|undefined;path:NonNullable<SessionSnapshot['connectionGesture']>['path']|undefined};
export function indexScene(projection:Projection){
const nodes=new Map(projection.nodes.map((node,index)=>[node.id,index])),edges=new Map(projection.edges.map((edge,index)=>[edge.id,index])),entries=new Map<Id,Id>(),depths=new Map<Id,number>([[projection.canvasId,0]]);
for(const canvas of projection.document.canvases.values())if(canvas.parent)entries.set(canvas.parent.entryElementId,canvas.id);
for(const id of projection.transforms.keys())if(id!==projection.canvasId){const parent=projection.document.canvases.get(id)?.parent;if(parent&&depths.has(parent.canvasId))depths.set(id,depths.get(parent.canvasId)!+1);}
return{nodes,edges,entries,depths,unsupported:projection.nodes.some(node=>!!node.data.__headlessFallback)||projection.edges.some(edge=>edge.type==='unsupported')};
}
/** Reuse existing projections for checked field edits and ordinary node insertions. */
export function patchScene(base:Projection,layout:ReturnType<typeof indexScene>,next:SceneInputs,previous:SceneInputs,connection:SessionSnapshot['connectionGesture'],hint?:SceneChangeHint,queries?:DocumentQueries):Projection|undefined{
const {document,selection,preview,registry,catalog,canvasId}=next;
const {selection:oldSelection,preview:oldPreview,edgeId:oldEdgeId,path:oldPath}=previous;
if(layout.unsupported||document.projectId!==base.document.projectId)return;
const dirty=new Set<Id>(),created=new Set<Id>();
if(document!==base.document){
if(hint?.from===base.document&&hint.to===document){if(hint.topology)return;for(const id of hint.ids)dirty.add(id);}
else{
// Canonical replacement and missed publications carry no trustworthy write set.
if(document.canvases.size!==base.document.canvases.size)return;
const canvasOrder=base.document.canvases.keys();
for(const[id,canvas]of document.canvases)if(id!==canvasOrder.next().value||!equal(canvas,base.document.canvases.get(id)))return;
if(document.elements.size!==base.document.elements.size)return;
const elementOrder=base.document.elements.keys();
for(const[id,record]of document.elements){if(id!==elementOrder.next().value)return;if(record!==base.document.elements.get(id)&&!equal(record,base.document.elements.get(id)))dirty.add(id);}
}
}
if(selection!==oldSelection){for(const id of oldSelection)if(!selection.has(id))dirty.add(id);for(const id of selection)if(!oldSelection.has(id))dirty.add(id);}
if(preview!==oldPreview){for(const[id,g]of oldPreview??[])if(!equal(g,preview?.get(id)))dirty.add(id);for(const[id,g]of preview??[])if(!equal(g,oldPreview?.get(id)))dirty.add(id);}
if(connection?.target.id!==oldEdgeId||connection?.path!==oldPath){if(oldEdgeId)dirty.add(oldEdgeId);if(connection)dirty.add(connection.target.id);}
// Validate the whole dirty footprint before constructing or publishing any replacement.
for(const id of dirty){const before=base.document.elements.get(id),after=document.elements.get(id);
if(!before&&after?.structure==='node'){created.add(id);continue;}
if(!before||!after||before.incarnation!==after.incarnation||before.structure!==after.structure||before.componentId!==after.componentId||before.dataVersion!==after.dataVersion||before.ownerCanvasId!==after.ownerCanvasId)return;
if(before.structure==='connection'&&after.structure==='connection'&&(!equal(before.source,after.source)||!equal(before.target,after.target)))return;
if(before.structure==='node'&&after.structure==='node'&&layout.entries.has(id)&&(!equal(before.geometry,after.geometry)||!equal(before.data.presentation,after.data.presentation)||!equal(oldPreview?.get(id),preview?.get(id))))return;
}
let nodes=base.nodes,edges=base.edges;const inserted=new Map<Id,Projection['nodes'][number]>();
for(const id of dirty){const element=document.elements.get(id)!;
if(element.structure==='node'){
const index=layout.nodes.get(id);if(index===undefined&&!created.has(id))continue;
if(!base.transforms.has(element.ownerCanvasId))continue;
const transform=base.transforms.get(element.ownerCanvasId)!,depth=layout.depths.get(element.ownerCanvasId)!;
const node=projectNode(element,canvasId,registry,selection,preview?.get(id),transform,depth,layout.entries.get(id),catalog);
if(created.has(id)){if(node.data.__headlessFallback)return;inserted.set(id,node);}
else{if(nodes===base.nodes)nodes=[...nodes];nodes[index!]=node;}
}else{
const index=layout.edges.get(id);if(index===undefined)continue;const prior=base.edges[index];
const edge=projectEdge(element,prior.source,prior.target,registry,selection,base.transforms.get(element.ownerCanvasId)!,connection,catalog);
if(edges===base.edges)edges=[...edges];edges[index]=edge;
}
}
if(inserted.size){
// Membership order is depth-first, not global insertion order. Reorder references
// without reconstructing unaffected nodes, transforms or edge projections.
const members=new Map<Id,Id[]>();if(!queries)for(const element of document.elements.values()){const list=members.get(element.ownerCanvasId)??[];list.push(element.id);members.set(element.ownerCanvasId,list);}
const ordered:Projection['nodes']=[];let complete=true;
const visit=(owner:Id)=>{for(const id of queries?queries.members(owner):members.get(owner)??[]){const element=document.elements.get(id)!;if(element.structure!=='node')continue;const projected=inserted.get(id)??nodes[layout.nodes.get(id)!];if(!projected){complete=false;return;}ordered.push(projected);const child=layout.entries.get(id);if(child&&base.transforms.has(child))visit(child);}};
visit(canvasId);if(!complete)return;nodes=ordered;
}
return nodes===base.nodes&&edges===base.edges&&document===base.document?base:{...base,document,nodes,edges};
}
@@ -0,0 +1,40 @@
import {fault} from '../canvas-core/values.ts';
import type {Change,Document,Registry} from '../canvas-core/types.ts';
import type {SessionSnapshot,ViewState} from '../canvas-core/gestures.ts';
import type {DocumentQueries} from '../canvas-core/queries.ts';
import {createGeometryProjectionCache} from './geometry-cache.ts';
import {projectCanvas} from './projection.ts';
import {indexScene,patchScene,type SceneChangeHint,type SceneInputs} from './scene-changes.ts';
import type {RendererCatalog} from './renderers.ts';
/** One view's derived scene. Viewport/tool/mode and task status do not change scene inputs. */
export function createSceneProjection(){
let geometry=createGeometryProjectionCache();
let previous:SceneInputs|undefined,value:ReturnType<typeof projectCanvas>|undefined,layout:ReturnType<typeof indexScene>|undefined,pending:SceneChangeHint|undefined;
return Object.freeze({
advance(before:Document,after:Document,changes:readonly Change[]){
if(before===after)return;
if(!previous||previous.document===after){pending=undefined;return;}
if(pending?.to!==before){pending=previous.document===before?{from:before,to:after,ids:new Set(),topology:false}:undefined;}
if(!pending)return;pending.to=after;
if(!changes.length){pending=undefined;return;}
for(const change of changes){if(change.kind==='canvas'){pending.topology=true;break;}pending.ids.add(change.kind==='element'?change.id:change.target.id);if(pending.ids.size>16384){pending.topology=true;break;}}
if(pending.topology)pending.ids.clear();
},
project(owner:object,document:Document,view:ViewState,sessions:SessionSnapshot,registry:Registry,catalog:RendererCatalog,queries?:DocumentQueries){
if(queries&&queries.document!==document)throw fault('stale_query_snapshot');
if(queries&&queries.registry!==registry)throw fault('query_registry_changed');
const preview=sessions.gesture?.preview,connection=sessions.connectionGesture;
const next:SceneInputs={owner,document,registry,catalog,canvasId:view.canvasId,selection:view.selection,preview:preview?.size?preview:undefined,edgeId:connection?.target.id,path:connection?.path};
if(previous&&Object.keys(next).every(key=>Object.is(next[key as keyof SceneInputs],previous![key as keyof SceneInputs])))return value!;
// Read the captured session, not a newer live gesture during an older React render.
const compatible=previous&&value&&layout&&previous.owner===owner&&previous.registry===registry&&previous.catalog===catalog&&previous.canvasId===view.canvasId;
const patched=compatible?patchScene(value!,layout!,next,previous!,connection,pending,queries):undefined;
const result=patched??projectCanvas(document,view.canvasId,registry,view.selection,id=>next.preview?.get(id),connection,catalog,{queries,geometry,owner});
if(!patched||result.nodes.length!==value!.nodes.length)layout=indexScene(result);
previous=next;value=result;pending=undefined;return result;
},
clear(){previous=undefined;value=undefined;layout=undefined;pending=undefined;geometry=createGeometryProjectionCache();},
});
}
+11
View File
@@ -0,0 +1,11 @@
/** One immutable source, one cached selection; no second document or operation state. */
export type ExternalSource<S>={getSnapshot():S;subscribe(notify:()=>void):()=>void};
export function createSelectorStore<S,T>(source:ExternalSource<S>,selector:(snapshot:S)=>T,isEqual:(a:T,b:T)=>boolean=Object.is){
let initialized=false,snapshot:S,selection:T;
function getSnapshot():T{
const current=source.getSnapshot();if(initialized&&Object.is(snapshot,current))return selection;
const next=selector(current),same=initialized&&isEqual(selection,next);
snapshot=current;if(!same)selection=next;initialized=true;return selection;
}
return Object.freeze({getSnapshot,subscribe(notify:()=>void){let observed=getSnapshot();return source.subscribe(()=>{let next:T;try{next=getSnapshot();}catch{notify();return;}if(!Object.is(observed,next)){observed=next;notify();}});}});
}
+16
View File
@@ -0,0 +1,16 @@
import type {SessionSnapshot} from '../canvas-core/gestures.ts';
import type {ExternalSource} from './selectors.ts';
import {createFrameQueue,type FrameClock} from './frame-queue.ts';
/** One subscription owns one disposable frame queue. Reads always see current sessions. */
export function createSessionFrameSource(source:ExternalSource<SessionSnapshot>,clock:FrameClock):ExternalSource<SessionSnapshot>{
return Object.freeze({getSnapshot:source.getSnapshot,subscribe(notify:()=>void){
let previous=source.getSnapshot();const queue=createFrameQueue<string>(clock,()=>notify());
const stop=source.subscribe(()=>{const next=source.getSnapshot(),dirty=new Set<string>();
for(const [id,view]of previous.views)if(next.views.get(id)!==view)dirty.add(`view:${id}`);
for(const [id,view]of next.views)if(previous.views.get(id)!==view)dirty.add(`view:${id}`);
if(previous.gesture!==next.gesture)for(const state of [previous.gesture,next.gesture])for(const target of state?.targets??[])dirty.add(`element:${target.id}`);
if(previous.connectionGesture!==next.connectionGesture)for(const state of [previous.connectionGesture,next.connectionGesture])if(state)dirty.add(`element:${state.target.id}`);
previous=next;queue.invalidate(dirty);
});return()=>{stop();queue.dispose();};
}});
}
@@ -0,0 +1,54 @@
import type {Id,Target,Geometry} from '../canvas-core/types.ts';
import type {SessionSnapshot,GestureState} from '../canvas-core/gestures.ts';
import {equal} from '../canvas-core/values.ts';
import type {ExternalSource} from './selectors.ts';
export type GestureSelection={readonly preview:Geometry|undefined;readonly active:boolean};
/** Derived view/target subscriptions. Values retain no document or full per-hook session. */
export function createSessionSources(source:ExternalSource<SessionSnapshot>){
type Observer={update(snapshot:SessionSnapshot):boolean;notify():void};
const modes=new Map<Id,Map<Id,Set<Observer>>>(),gestures=new Map<Id,Set<Observer>>();
const targetIndexes=new WeakMap<readonly Target[],ReadonlyMap<Id,number>>();
let stop:(()=>void)|undefined,previous:SessionSnapshot|undefined;
function targets(gesture:GestureState){let index=targetIndexes.get(gesture.targets);if(!index){index=new Map(gesture.targets.map(target=>[target.id,target.incarnation]));targetIndexes.set(gesture.targets,index);}return index;}
function changed(){
if(!previous)return;const before=previous,next=source.getSnapshot();previous=next;
const candidates=new Set<Observer>(),add=(items:Set<Observer>|undefined)=>{for(const item of items??[])candidates.add(item);};
if(before.views!==next.views)for(const[viewId,byId]of modes){
const old=before.views.get(viewId)?.modes,current=next.views.get(viewId)?.modes;if(old===current)continue;
for(const id of new Set([...(old?.keys()??[]),...(current?.keys()??[])]))if(old?.get(id)!==current?.get(id))add(byId.get(id));
}
const old=before.gesture,current=next.gesture;
if(old!==current){
const ids=new Set<Id>();
if(!old||!current||old.token!==current.token||old.viewId!==current.viewId||old.targets!==current.targets){for(const gesture of [old,current])for(const target of gesture?.targets??[])ids.add(target.id);}
else if(old.preview!==current.preview){for(const[id,value]of old.preview)if(current.preview.get(id)!==value)ids.add(id);for(const[id,value]of current.preview)if(old.preview.get(id)!==value)ids.add(id);}
for(const id of ids)add(gestures.get(id));
}
const notifications:Observer[]=[];for(const observer of candidates)if(observer.update(next))notifications.push(observer);
let failed=false,error:unknown;for(const observer of notifications)try{observer.notify();}catch(caught){if(!failed){failed=true;error=caught;}}if(failed)throw error;
}
function channel<T>(read:(snapshot:SessionSnapshot)=>T,register:(observer:Observer)=>()=>void):ExternalSource<T>{
let initialized=false,value:T;
return Object.freeze({
getSnapshot(){const next=read(source.getSnapshot());if(!initialized||!equal(value,next))value=next;initialized=true;return value;},
subscribe(notify:()=>void){
let observed=read(source.getSnapshot()),failed=false;
const observer:Observer={notify,update(snapshot){try{const next=read(snapshot);if(!failed&&equal(observed,next))return false;observed=next;failed=false;}catch{failed=true;}return true;}};
const remove=register(observer);
if(!stop){previous=source.getSnapshot();try{stop=source.subscribe(changed);}catch(error){remove();previous=undefined;throw error;}}
let active=true;return()=>{if(!active)return;active=false;remove();if(!modes.size&&!gestures.size){stop?.();stop=undefined;previous=undefined;}};
},
});
}
return Object.freeze({
mode(viewId:Id,id:Id):ExternalSource<string|null>{return channel(snapshot=>snapshot.views.get(viewId)?.modes.get(id)??null,observer=>{
let byId=modes.get(viewId);if(!byId){byId=new Map();modes.set(viewId,byId);}let bucket=byId.get(id);if(!bucket){bucket=new Set();byId.set(id,bucket);}bucket.add(observer);
return()=>{bucket!.delete(observer);if(!bucket!.size)byId!.delete(id);if(!byId!.size)modes.delete(viewId);};
});},
gesture(viewId:Id,target:Target):ExternalSource<GestureSelection>{const{id,incarnation}=target;return channel(snapshot=>{
const gesture=snapshot.gesture,matched=!!gesture&&targets(gesture).get(id)===incarnation;
return{preview:matched?gesture!.preview.get(id):undefined,active:matched&&gesture!.viewId===viewId};
},observer=>{let bucket=gestures.get(id);if(!bucket){bucket=new Set();gestures.set(id,bucket);}bucket.add(observer);return()=>{bucket!.delete(observer);if(!bucket!.size)gestures.delete(id);};});},
});
}
+3
View File
@@ -0,0 +1,3 @@
import type {Rect} from '../canvas-core/geometry.ts';
/** Positive-area native node visibility. Degenerate nodes are retained for measurement. */
export function nativeRectVisible(node:Rect,viewport:Rect){if(node.width<=0||node.height<=0)return true;return Math.min(node.x+node.width,viewport.x+viewport.width)>Math.max(node.x,viewport.x)&&Math.min(node.y+node.height,viewport.y+viewport.height)>Math.max(node.y,viewport.y);}
+51
View File
@@ -0,0 +1,51 @@
import type { Document, Geometry, Id, Json, Point } from './types.ts';
import { ancestors } from './document.ts';
import { fault } from './values.ts';
import { closeNumber, closeScale, unitScale } from './units.ts';
import { connectionPathValid } from './features.ts';
export type Transform = { x: number; y: number; scale: number; rotation: number };
export const sameTransform=(a:Transform,b:Transform)=>closeNumber(a.x,b.x)&&closeNumber(a.y,b.y)&&closeScale(a.scale,b.scale)&&Math.abs(((a.rotation-b.rotation+540)%360)-180)<1e-9;
const origin: Transform = { x: 0, y: 0, scale: 1, rotation: 0 };
const rotate = (point: Point, angle: number): Point => { const a = angle * Math.PI / 180; return { x: point.x * Math.cos(a) - point.y * Math.sin(a), y: point.x * Math.sin(a) + point.y * Math.cos(a) }; };
export function toWorld(transform: Transform, point: Point): Point { const value = rotate(point, transform.rotation); return { x: transform.x + value.x * transform.scale, y: transform.y + value.y * transform.scale }; }
export function toLocal(transform: Transform, point: Point): Point { return rotate({ x: (point.x - transform.x) / transform.scale, y: (point.y - transform.y) / transform.scale }, -transform.rotation); }
const object = (value: Json | undefined) => value && typeof value === 'object' && !Array.isArray(value) ? value : {};
export function transformPath(path: Json, transform: Transform, inverse = false): Json {
const raw = object(path);
if (raw.type !== 'bezier-chain' || !Array.isArray(raw.knots)) return path;
const point = (input: Json | undefined, vector = false): Json | undefined => {
const p = object(input); if (typeof p.x !== 'number' || typeof p.y !== 'number') return input;
const t = vector ? { ...transform, x: 0, y: 0 } : transform;
return { ...p, ...(inverse ? toLocal(t, { x: p.x, y: p.y }) : toWorld(t, { x: p.x, y: p.y })) };
};
return { ...raw, ...(raw.startControl ? { startControl: point(raw.startControl, true)! } : {}), ...(raw.endControl ? { endControl: point(raw.endControl, true)! } : {}), knots: raw.knots.map(knot => {
const k = object(knot); return { ...k, position: point(k.position)!, in: point(k.in, true)!, out: point(k.out, true)! };
}) };
}
/** Format-1 child content origin: the same inset, scale and rotation used by renderer projection. */
export function childCanvasTransform(parent:Transform,geometry:Geometry,presentation:Json|undefined):Transform {
const data=object(presentation),units=unitScale(presentation),scale=(typeof data.contentScale==='number'&&data.contentScale>0?data.contentScale:1)*units;
const offset=rotate({x:24*units-geometry.width/2,y:36*units-geometry.height/2},geometry.rotation);
const start=toWorld(parent,{x:geometry.x+geometry.width/2+offset.x,y:geometry.y+geometry.height/2+offset.y});
return{...start,scale:parent.scale*scale,rotation:((parent.rotation+geometry.rotation)%360+360)%360};
}
export function canvasTransform(document:Document,canvasId:Id):Transform {
let result:Transform={...origin};
for(const id of ancestors(document,canvasId).reverse()){
const parent=document.canvases.get(id)!.parent;if(!parent)continue;
const entry=document.elements.get(parent.entryElementId);if(!entry||entry.structure!=='node')throw fault('invalid_canvas_entry',[id]);
result=childCanvasTransform(result,entry.geometry,entry.data.presentation);
}
return result;
}
export function convertGeometry(geometry:Geometry,from:Transform,to:Transform):Geometry {
if(sameTransform(from,to))return geometry;
const center=toLocal(to,toWorld(from,{x:geometry.x+geometry.width/2,y:geometry.y+geometry.height/2})),ratio=from.scale/to.scale,width=geometry.width*ratio,height=geometry.height*ratio;
return{x:center.x-width/2,y:center.y-height/2,width,height,rotation:((geometry.rotation+from.rotation-to.rotation)%360+360)%360};
}
export function convertPath(path:Json,from:Transform,to:Transform):Json {
if(path===null||sameTransform(from,to))return path;
if(!connectionPathValid(path))throw fault('unsupported_coordinate_path');
return transformPath(transformPath(path,from),to,true);
}
+28
View File
@@ -0,0 +1,28 @@
import type { Json } from './types.ts';
import { fault } from './values.ts';
/** Own JSON fields only. A * segment enumerates an array, never object keys. */
export function* copySlots(data: Json, path: readonly string[]): Generator<{parent: Record<string, Json>; key: string}> {
const work: {value: Json; depth: number}[] = [{value: data, depth: 0}];
while (work.length) {
const {value, depth} = work.pop()!, key = path[depth];
if (key === '*') {
if (!Array.isArray(value)) throw fault('invalid_copy_reference');
if (depth === path.length - 1) { for (let i = 0; i < value.length; i++) yield {parent: value as unknown as Record<string, Json>, key: String(i)}; }
else for (let i = value.length - 1; i >= 0; i--) work.push({value: value[i], depth: depth + 1});
} else if (value && typeof value === 'object' && Object.hasOwn(value, key)) {
if (depth === path.length - 1) yield {parent: value as Record<string, Json>, key};
else work.push({value: (value as Record<string, Json>)[key], depth: depth + 1});
}
}
}
export function* copyReferenceIds(data: Json, ref: {path: readonly string[]; many?: boolean}): Generator<string> {
for (const {parent, key} of copySlots(data, ref.path)) {
const value = parent[key]; if (value === null) continue;
if (ref.many && !Array.isArray(value)) throw fault('invalid_copy_reference');
for (const id of ref.many ? value as Json[] : [value]) {
if (typeof id !== 'string' || !id) throw fault('invalid_copy_reference');
yield id;
}
}
}
+31
View File
@@ -0,0 +1,31 @@
import {detachElementData} from './element-data.ts';
import type {CanvasRecord,Change,Document,ElementRecord,Id} from './types.ts';
import {applyChanges} from './document.ts';
import {clone,fault,freeze,readMap,validJson} from './values.ts';
/** Transaction-local append views. Each retained view has fixed membership;
* at most 64 additions are copied before folding into a new read-only base. */
const overlays=new WeakMap<object,{base:ReadonlyMap<Id,unknown>;added:ReadonlyMap<Id,unknown>}>(),drafts=new WeakSet<Document>();
function append<T>(input:ReadonlyMap<Id,T>,id:Id,value:T):ReadonlyMap<Id,T>{
if(input.has(id))throw fault('duplicate_identity',[id]);
let previous=overlays.get(input) as {base:ReadonlyMap<Id,T>;added:ReadonlyMap<Id,T>}|undefined;
if(previous&&previous.added.size>=64)previous=undefined;
const base=previous?.base??(overlays.has(input)?readMap(input):input),added=new Map(previous?.added);
added.set(id,value);
const entries=function*():Generator<[Id,T],undefined,unknown>{yield* base;yield* added;return undefined;};
const view:ReadonlyMap<Id,T>=Object.freeze({size:base.size+added.size,get:(key:Id)=>added.has(key)?added.get(key):base.get(key),has:(key:Id)=>added.has(key)||base.has(key),entries,
*keys():Generator<Id,undefined,unknown>{for(const [key]of entries())yield key;return undefined;},*values():Generator<T,undefined,unknown>{for(const [,item]of entries())yield item;return undefined;},[Symbol.iterator]:entries,
forEach(callback:(value:T,key:Id,map:ReadonlyMap<Id,T>)=>void,thisArg?:unknown){for(const[key,item]of entries())callback.call(thisArg,item,key,view);},
});
overlays.set(view,{base,added});return view;
}
/** Internal editor path: input tables already have fixed membership. */
export function stageCreation(document:Document,change:Extract<Change,{kind:'canvas'|'element'}>):Document{
if(change.before!==null||!change.after)throw fault('invalid_creation_draft');
if(!validJson(change.after))throw fault(change.kind==='canvas'?'invalid_canvas':'invalid_element',[change.id]);
if(change.after.id!==change.id)throw fault('identity_mismatch',[change.id]);
const value=change.kind==='element'?detachElementData(change.after as ElementRecord):freeze(clone(change.after));
const next=Object.freeze({...document,...(change.kind==='canvas'?{canvases:append(document.canvases,change.id,value as CanvasRecord)}:{elements:append(document.elements,change.id,value as ElementRecord)})});drafts.add(next);return next;
}
/** Only published documents use the core's owned table envelopes. */
export const materializeCreations=(document:Document)=>drafts.has(document)?applyChanges(document,[]):document;
+100
View File
@@ -0,0 +1,100 @@
import type { Change, CreationSizeEffect, Document, ElementRecord, Geometry, HistoryState, Id, Registry, Target } from './types.ts';
import { applyChanges, invert, validateDocument } from './document.ts';
import { closeNumber, unitScale } from './units.ts';
import { equal, fault, freeze, readMap, validJson } from './values.ts';
export function validateCreationSizeEffect(value: unknown): asserts value is CreationSizeEffect {
const effect = value as CreationSizeEffect | undefined;
if (!effect || effect.version !== 1 || effect.kind !== 'creation-size') throw fault('history_effect_interpreter_unavailable');
if (!validJson(effect) || typeof effect.entryId !== 'string' || !effect.entryId || typeof effect.target?.id !== 'string' || !effect.target.id
|| !Number.isSafeInteger(effect.target.incarnation) || effect.target.incarnation < 1
|| !effect.size || ![effect.size.width, effect.size.height].every(value => typeof value === 'number' && Number.isFinite(value) && value > 0)) throw fault('invalid_creation_size_effect');
}
/** A pure proposal, not a publication API. The coordinator must still validate a
* durable sizing grant and record a recoverable history effect before adopting it.
* Already-issued operation bodies are deliberately outside this function.
*/
export function planCreationSize(document: Document, history: HistoryState, registry: Registry,
entryId: Id, target: Target, size: { readonly width: number; readonly height: number }) {
const fail = (code: string): never => { throw fault(code, [target.id]); };
if (![size.width, size.height].every(value => Number.isFinite(value) && value > 0)) fail('invalid_creation_size');
const index = history.undo.indexOf(entryId), entry = history.entries.get(entryId);
const current = document.elements.get(target.id);
if (index < 0 || !entry || current?.incarnation !== target.incarnation || current.structure !== 'node') fail('creation_size_target_changed');
const creations = entry!.changes.filter(change => change.kind === 'element' && change.id === target.id && !change.before && change.after?.incarnation === target.incarnation);
if (creations.length !== 1 || creations[0].kind !== 'element' || creations[0].after?.structure !== 'node') fail('creation_size_entry_mismatch');
const created = creations[0].after as ElementRecord & { structure: 'node' };
if (registry.resolve(current!) !== 'known' || !registry.supports(current!, 'geometry.resize')) fail('unsupported_capability');
const units = unitScale(created.data.presentation);
const baseline = { width: created.geometry.width / units, height: created.geometry.height / units };
const ratio = { width: size.width / baseline.width, height: size.height / baseline.height };
const matches = (element: ElementRecord | undefined) => element?.incarnation === target.incarnation && element.structure === 'node';
const unchanged = (element: ElementRecord | undefined) => matches(element) && element!.structure === 'node'
&& closeNumber(element!.geometry.width / unitScale(element!.data.presentation), baseline.width)
&& closeNumber(element!.geometry.height / unitScale(element!.data.presentation), baseline.height);
if (!unchanged(current)) fail('creation_size_user_resized');
const scaled = (geometry: Geometry): Geometry => freeze({ ...geometry, width: geometry.width * ratio.width, height: geometry.height * ratio.height });
// Rewind on private roots. applyChanges intentionally preserves historical
// incarnations here; this is inspection, not a user-visible delete/restore.
let original = document;
const applied = history.undo.slice(index);
for (const id of [...applied].reverse()) original = applyChanges(original, invert(history.entries.get(id)!.changes));
let amended = original, inheriting = false;
const entries = new Map(history.entries);
const timeline = [...applied, ...[...history.redo].reverse()];
for (let cursor = 0; cursor < timeline.length; cursor++) {
const id = timeline[cursor], source = entries.get(id)!;
const end = applyChanges(original, source.changes), isApplied = cursor < applied.length;
if (isApplied && !unchanged(end.elements.get(target.id))) fail('creation_size_user_resized');
// A future explicit resize remains a resize to the user's chosen dimensions.
// Only its before value inherits the asynchronous default correction.
const futureResize = !isApplied && inheriting && matches(end.elements.get(target.id)) && !unchanged(end.elements.get(target.id));
let resizeConsumed = false;
const preserveFutureResize = () => {
if (!futureResize || resizeConsumed) return;
const value = end.elements.get(target.id) as ElementRecord & { structure: 'node' };
if (!closeNumber(value.geometry.width / unitScale(value.data.presentation), baseline.width)) ratio.width = 1;
if (!closeNumber(value.geometry.height / unitScale(value.data.presentation), baseline.height)) ratio.height = 1;
resizeConsumed = true;
};
const changes: Change[] = [];
for (const change of source.changes) {
let next = change;
if (change.kind === 'element' && change.id === target.id) {
const before = amended.elements.get(target.id) ?? null;
const birth = id === entryId && change === creations[0];
if (birth) inheriting = true;
else preserveFutureResize();
const after = change.after && inheriting && change.after.incarnation === target.incarnation && change.after.structure === 'node'
? freeze({ ...change.after, geometry: scaled(change.after.geometry) }) : change.after;
next = freeze({ ...change, before, after });
if (!change.after || change.after.incarnation !== target.incarnation) inheriting = false;
} else if (change.kind === 'field' && change.target.id === target.id && change.target.incarnation === target.incarnation && change.field === 'geometry') {
const before = amended.elements.get(target.id);
if (before?.structure !== 'node') fail('creation_size_target_changed');
preserveFutureResize();
next = freeze({ ...change, before: (before as ElementRecord & { structure: 'node' }).geometry, after: inheriting ? scaled(change.after as Geometry) : change.after });
}
amended = applyChanges(amended, [next]);
changes.push(next);
}
original = end;
if (!equal(changes, source.changes)) entries.set(id, freeze({ ...source, changes }));
}
// Publishable proposal changes only the currently live geometry. Replaying
// redo above was validation; it must never become the working document.
const after = freeze({ ...(current as ElementRecord & { structure: 'node' }).geometry,
width: (current as ElementRecord & { structure: 'node' }).geometry.width * size.width / baseline.width,
height: (current as ElementRecord & { structure: 'node' }).geometry.height * size.height / baseline.height });
const changes: readonly Change[] = equal(after, (current as ElementRecord & { structure: 'node' }).geometry) ? [] : freeze([
{ kind: 'field', target, field: 'geometry', before: (current as ElementRecord & { structure: 'node' }).geometry, after },
]);
const nextDocument = applyChanges(document, changes);
validateDocument(nextDocument, registry);
const handler = registry.handlers.get(current!.componentId)?.get('geometry.resize');
const proposed = handler!({ element: current!, input: freeze({ patch: { width: after.width, height: after.height } }) });
for (const change of proposed) if (change.target.id !== target.id || change.target.incarnation !== target.incarnation || change.field !== 'geometry') fail('invalid_handler_change');
if (!equal(applyChanges(document, proposed).elements.get(target.id), nextDocument.elements.get(target.id))) fail('creation_size_constraint');
return Object.freeze({ document: nextDocument, changes, history: Object.freeze({ undo: history.undo, redo: history.redo, entries: readMap(entries) }) });
}
+14
View File
@@ -0,0 +1,14 @@
import type {Point} from './types.ts';
import type {Cubic} from './geometry.ts';
const distance=(p:Point,a:Point,b:Point)=>{const magnitude=Math.max(1,Math.abs(p.x),Math.abs(p.y),Math.abs(a.x),Math.abs(a.y),Math.abs(b.x),Math.abs(b.y)),x=b.x/magnitude-a.x/magnitude,y=b.y/magnitude-a.y/magnitude,dx=p.x/magnitude-a.x/magnitude,dy=p.y/magnitude-a.y/magnitude,length=Math.hypot(x,y),ux=length?x/length:0,uy=length?y/length:0,t=Math.max(0,Math.min(length,dx*ux+dy*uy));return Math.hypot(dx-t*ux,dy-t*uy)*magnitude;};
const mid=(a:Point,b:Point)=>({x:a.x/2+b.x/2,y:a.y/2+b.y/2});
function split([a,b,c,d]:Cubic):[Cubic,Cubic]{const ab=mid(a,b),bc=mid(b,c),cd=mid(c,d),abc=mid(ab,bc),bcd=mid(bc,cd),center=mid(abc,bcd);return[[a,ab,abc,center],[center,bcd,cd,d]];}
/** Hull rejection + bounded de Casteljau refinement; ambiguous boundaries are explicit, never a proven miss. */
export function hitCubic(curve:Cubic,point:Point,radius:number,options:{error:number;maxDepth:number;maxSteps:number}){
const stack:{curve:Cubic;depth:number}[]=[{curve,depth:0}];let steps=0,possible=false,estimate=Infinity,error=0;
while(stack.length&&steps<options.maxSteps){const item=stack.pop()!;steps++;const xs=item.curve.map(p=>p.x),ys=item.curve.map(p=>p.y),dx=Math.max(0,Math.min(...xs)-point.x,point.x-Math.max(...xs)),dy=Math.max(0,Math.min(...ys)-point.y,point.y-Math.max(...ys));if(Math.hypot(dx,dy)>radius)continue;
const[a,b,c,d]=item.curve,roundoff=Number.EPSILON*64*Math.max(1,Math.abs(point.x),Math.abs(point.y),...xs.map(Math.abs),...ys.map(Math.abs)),flat=Math.max(distance(b,a,d),distance(c,a,d))+roundoff,near=distance(point,a,d);if(!Number.isFinite(flat)||!Number.isFinite(near))return{hit:true,certainty:'possible' as const,steps,distance:Infinity,error:Infinity,budget:true};if(near-flat>radius)continue;if(near+flat<=radius)return{hit:true,certainty:'confirmed' as const,steps,distance:near,error:flat,budget:false};
if(flat<=options.error||item.depth>=options.maxDepth){possible=true;if(near<estimate){estimate=near;error=flat;}continue;}const[left,right]=split(item.curve);stack.push({curve:right,depth:item.depth+1},{curve:left,depth:item.depth+1});
}
return{hit:possible||stack.length>0,certainty:'possible' as const,steps,distance:estimate,error,budget:stack.length>0};
}
+10
View File
@@ -0,0 +1,10 @@
// Private shape provenance: values may still be mutable and require capture.
// Always copy; external Object.freeze is never accepted as ownership evidence.
const owned = new WeakSet<object>();
export function dataArray<T>(values: Iterable<T>): readonly T[] {
const result = Object.freeze(Array.from(values)); owned.add(result); return result;
}
export function dataRecord<T extends object>(value: T): Readonly<T> {
const result = Object.freeze({...value}); owned.add(result); return result;
}
export const isDataContainer = (value: object) => owned.has(value);
+46
View File
@@ -0,0 +1,46 @@
import type {Change,Command,Document,Id,Json,Registry,Target} from './types.ts';
import {applyChanges,targetElement} from './document.ts';
import {canvasTransform,convertGeometry} from './coordinates.ts';
import {unitScale,withUnitScale} from './units.ts';
import {copySlots} from './copy-paths.ts';
import {repairConnectionOwners} from './structure.ts';
import {clone,equal,fault,freeze} from './values.ts';
/** Remove an owning canvas, retaining its entry and promoting only direct members. */
export function planDissolveCanvas(document:Document,registry:Registry,canvasId:Id,entryTarget:Target):{readonly changes:readonly Change[];readonly commands:readonly Command[]}{
const canvas=document.canvases.get(canvasId);if(!canvas)throw fault('canvas_not_loaded',[canvasId]);
if(!canvas.parent)throw fault('cannot_dissolve_root',[canvasId]);
if(canvas.parent.entryElementId!==entryTarget.id)throw fault('canvas_entry_changed',[canvasId]);
targetElement(document,entryTarget);
const parent=canvas.parent.canvasId,from=canvasTransform(document,canvasId),to=canvasTransform(document,parent),changes:Change[]=[],commands:Command[]=[];
for(const element of document.elements.values())if(element.ownerCanvasId===canvasId&&element.structure==='node'){
if(!registry.supports(element,'geometry.move'))throw fault('unsupported_capability',[element.id,'geometry.move']);
const target={id:element.id,incarnation:element.incarnation},geometry=convertGeometry(element.geometry,from,to),presentation=withUnitScale(element.data.presentation,unitScale(element.data.presentation)*from.scale/to.scale);
commands.push({type:'element.move',target,canvasId:parent});
changes.push({kind:'field',target,field:'ownerCanvasId',before:canvasId,after:parent});
if(!equal(geometry,element.geometry))changes.push({kind:'field',target,field:'geometry',before:element.geometry,after:geometry});
if(!equal(presentation,element.data.presentation))changes.push({kind:'field',target,field:'data.presentation',before:element.data.presentation??null,beforePresent:Object.hasOwn(element.data,'presentation'),after:presentation??null,afterPresent:presentation!==undefined});
}
for(const child of document.canvases.values())if(child.parent?.canvasId===canvasId)changes.push({kind:'canvas',id:child.id,before:child,after:{...child,parent:{...child.parent,canvasId:parent}}});
changes.push({kind:'canvas',id:canvasId,before:canvas,after:null});
const promoted=applyChanges(document,changes);changes.push(...repairConnectionOwners(document,promoted));
// Only declared canvas references have meaning here. Opaque payloads are retained.
for(const element of promoted.elements.values()){
if(registry.resolve(element)!=='known')continue;
const references=registry.components.get(element.componentId)?.copy?.references.filter(reference=>reference.target==='canvas')??[];
if(!references.length)continue;
const data=clone(element.data) as Record<string,Json>;
for(const reference of references)for(const slot of copySlots(data,reference.path)){
const value=slot.parent[slot.key];if(value===null)continue;
if(reference.many&&!Array.isArray(value))throw fault('invalid_copy_reference',[element.id]);
const map=(value:Json):Json=>{if(typeof value!=='string'||!value)throw fault('invalid_copy_reference',[element.id]);return value===canvasId?parent:value;};
slot.parent[slot.key]=reference.many?(value as Json[]).map(map):map(value);
}
for(const key of Object.keys(data))if(!equal(data[key],element.data[key])){
if(!registry.supports(element,'content.edit'))throw fault('unsupported_capability',[element.id,'content.edit']);
changes.push({kind:'field',target:{id:element.id,incarnation:element.incarnation},field:`data.${key}`,before:element.data[key],after:data[key]});
commands.push({type:'element.data',target:{id:element.id,incarnation:element.incarnation},patch:{[key]:data[key]}});
}
}
return freeze({changes,commands});
}
+165
View File
@@ -0,0 +1,165 @@
import {detachElementData,hasOwnedElementData,componentDataValid} from './element-data.ts';
import type { CanvasRecord, Change, Document, ElementRecord, Geometry, Id, Registry, Target } from './types.ts';
import { clone, equal, fault, freeze, readMap, validJson } from './values.ts';
// This owns only the envelope and fixed table membership, not validation or
// immutability of values inherited by applyChanges from an external document.
const factoryDocuments=new WeakSet<object>();
function documentEnvelope(document:Document):Document{const value=Object.freeze(document);factoryDocuments.add(value);return value;}
export const isFactoryDocument=(document:Document)=>factoryDocuments.has(document);
export function createDocument(projectId: Id, canvases: readonly CanvasRecord[], elements: readonly ElementRecord[] = [], tombstones: ReadonlyMap<Id, number> = new Map()): Document {
for (const element of elements) { const id = element.id; if (!staticElementProofs.has(element)&&!validJson(element)) throw fault('invalid_element', [id]); }
for (const canvas of canvases) if (!validJson(canvas)) throw fault('invalid_canvas');
if (!projectId || new Set(canvases.map(item => item.id)).size !== canvases.length || new Set(elements.map(item => item.id)).size !== elements.length) throw fault('duplicate_identity');
return documentEnvelope({ projectId, canvases: readMap(new Map(canvases.map(item => [item.id, freeze(clone(item))]))), elements: readMap(new Map(elements.map(item => [item.id, detachElementData(item)]))), tombstones: readMap(tombstones) });
}
export function geometryValid(value: Geometry): boolean {
return !!value && typeof value === 'object' && [value.x, value.y, value.width, value.height, value.rotation].every(Number.isFinite) && value.width > 0 && value.height > 0 && value.rotation >= 0 && value.rotation < 360;
}
export function targetElement(document: Document, target: Target): ElementRecord {
const element = document.elements.get(target.id);
if (!element) throw fault('element_missing', [target.id]);
if (element.incarnation !== target.incarnation) throw fault('stale_incarnation', [target.id]);
return element;
}
export function ancestors(document: Document, canvasId: Id): Id[] {
const result: Id[] = [], visited = new Set<Id>();
let next: Id | undefined = canvasId;
while (next) {
if (visited.has(next)) throw fault('canvas_cycle', [next]);
visited.add(next); result.push(next);
const canvas: CanvasRecord | undefined = document.canvases.get(next);
if (!canvas) throw fault('canvas_not_loaded', [next]);
next = canvas.parent?.canvasId;
}
return result;
}
export function commonCanvas(document: Document, left: Id, right: Id): Id {
const parents = new Set(ancestors(document, left));
const result = ancestors(document, right).find(id => parents.has(id));
if (!result) throw fault('no_common_canvas', [left, right]);
return result;
}
// Only intrinsic shape is reusable; document- and registry-dependent checks stay live.
const staticElementProofs=new WeakSet<object>();
/** Validate one record before its graph dependencies have arrived. This does not
* prove owner membership, endpoint existence or connection common ownership. */
export function validateElementRecord(element: ElementRecord, registry: Registry, tombstones: ReadonlyMap<Id,number> = new Map(), componentData = true): void {
const proven=!!element&&staticElementProofs.has(element);
if (!proven&&(!element || !validJson(element) || typeof element.id !== 'string' || !element.id || typeof element.ownerCanvasId !== 'string' || !element.ownerCanvasId || typeof element.componentId !== 'string' || !element.componentId || !['node','connection'].includes(element.structure) || !Number.isSafeInteger(element.incarnation) || element.incarnation < 1 || !Number.isSafeInteger(element.dataVersion) || element.dataVersion < 1 || !element.data || typeof element.data !== 'object' || Array.isArray(element.data) || !validJson(element.data))) throw fault('invalid_element', [element?.id]);
const dead=tombstones.get(element.id);if(dead!==undefined&&element.incarnation<=dead)throw fault('stale_incarnation',[element.id]);
const definition=registry.components.get(element.componentId);
if(componentData&&registry.resolve(element)==='known'&&(!definition||definition.structure!==element.structure||!componentDataValid(element,definition)))throw fault('invalid_component_data',[element.id]);
if(proven)return;
if(element.structure==='node'){if(!geometryValid(element.geometry))throw fault('invalid_geometry',[element.id]);}
else if(!element.source||!element.target||typeof element.source.elementId!=='string'||!element.source.elementId||typeof element.target.elementId!=='string'||!element.target.elementId||[element.source,element.target].some(endpoint=>endpoint.portId!==undefined&&(typeof endpoint.portId!=='string'||!endpoint.portId))||!validJson(element.path))throw fault('invalid_endpoint',[element.id]);
if(hasOwnedElementData(element))staticElementProofs.add(element);
}
function validateTombstone(id:Id,incarnation:number){
if(!id||!Number.isSafeInteger(incarnation)||incarnation<1)throw fault('invalid_tombstone',[id]);
}
function validateCanvasInDocument(document:Document,canvas:CanvasRecord,entries:Set<Id>):number{
if(typeof canvas.id!=='string'||!canvas.id||!Object.hasOwn(canvas,'parent')||canvas.parent!==null&&(!canvas.parent||typeof canvas.parent.canvasId!=='string'||typeof canvas.parent.entryElementId!=='string'))throw fault('invalid_canvas');
ancestors(document,canvas.id);
if(!canvas.parent)return 1;
const entry=document.elements.get(canvas.parent.entryElementId);
if(!entry||entry.structure!=='node'||entry.ownerCanvasId!==canvas.parent.canvasId||entries.has(entry.id))throw fault('invalid_canvas_entry',[canvas.id]);
entries.add(entry.id);return 0;
}
function validateElementInDocument(document:Document,element:ElementRecord,registry:Registry,componentData:boolean){
validateElementRecord(element,registry,document.tombstones,componentData);
if(!document.canvases.has(element.ownerCanvasId))throw fault('invalid_element',[element.id]);
if(element.structure==='connection'){
const source=document.elements.get(element.source.elementId),target=document.elements.get(element.target.elementId);
if(!source||!target||source.structure!=='node'||target.structure!=='node'||!validJson(element.path))throw fault('invalid_endpoint',[element.id]);
if(element.ownerCanvasId!==commonCanvas(document,source.ownerCanvasId,target.ownerCanvasId))throw fault('invalid_connection_owner',[element.id]);
}
}
/** Cooperative and synchronous paths use the same per-record checks and order. */
function* validateDocumentChecks(document:Document,registry:Registry,componentData:boolean):Generator<void,void>{
if(!document.canvases.size)throw fault('missing_root');
for(const[id,incarnation]of document.tombstones){yield;validateTombstone(id,incarnation);}
const entries=new Set<Id>();let roots=0;
for(const canvas of document.canvases.values()){yield;roots+=validateCanvasInDocument(document,canvas,entries);}
if(roots!==1)throw fault('invalid_roots');
for(const element of document.elements.values()){yield;validateElementInDocument(document,element,registry,componentData);}
}
/** Structural indexes contain no component payload; use only for source planning, never editor publication. */
export function* validateDocumentTopologySteps(document:Document,registry:Registry):Generator<void,void>{yield* validateDocumentChecks(document,registry,false);}
export function* validateDocumentSteps(document:Document,registry:Registry):Generator<void,void>{yield* validateDocumentChecks(document,registry,true);}
export function validateDocument(document:Document,registry:Registry):void{
if(!document.canvases.size)throw fault('missing_root');
for(const[id,incarnation]of document.tombstones)validateTombstone(id,incarnation);
const entries=new Set<Id>();let roots=0;
for(const canvas of document.canvases.values())roots+=validateCanvasInDocument(document,canvas,entries);
if(roots!==1)throw fault('invalid_roots');
for(const element of document.elements.values())validateElementInDocument(document,element,registry,true);
}
export type ReplayChangeBatch = {readonly changes:readonly Change[];readonly canvasIds?:readonly Id[]};
export function applyChanges(document: Document, changes: readonly Change[], conditional = true): Document {
return applyChangeBatches(document,[{changes}],conditional,false);
}
/** Ordered conditional replay against one private draft; no intermediate document escapes. */
export function replayChanges(document:Document,batches:Iterable<ReplayChangeBatch>):Document {
return applyChangeBatches(document,batches,true,true);
}
function applyChangeBatches(document:Document,batches:Iterable<ReplayChangeBatch>,conditional:boolean,replay:boolean):Document {
const elements = new Map(document.elements), canvases = new Map(document.canvases), tombstones = new Map(document.tombstones);
for (const batch of batches) for (const change of batch.changes) {
if(replay){
// Check scope even when the desired field value is already present.
if(change.kind==='field'){const owner=elements.get(change.target.id)?.ownerCanvasId;if(owner&&batch.canvasIds?.length&&!batch.canvasIds.includes(owner))throw fault('operation_context_changed',[change.target.id]);}
if(changeSatisfied(elements,canvases,change))continue;
}
if (change.kind === 'field') {
if (!validJson(change.after)) throw fault('invalid_change');
if (!change.field.startsWith('data.') && !['geometry', 'ownerCanvasId', 'source', 'target', 'path'].includes(change.field)) throw fault('invalid_field');
const element = elements.get(change.target.id);
if (!element || element.incarnation !== change.target.incarnation) throw fault('stale_incarnation', [change.target.id]);
const dataKey = change.field.startsWith('data.') ? change.field.slice(5) : null;
const prior = dataKey !== null ? element.data[dataKey] ?? null : (element as unknown as Record<string, unknown>)[change.field];
if (conditional && (dataKey !== null && Object.hasOwn(element.data, dataKey) !== (change.beforePresent ?? true) || !equal(prior, change.before))) throw fault('field_conflict', [element.id]);
if (dataKey !== null) {
if (!dataKey || ['__proto__', 'prototype', 'constructor'].includes(dataKey)) throw fault('invalid_field');
const data = { ...element.data };
if (change.afterPresent === false) delete data[dataKey]; else data[dataKey] = clone(change.after);
elements.set(element.id, freeze({ ...element, data }));
} else elements.set(element.id, freeze({ ...element, [change.field]: clone(change.after) }) as ElementRecord);
} else if (change.kind === 'element') {
if (conditional && !equal(elements.get(change.id) || null, change.before)) throw fault('element_conflict', [change.id]);
if (change.after) {
if (!validJson(change.after)) throw fault('invalid_element', [change.id]);
if (change.after.id !== change.id) throw fault('identity_mismatch', [change.id]);
elements.set(change.id, detachElementData(change.after));
} else {
const old = elements.get(change.id);
if (old) tombstones.set(change.id, Math.max(tombstones.get(change.id) || 0, old.incarnation));
elements.delete(change.id);
}
} else if (change.kind === 'canvas') {
if (conditional && !equal(canvases.get(change.id) || null, change.before)) throw fault('canvas_conflict', [change.id]);
if (change.after) {
if (!validJson(change.after)) throw fault('invalid_canvas', [change.id]);
if (change.after.id !== change.id) throw fault('identity_mismatch', [change.id]);
canvases.set(change.id, freeze(clone(change.after)));
} else canvases.delete(change.id);
} else throw fault('invalid_change');
}
return documentEnvelope({ projectId: document.projectId, elements: readMap(elements), canvases: readMap(canvases), tombstones: readMap(tombstones) });
}
function changeSatisfied(elements:ReadonlyMap<Id,ElementRecord>,canvases:ReadonlyMap<Id,CanvasRecord>,change:Change):boolean {
if(change.kind==='canvas')return equal(canvases.get(change.id)??null,change.after);
if(change.kind==='element')return equal(elements.get(change.id)??null,change.after);
const element=elements.get(change.target.id);
if(!element||element.incarnation!==change.target.incarnation)return false;
if(change.field.startsWith('data.')){const key=change.field.slice(5);return Object.hasOwn(element.data,key)===(change.afterPresent??true)&&equal(element.data[key]??null,change.after);}
return equal((element as unknown as Record<string,unknown>)[change.field],change.after);
}
export function invert(changes: readonly Change[]): readonly Change[] {
return changes.slice().reverse().map(change => {
if(change.kind==='field'){const{beforePresent,afterPresent,...record}=change;return freeze({...record,before:change.after,after:change.before,...(afterPresent===undefined?{}:{beforePresent:afterPresent}),...(beforePresent===undefined?{}:{afterPresent:beforePresent})});}
return freeze({...change,before:change.after,after:change.before}) as Change;
});
}
@@ -0,0 +1,75 @@
import {detachJsonData} from './json-data.ts';
import type { CanvasRecord, Change, Document, ElementRecord, Id, Point, Registry, Target } from './types.ts';
import { ancestors, targetElement, validateDocumentSteps, validateDocumentTopologySteps } from './document.ts';
import { duplicateTreeChange, duplicateEdgeChange, duplicateOverlay, type DuplicateOptions, type DuplicatePlan } from './duplicate.ts';
import { clone, equal, fault, freeze, validJson } from './values.ts';
const phases=['selection','canvases','members','roots','seed','dfs','edges','allocate-elements','allocate-canvases','mapping','emit','emit-edges','validate','done'] as const;
type Task={kind:'element'|'canvas';id:Id};
/** Internal preparation state: IDs/cursors only. Emitted records are a deterministic derived cache. */
type MutableCheckpoint={version:1;projectId:Id;sourceToken:string;schemaToken:string;selection:Target[];offset:Point;mapping?:DuplicateOptions['mapping'];phase:typeof phases[number];cursor:number;steps:number;selected:Id[];children:[Id,Id][];members:[Id,Id[]][];roots:Id[];copied:Id[];canvases:Id[];order:Task[];work:Task[];expanding?:{canvas:Id;index:number};edges:Id[];elementIds:[Id,Id][];canvasIds:[Id,Id][];emitted:number;emittedEdges:number};
type Immutable<T>=T extends object?{readonly [K in keyof T]:Immutable<T[K]>}:T;
export type DuplicateCheckpoint=Immutable<MutableCheckpoint>;
const ids=(value:unknown):value is Id[]=>Array.isArray(value)&&value.every(id=>typeof id==='string'&&!!id);
export function validateDuplicateCheckpoint(value:DuplicateCheckpoint):void{
if(!value||!validJson(value)||value.version!==1)throw fault('copy_checkpoint_interpreter_unavailable');
if(![value.projectId,value.sourceToken,value.schemaToken].every(id=>typeof id==='string'&&!!id)||!phases.includes(value.phase)||!Number.isSafeInteger(value.cursor)||value.cursor<(value.phase==='seed'?-1:0)||![value.steps,value.emitted,value.emittedEdges].every(n=>Number.isSafeInteger(n)&&n>=0)||!Array.isArray(value.selection)||!value.selection.length||value.selection.some(t=>!t||typeof t.id!=='string'||!t.id||!Number.isSafeInteger(t.incarnation)||t.incarnation<1)||!value.offset||![value.offset.x,value.offset.y].every(Number.isFinite))throw fault('invalid_copy_checkpoint');
for(const list of [value.selected,value.roots,value.copied,value.canvases,value.edges])if(!ids(list)||new Set(list).size!==list.length)throw fault('invalid_copy_checkpoint');
for(const list of [value.order,value.work])if(!Array.isArray(list)||list.some(t=>!t||!['element','canvas'].includes(t.kind)||typeof t.id!=='string'||!t.id))throw fault('invalid_copy_checkpoint');
for(const list of [value.children,value.elementIds,value.canvasIds])if(!Array.isArray(list)||list.some(pair=>!ids(pair)||pair.length!==2)||new Set(list.map(pair=>pair[0])).size!==list.length)throw fault('invalid_copy_checkpoint');
if(!Array.isArray(value.members)||value.members.some(pair=>!Array.isArray(pair)||pair.length!==2||typeof pair[0]!=='string'||!pair[0]||!ids(pair[1]))||new Set(value.members.map(pair=>pair[0])).size!==value.members.length||value.emitted>value.order.length||value.emittedEdges>value.edges.length||value.emittedEdges>0&&value.emitted!==value.order.length||value.expanding&&(typeof value.expanding.canvas!=='string'||!value.expanding.canvas||!Number.isSafeInteger(value.expanding.index)||value.expanding.index< -1))throw fault('invalid_copy_checkpoint');
}
export function createDuplicatePlanner(document:Document,registry:Registry,selection:readonly Target[],options:DuplicateOptions,binding:{sourceToken:string;schemaToken:string;checkpoint?:DuplicateCheckpoint;topologyOnly?:boolean}){
const offset=options.worldOffset??{x:32,y:32},idFactory=options.createId,suppliedMapping=options.mapping?freeze(clone(options.mapping)):undefined;
const state:MutableCheckpoint=binding.checkpoint?clone(binding.checkpoint) as MutableCheckpoint:{version:1,projectId:document.projectId,sourceToken:binding.sourceToken,schemaToken:binding.schemaToken,selection:clone(selection) as Target[],offset:clone(offset),...(suppliedMapping?{mapping:suppliedMapping}:{}),phase:'selection',cursor:0,steps:0,selected:[],children:[],members:[],roots:[],copied:[],canvases:[],order:[],work:[],edges:[],elementIds:[],canvasIds:[],emitted:0,emittedEdges:0};
validateDuplicateCheckpoint(state);
if(state.projectId!==document.projectId||state.sourceToken!==binding.sourceToken||state.schemaToken!==binding.schemaToken||!equal(state.selection,selection)||!equal(state.offset,offset)||!equal(state.mapping,suppliedMapping))throw fault('copy_checkpoint_binding_mismatch');
const sourceElements=[...document.elements.values()],sourceCanvases=[...document.canvases.values()],selected=new Set(state.selected),copied=new Set(state.copied),canvases=new Set(state.canvases),children=new Map(state.children),members=new Map(state.members),elementIds=new Map(state.elementIds),canvasIds=new Map(state.canvasIds),used=new Set([...document.elements.keys(),...document.canvases.keys(),...document.tombstones.keys()]);
for(const id of [...state.selected,...state.roots,...state.copied,...state.edges])if(!document.elements.has(id))throw fault('invalid_copy_checkpoint');
for(const id of state.canvases)if(!document.canvases.has(id))throw fault('invalid_copy_checkpoint');
for(const [entry,id]of children)if(document.canvases.get(id)?.parent?.entryElementId!==entry)throw fault('invalid_copy_checkpoint');
for(const [owner,list]of members)for(const id of list)if(document.elements.get(id)?.ownerCanvasId!==owner)throw fault('invalid_copy_checkpoint');
for(const [list,scope]of [[state.elementIds,copied],[state.canvasIds,canvases]] as const)for(const [id,mapped]of list){if(!scope.has(id)||used.has(mapped))throw fault('invalid_copy_checkpoint');used.add(mapped);}
for(const task of [...state.order,...state.work])if(!(task.kind==='element'?document.elements:document.canvases).has(task.id))throw fault('invalid_copy_checkpoint');
if(phases.indexOf(state.phase)>=phases.indexOf('emit')&&(elementIds.size!==copied.size||canvasIds.size!==canvases.size))throw fault('invalid_copy_checkpoint');
const limits:Partial<Record<DuplicateCheckpoint['phase'],number>>={selection:state.selection.length,canvases:sourceCanvases.length,members:sourceElements.length,roots:state.selected.length,seed:state.roots.length,edges:sourceElements.length,'allocate-elements':state.copied.length,'allocate-canvases':state.canvases.length};if(limits[state.phase]!==undefined&&state.cursor>limits[state.phase]!)throw fault('invalid_copy_checkpoint');
const supplied=suppliedMapping?[...Object.keys(suppliedMapping.elements).map(id=>({id,kind:'element' as const})),...Object.keys(suppliedMapping.canvases).map(id=>({id,kind:'canvas' as const}))]:[];
const outputElements=new Map<Id,ElementRecord>(),outputCanvases=new Map<Id,CanvasRecord>(),output=duplicateOverlay(document,outputElements,outputCanvases),changes:Change[]=[];
let treeCache=0,edgeCache=0,sourceChecks:Generator<void>|undefined=(binding.topologyOnly?validateDocumentTopologySteps:validateDocumentSteps)(document,registry),outputChecks:Generator<void>|undefined,validated=false;
const phase=(next:DuplicateCheckpoint['phase'],cursor=0)=>{state.phase=next;state.cursor=cursor;};
function add(change:Exclude<Change,{kind:'field'}>){freeze(change);changes.push(change);if(change.kind==='element')outputElements.set(change.id,change.after!);else outputCanvases.set(change.id,change.after!);}
function tree(index:number){const task=state.order[index];add(duplicateTreeChange(document,registry,task.kind,(task.kind==='element'?document.elements:document.canvases).get(task.id)!,elementIds,canvasIds,canvases,state.offset));}
function edge(index:number){add(duplicateEdgeChange(document,registry,document.elements.get(state.edges[index])!,elementIds,canvasIds,output));}
function allocate(id:Id,kind:'elements'|'canvases'){const map=suppliedMapping?.[kind],value=map&&Object.hasOwn(map,id)?map[id]:idFactory();if(typeof value!=='string'||!value||used.has(value))throw fault('duplicate_identity',[value]);used.add(value);return value;}
return Object.freeze({
ready():boolean{return !sourceChecks&&state.phase==='emit'&&state.emitted===0;},
progress(){return Object.freeze({phase:state.phase,steps:state.steps,allocated:state.elementIds.length+state.canvasIds.length});},
checkpoint():DuplicateCheckpoint{return detachJsonData(state);},
step():boolean{
if(sourceChecks){if(sourceChecks.next().done)sourceChecks=undefined;return false;}
// Reconstruct only the derived output cache. Index/DFS/allocation work stays at its saved cursor.
if(treeCache<state.emitted){tree(treeCache++);return false;}if(edgeCache<state.emittedEdges){edge(edgeCache++);return false;}
if(state.phase==='validate'||state.phase==='done'){outputChecks??=validateDocumentSteps(output,registry);if(!outputChecks.next().done)return false;validated=true;if(state.phase!=='done'){phase('done');state.steps++;}return true;}
switch(state.phase){
case 'selection':if(state.cursor<state.selection.length){const id=targetElement(document,state.selection[state.cursor++]).id;if(!selected.has(id)){selected.add(id);state.selected.push(id);}}else{if(!selected.size)throw fault('empty_copy_selection');phase('canvases');}break;
case 'canvases':if(state.cursor<sourceCanvases.length){const canvas=sourceCanvases[state.cursor++];if(canvas.parent){children.set(canvas.parent.entryElementId,canvas.id);state.children.push([canvas.parent.entryElementId,canvas.id]);}}else phase('members');break;
case 'members':if(state.cursor<sourceElements.length){const record=sourceElements[state.cursor++];let list=members.get(record.ownerCanvasId);if(!list){list=[];members.set(record.ownerCanvasId,list);state.members.push([record.ownerCanvasId,list]);}list.push(record.id);}else phase('roots');break;
case 'roots':if(state.cursor<state.selected.length){const id=state.selected[state.cursor++];if(!ancestors(document,document.elements.get(id)!.ownerCanvasId).some(canvas=>{const parent=document.canvases.get(canvas)!.parent;return parent&&selected.has(parent.entryElementId);}))state.roots.push(id);}else phase('seed',state.roots.length-1);break;
case 'seed':if(state.cursor>=0){const id=state.roots[state.cursor--];if(document.elements.get(id)!.structure==='node')state.work.push({kind:'element',id});}else phase('dfs');break;
case 'dfs':{
if(state.expanding){const list=members.get(state.expanding.canvas)??[];if(state.expanding.index>=list.length)throw fault('invalid_copy_checkpoint');if(state.expanding.index<0)delete state.expanding;else{const id=list[state.expanding.index--];if(document.elements.get(id)!.structure==='node')state.work.push({kind:'element',id});}break;}
const task=state.work.pop();if(!task){phase('edges');break;}
if(task.kind==='canvas'){if(canvases.has(task.id))break;canvases.add(task.id);state.canvases.push(task.id);state.order.push(task);state.expanding={canvas:task.id,index:(members.get(task.id)?.length??0)-1};}
else{if(copied.has(task.id))break;copied.add(task.id);state.copied.push(task.id);state.order.push(task);const child=children.get(task.id);if(child)state.work.push({kind:'canvas',id:child});}break;
}
case 'edges':if(state.cursor<sourceElements.length){const record=sourceElements[state.cursor++];if(record.structure==='connection'&&(selected.has(record.id)||canvases.has(record.ownerCanvasId)||copied.has(record.target.elementId))){state.edges.push(record.id);copied.add(record.id);state.copied.push(record.id);}}else phase('allocate-elements');break;
case 'allocate-elements':if(state.cursor<state.copied.length){const id=state.copied[state.cursor++],value=allocate(id,'elements');elementIds.set(id,value);state.elementIds.push([id,value]);}else phase('allocate-canvases');break;
case 'allocate-canvases':if(state.cursor<state.canvases.length){const id=state.canvases[state.cursor++],value=allocate(id,'canvases');canvasIds.set(id,value);state.canvasIds.push([id,value]);}else phase('mapping');break;
case 'mapping':if(state.cursor<supplied.length){const value=supplied[state.cursor++];if(!(value.kind==='element'?copied:canvases).has(value.id))throw fault('copy_mapping_scope_mismatch');}else phase('emit');break;
case 'emit':if(state.emitted<state.order.length){tree(state.emitted++);treeCache++;}else phase('emit-edges');break;
case 'emit-edges':if(state.emittedEdges<state.edges.length){edge(state.emittedEdges++);edgeCache++;}else phase('validate');break;
}
state.steps++;return false;
},
result():DuplicatePlan{if(!validated||state.phase!=='done')throw fault('copy_plan_not_ready');return freeze({version:1,roots:state.roots.map(id=>elementIds.get(id)!),elementIds:Object.fromEntries(elementIds),canvasIds:Object.fromEntries(canvasIds),changes:[...changes]});},
});
}
+155
View File
@@ -0,0 +1,155 @@
import type { CanvasRecord, Change, Data, Document, ElementRecord, Id, Json, Point, Registry, Target } from './types.ts';
import { copySlots } from './copy-paths.ts';
import { ancestors, commonCanvas, targetElement, validateDocumentSteps } from './document.ts';
import { connectionPathValid } from './features.ts';
import { canvasTransform, convertPath, toLocal, toWorld } from './coordinates.ts';
import { clone, fault, freeze } from './values.ts';
export type DuplicatePlan = { readonly version: 1; readonly roots: readonly Id[]; readonly elementIds: Readonly<Record<Id, Id>>; readonly canvasIds: Readonly<Record<Id, Id>>; readonly changes: readonly Change[] };
export type DuplicateOptions = { readonly createId: () => Id; readonly worldOffset?: Point; readonly mapping?: { readonly elements: Readonly<Record<Id, Id>>; readonly canvases: Readonly<Record<Id, Id>> } };
export function remapCopyData(element: ElementRecord, registry: Registry, elements: ReadonlyMap<Id, Id>, canvases: ReadonlyMap<Id, Id>, document: Document): Data {
const definition = registry.components.get(element.componentId), schema = definition?.copy;
if (registry.resolve(element) !== 'known' || !schema) throw fault('copy_schema_unavailable', [element.id]);
const data = clone(element.data) as Record<string, Json>;
for (const ref of schema.references) {
for (const slot of copySlots(data, ref.path)) {
if (slot.parent[slot.key] === null) continue;
const mapped = (value: Json): Json => {
if (typeof value !== 'string' || !value) throw fault('invalid_copy_reference', [element.id]);
const map = ref.target === 'element' ? elements : canvases, known = ref.target === 'element' ? document.elements : document.canvases;
if (map.has(value)) return map.get(value)!;
if (ref.external !== 'preserve' || !known.has(value)) throw fault('copy_external_reference', [element.id, value]);
return value;
};
const value = slot.parent[slot.key];
if (ref.many && !Array.isArray(value)) throw fault('invalid_copy_reference', [element.id]);
slot.parent[slot.key] = ref.many ? (value as Json[]).map(mapped) : mapped(value);
}
}
for (const path of schema.omit) for (const slot of copySlots(data, path)) { if (Array.isArray(slot.parent)) throw fault('invalid_copy_omit', [element.id]); delete slot.parent[slot.key]; }
return data;
}
/** Pure, complete-snapshot planning. Runtime owns time slicing, durable source/mapping and partial progress. */
export function* planDuplicateSteps(document: Document, registry: Registry, selection: readonly Target[], options: DuplicateOptions): Generator<void, DuplicatePlan> {
yield* validateDocumentSteps(document, registry);
const offset = options.worldOffset ?? { x: 32, y: 32 };
if (![offset.x, offset.y].every(Number.isFinite)) throw fault('invalid_copy_offset');
const selected = new Set<Id>();
for (const target of selection) { yield; selected.add(targetElement(document, target).id); }
if (!selected.size) throw fault('empty_copy_selection');
const childByEntry = new Map<Id, CanvasRecord>(), members = new Map<Id, ElementRecord[]>();
for (const canvas of document.canvases.values()) { yield; if (canvas.parent) childByEntry.set(canvas.parent.entryElementId, canvas); }
for (const element of document.elements.values()) { yield; const list = members.get(element.ownerCanvasId) ?? []; list.push(element); members.set(element.ownerCanvasId, list); }
const roots: Id[] = [];
for (const id of selected) { yield; if (!ancestors(document, document.elements.get(id)!.ownerCanvasId).some(canvas => { const parent = document.canvases.get(canvas)!.parent; return parent && selected.has(parent.entryElementId); })) roots.push(id); }
const copied = new Set<Id>(), copiedCanvases = new Set<Id>(), order: ({ kind: 'element'; record: ElementRecord } | { kind: 'canvas'; record: CanvasRecord })[] = [];
const work: typeof order = [];
for (let index = roots.length - 1; index >= 0; index--) { yield; const record = document.elements.get(roots[index])!; if (record.structure === 'node') work.push({kind:'element',record}); }
while (work.length) {
yield;
const item = work.pop()!;
if (item.kind === 'canvas') {
if (copiedCanvases.has(item.record.id)) continue;
copiedCanvases.add(item.record.id); order.push(item);
const list = members.get(item.record.id) ?? [];
for (let index = list.length - 1; index >= 0; index--) { yield; const member = list[index]; if (member.structure === 'node') work.push({ kind: 'element', record: member }); }
} else {
if (copied.has(item.record.id)) continue;
copied.add(item.record.id); order.push(item);
const child = childByEntry.get(item.record.id); if (child) work.push({ kind: 'canvas', record: child });
}
}
const edges: ElementRecord[] = [];
for (const element of document.elements.values()) { yield; if (element.structure === 'connection' && (selected.has(element.id) || copiedCanvases.has(element.ownerCanvasId) || copied.has(element.target.elementId))) { edges.push(element); copied.add(element.id); } }
const elementIds = new Map<Id, Id>(), canvasIds = new Map<Id, Id>(), used = new Set<Id>();
for (const map of [document.elements, document.canvases, document.tombstones]) for (const id of map.keys()) { yield; used.add(id); }
const allocate = (source: Id, kind: 'elements' | 'canvases') => {
const supplied = options.mapping?.[kind];
const id = supplied && Object.hasOwn(supplied, source) ? supplied[source] : options.createId();
if (typeof id !== 'string' || !id || used.has(id)) throw fault('duplicate_identity', [id]);
used.add(id); return id;
};
for (const id of copied) { yield; elementIds.set(id, allocate(id, 'elements')); }
for (const id of copiedCanvases) { yield; canvasIds.set(id, allocate(id, 'canvases')); }
if (options.mapping) for (const [mapping, scope] of [[options.mapping.elements,copied],[options.mapping.canvases,copiedCanvases]] as const) for (const id in mapping) { yield; if (Object.hasOwn(mapping,id) && !scope.has(id)) throw fault('copy_mapping_scope_mismatch'); }
const changes: Change[] = [];
for (const item of order) {
yield;
changes.push(duplicateTreeChange(document,registry,item.kind,item.record,elementIds,canvasIds,copiedCanvases,offset));
}
const nodeElements = new Map<Id, ElementRecord>(), nodeCanvases = new Map<Id, CanvasRecord>();
for (const change of changes) { yield; if (change.kind === 'element' && change.after) nodeElements.set(change.id, change.after); else if (change.kind === 'canvas' && change.after) nodeCanvases.set(change.id, change.after); }
const nodes: Document = { ...document, elements: overlay(document.elements, nodeElements), canvases: overlay(document.canvases, nodeCanvases) };
for (const edge of edges) {
yield;
const change=duplicateEdgeChange(document,registry,edge,elementIds,canvasIds,nodes);
changes.push(change);nodeElements.set(change.id,change.after!);
}
yield* validateDocumentSteps(nodes, registry);
const elementMapping: Record<Id,Id> = {}, canvasMapping: Record<Id,Id> = {}, copiedRoots: Id[] = [];
for (const [id, value] of elementIds) { yield; Object.defineProperty(elementMapping,id,{value,enumerable:true}); }
for (const [id, value] of canvasIds) { yield; Object.defineProperty(canvasMapping,id,{value,enumerable:true}); }
for (const id of roots) { yield; copiedRoots.push(elementIds.get(id)!); }
for (const change of changes) { yield; freeze(change); }
return Object.freeze({ version: 1, roots: Object.freeze(copiedRoots), elementIds: Object.freeze(elementMapping), canvasIds: Object.freeze(canvasMapping), changes: Object.freeze(changes) });
}
/** Shared deterministic emitters for synchronous and checkpointed planners. */
export function duplicateTreeChange(document:Document,registry:Registry,kind:'element'|'canvas',record:ElementRecord|CanvasRecord,elementIds:ReadonlyMap<Id,Id>,canvasIds:ReadonlyMap<Id,Id>,copiedCanvases:ReadonlySet<Id>,offset:Point):Exclude<Change,{kind:'field'}>{
if (kind === 'canvas') {
const canvas=record as CanvasRecord,parent=canvas.parent!;
const after = { ...clone(canvas), id: canvasIds.get(canvas.id)!, parent: { ...parent, canvasId: canvasIds.get(parent.canvasId) ?? parent.canvasId, entryElementId: elementIds.get(parent.entryElementId)! } };
return { kind: 'canvas', id: after.id, before: null, after };
} else {
const old = record as ElementRecord; if (old.structure !== 'node') throw fault('invalid_copy_node');
let geometry = clone(old.geometry);
if (!copiedCanvases.has(old.ownerCanvasId)) {
const transform = canvasTransform(document, old.ownerCanvasId), zero = toLocal(transform, { x: 0, y: 0 }), shift = toLocal(transform, offset);
geometry = { ...geometry, x: geometry.x + shift.x - zero.x, y: geometry.y + shift.y - zero.y };
}
const after = { ...clone(old), id: elementIds.get(old.id)!, incarnation: 1, ownerCanvasId: canvasIds.get(old.ownerCanvasId) ?? old.ownerCanvasId, data: remapCopyData(old, registry, elementIds, canvasIds, document), geometry };
return { kind: 'element', id: after.id, before: null, after };
}
}
export function duplicateEdgeChange(document:Document,registry:Registry,edge:ElementRecord,elementIds:ReadonlyMap<Id,Id>,canvasIds:ReadonlyMap<Id,Id>,nodes:Document):Extract<Change,{kind:'element'}>{
if (edge.structure !== 'connection') throw fault('invalid_copy_endpoint');
if (!connectionPathValid(edge.path)) throw fault('unsupported_copy_path', [edge.id]);
const source = { ...edge.source, elementId: elementIds.get(edge.source.elementId) ?? edge.source.elementId }, target = { ...edge.target, elementId: elementIds.get(edge.target.elementId) ?? edge.target.elementId };
const owner = commonCanvas(nodes, nodes.elements.get(source.elementId)!.ownerCanvasId, nodes.elements.get(target.elementId)!.ownerCanvasId), transform = canvasTransform(nodes, owner);
let path = convertPath(edge.path, canvasTransform(document, edge.ownerCanvasId), transform);
if (path && typeof path === 'object' && !Array.isArray(path) && Array.isArray(path.knots)) {
const displacement = (beforeId: Id, afterId: Id) => {
const a = document.elements.get(beforeId)!, b = nodes.elements.get(afterId)!;
if (a.structure !== 'node' || b.structure !== 'node') throw fault('invalid_copy_endpoint');
const from = toWorld(canvasTransform(document, a.ownerCanvasId), { x: a.geometry.x + a.geometry.width / 2, y: a.geometry.y + a.geometry.height / 2 }), to = toWorld(canvasTransform(nodes, b.ownerCanvasId), { x: b.geometry.x + b.geometry.width / 2, y: b.geometry.y + b.geometry.height / 2 });
return { x: to.x - from.x, y: to.y - from.y };
};
const from = displacement(edge.source.elementId, source.elementId), to = displacement(edge.target.elementId, target.elementId), knots = path.knots;
path = { ...path, knots: knots.map((raw, index) => { const knot = raw as Record<string, Json>, point = knot.position as { x: number; y: number }, world = toWorld(transform, point), t = (index + 1) / (knots.length + 1); return { ...knot, position: toLocal(transform, { x: world.x + from.x * (1 - t) + to.x * t, y: world.y + from.y * (1 - t) + to.y * t }) }; }) };
}
const after: ElementRecord = { ...clone(edge), id: elementIds.get(edge.id)!, incarnation: 1, ownerCanvasId: owner, source, target, path, data: remapCopyData(edge, registry, elementIds, canvasIds, document) };
return { kind: 'element', id: after.id, before: null, after };
}
export function duplicateOverlay(document:Document,elements:ReadonlyMap<Id,ElementRecord>,canvases:ReadonlyMap<Id,CanvasRecord>):Document{return {...document,elements:overlay(document.elements,elements),canvases:overlay(document.canvases,canvases)};}
/** Internal overlay avoids an O(document size) clone between planning phases. Additions own unique IDs. */
function overlay<K,V>(base: ReadonlyMap<K,V>, additions: ReadonlyMap<K,V>): ReadonlyMap<K,V> {
const view: ReadonlyMap<K,V> = {
get size() { return base.size + additions.size; },
get: key => additions.has(key) ? additions.get(key) : base.get(key), has: key => additions.has(key) || base.has(key),
*entries(): Generator<[K,V],undefined> { yield* base; yield* additions; return undefined; },
*keys(): Generator<K,undefined> { yield* base.keys(); yield* additions.keys(); return undefined; },
*values(): Generator<V,undefined> { yield* base.values(); yield* additions.values(); return undefined; },
[Symbol.iterator]() { return this.entries(); },
forEach(callback,thisArg) { for (const [key,value] of view) callback.call(thisArg,value,key,view); },
};
return Object.freeze(view);
}
/** Synchronous API retained for small snapshots and callers that own scheduling. */
export function planDuplicate(document: Document, registry: Registry, selection: readonly Target[], options: DuplicateOptions): DuplicatePlan {
const steps = planDuplicateSteps(document,registry,selection,options);
while (true) { const step = steps.next(); if (step.done) return step.value; }
}
+353
View File
@@ -0,0 +1,353 @@
import {dataArray,dataRecord} from './data-containers.ts';
import {stageCreation,materializeCreations} from './creation-draft.ts';
import type { Applied, Availability, Capability, Change, Command, Commit, CoreSnapshot, Document, ElementRecord, Geometry, HistoryEntry, HistoryState, Id, OperationIntent, Origin, Registry, Result, Snapshot } from './types.ts';
import { applyChanges, createDocument, invert, targetElement, validateDocument } from './document.ts';
import { clone, equal, fault, freeze, readMap, reject, validJson } from './values.ts';
import {createDocumentQueries,advanceDocumentQueries,withQueryCoverage,type QueryCoverage,type QueryBudget} from './queries.ts';
import { canvasTransform, convertGeometry } from './coordinates.ts';
import { unitScale, withUnitScale } from './units.ts';
import { removalChanges, repairConnectionOwners } from './structure.ts';
import {planDissolveCanvas} from './dissolve.ts';
import { planHistoryChanges } from './history.ts';
import { describeHistoryTransition } from './history-transition.ts';
import { planCreationSize, validateCreationSizeEffect } from './creation-size-plan.ts';
import type { CreationSizeEffect } from './types.ts';
export type EditorOptions<Runtime = undefined> = {
registry: Registry; document: Document; queryBudget?:QueryBudget; createId?: () => Id; historyLimit?: number;
queryCoverage?: (snapshot:Snapshot<Runtime>)=>QueryCoverage;
policy?: (command: Command, document: Document) => Availability;
traversalPolicy?: (entry: HistoryEntry, direction: 'undo' | 'redo') => Availability;
onListenerError?: (error: unknown) => void;
allowRawChanges?: boolean;
recovery?: CoreSnapshot & { readonly rootRevision: number };
runtime?: Runtime;
/** Installed by the engine. Pure preparation: an error rejects the entire commit. */
reduceRuntime?: (previous: Runtime, next: CoreSnapshot, commit: Commit) => Runtime;
/** Engine may attach transport semantics without changing identity, order or document changes. */
prepareIntent?: (intent: OperationIntent, runtime: Runtime) => OperationIntent;
/** Pure coordinator hook for all publication paths; may replace runtime only or reject. */
prepareRuntime?: (previous: Snapshot<Runtime>, next: Snapshot<Runtime>) => Runtime;
nextOperationSequence?: (runtime: Runtime, fallback: number) => number;
};
export type Transaction = { dispatch(command: Command): Availability; getDocument(): Document };
export type Editor = ReturnType<typeof createEditor>;
const isThenable = (value: unknown) => !!value && (typeof value === 'object' || typeof value === 'function') && typeof (value as { then?: unknown }).then === 'function';
export function createEditor<Runtime = undefined>(options: EditorOptions<Runtime>) {
const source = options.recovery?.document ?? options.document;
const document = createDocument(source.projectId, [...source.canvases.values()], [...source.elements.values()], source.tombstones);
validateDocument(document, options.registry);
let state: Snapshot<Runtime> = Object.freeze({ revision: options.recovery?.revision ?? 0, rootRevision: options.recovery?.rootRevision ?? 0, document, history: options.recovery?.history ?? Object.freeze({ undo: Object.freeze([]), redo: Object.freeze([]), entries: readMap(new Map<Id, HistoryEntry>()) }), operations: options.recovery?.operations ?? readMap(new Map<Id, OperationIntent>()), runtime: options.runtime as Runtime });
const bindCoverage=(snapshot:Snapshot<Runtime>,queries:ReturnType<typeof createDocumentQueries>)=>options.queryCoverage?withQueryCoverage(queries,options.queryCoverage(snapshot)):queries;
let queries=bindCoverage(state,createDocumentQueries(document,options.registry,state.revision,options.queryBudget));
const queriesByDocument=new WeakMap([[document,queries]]);
let entered = false, disposed = false, sequence = 0;
for (const intent of state.operations.values()) sequence = Math.max(sequence, intent.sequence);
const listeners = new Set<(commit: Commit, snapshot: Snapshot<Runtime>) => void>();
const createId = options.createId || (() => globalThis.crypto.randomUUID());
const limit = options.historyLimit ?? 100;
if (!Number.isSafeInteger(limit) || limit < 1) throw fault('invalid_history_limit');
function plan(document: Document, command: Command): { document: Document; changes: readonly Change[] } {
const permission = options.policy?.(command, document);
if (permission && !permission.enabled) throw fault(permission.code, permission.targets);
let changes: readonly Change[];
if (command.type === 'canvas.create') {
if (document.canvases.has(command.canvas.id)) throw fault('duplicate_identity', [command.canvas.id]);
changes = [{ kind: 'canvas', id: command.canvas.id, before: null, after: command.canvas }];
} else if (command.type === 'canvas.reparent') {
const canvas = document.canvases.get(command.canvasId);
if (!canvas) throw fault('canvas_not_loaded', [command.canvasId]);
changes = equal(canvas.parent, command.parent) ? [] : [{ kind: 'canvas', id: canvas.id, before: canvas, after: { ...canvas, parent: command.parent } }];
if(changes.length)changes=[...changes,...repairConnectionOwners(document,applyChanges(document,changes))];
} else if(command.type==='canvas.dissolve'){
const dissolved=planDissolveCanvas(document,options.registry,command.canvasId,command.entry);changes=dissolved.changes;
for(const member of dissolved.commands){
const allowed=options.policy?.(member,document);if(allowed&&!allowed.enabled)throw fault(allowed.code,allowed.targets);
}
} else if (command.type === 'element.create') {
const element = command.element;
if (document.elements.has(element.id) || document.tombstones.has(element.id)) throw fault('duplicate_identity', [element.id]);
if (options.registry.resolve(element) !== 'known') throw fault('unsupported_component', [element.id]);
changes = [{ kind: 'element', id: element.id, before: null, after: element }];
} else if (command.type === 'changes.apply') {
if (!options.allowRawChanges) throw fault('raw_changes_not_allowed');
changes = command.changes;
} else {
let element = targetElement(document, command.target);
if (options.registry.resolve(element) !== 'known') throw fault('unsupported_component', [element.id]);
if (command.type === 'connection.path') {
if (element.structure !== 'connection' || !options.registry.supports(element, 'connection.edit')) throw fault('unsupported_capability', [element.id]);
if (!validJson(command.path)) throw fault('invalid_connection_path', [element.id]);
changes = options.registry.handlers.get(element.componentId)!.get('connection.edit')!({ element, input: freeze({ patch: { path: clone(command.path) } }) });
for (const change of changes) if (change.kind !== 'field' || change.field !== 'path' || change.target.id !== element.id || change.target.incarnation !== element.incarnation) throw fault('invalid_handler_change', [element.id]);
} else if(command.type==='connection.endpoints'){
if(element.structure!=='connection'||!options.registry.supports(element,'connection.edit'))throw fault('unsupported_capability',[element.id]);
changes=[];
for(const [field,endpoint]of [['source',command.source],['target',command.destination]] as const)if(endpoint&&!equal(element[field],endpoint))changes=[...changes,{kind:'field',target:command.target,field,before:element[field],after:clone(endpoint)}];
if(changes.length)changes=[...changes,...repairConnectionOwners(document,applyChanges(document,changes))];
} else if (command.type === 'element.remove') {
changes = removalChanges(document,command.target,queriesByDocument.get(document));
} else if (command.type === 'element.move') {
if (!options.registry.supports(element, 'geometry.move')) throw fault('unsupported_capability', [element.id]);
changes = [{ kind: 'field', target: command.target, field: 'ownerCanvasId', before: element.ownerCanvasId, after: command.canvasId }];
if(element.structure==='node'){
const source=canvasTransform(document,element.ownerCanvasId),destination=canvasTransform(document,command.canvasId),presentation=withUnitScale(element.data.presentation,unitScale(element.data.presentation)*source.scale/destination.scale);
if(!equal(presentation,element.data.presentation))changes=[...changes,{kind:'field',target:command.target,field:'data.presentation',before:element.data.presentation??null,beforePresent:Object.hasOwn(element.data,'presentation'),after:presentation??null,afterPresent:presentation!==undefined}];
const geometry=command.geometry??(element.ownerCanvasId===command.canvasId?element.geometry:convertGeometry(element.geometry,canvasTransform(document,element.ownerCanvasId),canvasTransform(document,command.canvasId)));
if(!equal(geometry,element.geometry))changes=[...changes,{kind:'field',target:command.target,field:'geometry',before:element.geometry,after:geometry}];
}
for (const canvas of document.canvases.values()) if (canvas.parent?.entryElementId === element.id) changes = [...changes, { kind: 'canvas', id: canvas.id, before: canvas, after: { ...canvas, parent: { ...canvas.parent, canvasId: command.canvasId } } }];
changes=[...changes,...repairConnectionOwners(document,applyChanges(document,changes))];
} else {
const output: Change[] = [];
const run = (capability: Capability, patch: unknown) => {
const handler = options.registry.handlers.get(element.componentId)?.get(capability);
if (!handler) throw fault('unsupported_capability', [element.id, capability]);
const generated = handler({ element, input: freeze({ patch: clone(patch) }) });
// A handler is scoped to this target; it cannot write another object or domain.
for (const change of generated) if (change.target.id !== element.id || change.target.incarnation !== element.incarnation || (capability === 'content.edit' ? !change.field.startsWith('data.') : change.field !== 'geometry')) throw fault('invalid_handler_change', [element.id]);
output.push(...generated);
element = targetElement(applyChanges(document, output), command.target);
};
if (command.type === 'element.data') run('content.edit', command.patch);
else {
if (element.structure !== 'node' || Object.keys(command.patch).some(key => !['x', 'y', 'width', 'height', 'rotation'].includes(key))) throw fault('invalid_geometry_patch', [element.id]);
const parts: [Capability, Partial<Geometry>][] = [
['geometry.move', Object.fromEntries(Object.entries(command.patch).filter(([key]) => key === 'x' || key === 'y'))],
['geometry.resize', Object.fromEntries(Object.entries(command.patch).filter(([key]) => key === 'width' || key === 'height'))],
['geometry.rotate', Object.fromEntries(Object.entries(command.patch).filter(([key]) => key === 'rotation'))],
];
for (const [capability, patch] of parts) if (Object.keys(patch).length) run(capability, patch);
}
changes = output;
}
}
const next = command.type==='element.create'||command.type==='canvas.create'?stageCreation(document,changes[0] as Extract<Change,{kind:'canvas'|'element'}>):applyChanges(document, changes);
return { document: next, changes: freeze(clone(changes)) };
}
function assertOpen() {
if (disposed) throw fault('editor_disposed');
if (entered) throw fault('reentrant_dispatch');
}
function prepareState(next: Snapshot<Runtime>): Snapshot<Runtime> {
if(!options.prepareRuntime)return next;
const runtime=options.prepareRuntime(state,next);
if(isThenable(runtime))throw fault('async_transaction');
return runtime===next.runtime?next:Object.freeze({...next,runtime});
}
function emit(commit: Commit) {
for (const listener of [...listeners]) try { listener(commit, state); } catch (error) { try { options.onListenerError?.(error); } catch { /* Observers cannot roll back a committed state. */ } }
}
function uniqueId(used: Set<Id>): Id {
const id = createId();
if (!id || used.has(id) || state.operations.has(id) || state.history.entries.has(id)) throw fault('duplicate_operation_id');
used.add(id); return id;
}
function publish(document: Document, changes: readonly Change[], origin: Origin, history: HistoryState, historyEntryId?: Id, historyEffect?: CreationSizeEffect): Applied {
const used = new Set<Id>();
for (const id of history.entries.keys()) used.add(id);
// Canonical remote/ACK/replay updates must never feed back into the outbox.
const outbound = !['remote', 'ack', 'replay'].includes(origin);
const operationIds = Object.freeze(outbound ? [uniqueId(used)] : []);
const operations = new Map(state.operations);
const nextSequence = outbound ? options.nextOperationSequence?.(state.runtime, sequence + 1) ?? sequence + 1 : sequence;
if (!Number.isSafeInteger(nextSequence) || outbound && nextSequence <= sequence) throw fault('invalid_operation_sequence');
if (historyEntryId) {
const entries = new Map(history.entries), entry = entries.get(historyEntryId)!;
entries.set(entry.id, dataRecord(freeze({ ...entry, operationIds: dataArray(freeze([...entry.operationIds, ...operationIds])) })));
history = Object.freeze({ ...history, entries: readMap(entries) });
}
if (outbound) {
const canvasIds = new Set<Id>();
for (const change of changes) {
if (change.kind === 'canvas') {
canvasIds.add(change.id);
if (change.before?.parent) canvasIds.add(change.before.parent.canvasId);
if (change.after?.parent) canvasIds.add(change.after.parent.canvasId);
} else if (change.kind === 'element') {
if (change.before) canvasIds.add(change.before.ownerCanvasId);
if (change.after) canvasIds.add(change.after.ownerCanvasId);
if (!change.before && change.after?.structure === 'connection') {
for (const endpoint of [change.after.source, change.after.target]) {
const element = document.elements.get(endpoint.elementId) ?? state.document.elements.get(endpoint.elementId);
if (element) canvasIds.add(element.ownerCanvasId);
}
}
} else {
const before = state.document.elements.get(change.target.id), after = document.elements.get(change.target.id);
if (before) canvasIds.add(before.ownerCanvasId);
if (after) canvasIds.add(after.ownerCanvasId);
if (change.field === 'ownerCanvasId') { canvasIds.add(change.before as Id); canvasIds.add(change.after as Id); }
if (change.field === 'source' || change.field === 'target') {
for (const endpoint of [change.before, change.after]) {
const id = (endpoint as { elementId?: Id })?.elementId;
const element = id ? document.elements.get(id) ?? state.document.elements.get(id) : undefined;
if (element) canvasIds.add(element.ownerCanvasId);
}
}
}
}
const historyTransition=describeHistoryTransition(state.history,history,state.rootRevision+1,historyEntryId,true);
const intent: OperationIntent = freeze({ id: operationIds[0], operationType: 'document.change', operationSchemaVersion: 1, sequence: nextSequence, origin, canvasIds: [...canvasIds].sort(), changes, dependencies: [],...(historyTransition?{historyTransition}:{}),...(historyEffect?{historyEffect}:{}) });
const prepared=options.prepareIntent?.(intent,state.runtime) ?? intent;
if(prepared.operationType==='run.apply'&&!validJson(prepared.action)||!['document.change','run.apply'].includes(prepared.operationType)||['id','operationSchemaVersion','sequence','origin','canvasIds','changes','dependencies','historyTransition','historyEffect'].some(key=>!equal((prepared as unknown as Record<string,unknown>)[key],(intent as unknown as Record<string,unknown>)[key])))throw fault('invalid_prepared_intent');
operations.set(intent.id, freeze(clone(prepared)));
}
const next: CoreSnapshot = Object.freeze({ revision: state.revision + 1, document, history, operations: readMap(operations) });
const commit = freeze({ revision: next.revision, origin, changes, operationIds });
const runtime = options.reduceRuntime ? options.reduceRuntime(state.runtime, next, commit) : state.runtime;
if (isThenable(runtime)) throw fault('async_transaction');
const nextState=prepareState(Object.freeze({ ...next, rootRevision: state.rootRevision + 1, runtime })),nextQueries=bindCoverage(nextState,advanceDocumentQueries(queries,document,options.registry,next.revision,changes));
state=nextState;queries=nextQueries;queriesByDocument.set(document,queries);
if (outbound) sequence = nextSequence;
emit(commit);
return Object.freeze({ status: 'applied', revision: state.revision, operationIds, ...(historyEntryId ? { historyEntryId } : {}) });
}
function transaction(callback: (transaction: Transaction) => unknown, settings: { label?: string; groupId?: Id; history?: boolean; origin?: Origin } = {}): Result {
try { assertOpen(); } catch (error) { return reject(error); }
entered = true;
let active = true, document = state.document, failed: unknown;
const changes: Change[] = [];
const tx: Transaction = Object.freeze({
dispatch(command: Command): Availability {
if (!active) throw fault('transaction_closed');
if (failed) return { enabled: false, ...reject(failed) };
try {
const result = plan(document, command); document = result.document; changes.push(...result.changes);
return { enabled: true };
} catch (error) { failed = error; const result = reject(error); return { enabled: false, code: result.code, targets: result.targets }; }
},
getDocument() { if (!active) throw fault('transaction_closed'); return document; },
});
try {
const result = callback(tx);
active = false;
if (isThenable(result)) { Promise.resolve(result).catch(() => {}); throw fault('async_transaction'); }
if (failed) throw failed;
document=materializeCreations(document);
validateDocument(document, options.registry);
if (!changes.length || documentsEqual(document, state.document)) return Object.freeze({ status: 'applied', revision: state.revision, operationIds: Object.freeze([]) });
let history = state.history, historyEntryId: Id | undefined;
if (settings.history !== false && (!settings.origin || settings.origin === 'user')) {
const entries = new Map(history.entries), undo = [...history.undo];
for (const id of history.redo) entries.delete(id);
const prior = undo.length ? entries.get(undo[undo.length - 1]) : undefined;
if (settings.groupId && prior?.groupId === settings.groupId) {
historyEntryId = prior.id;
entries.set(prior.id, dataRecord(freeze({ ...prior, changes: dataArray(freeze([...prior.changes, ...changes])) })));
} else {
historyEntryId = uniqueId(new Set());
entries.set(historyEntryId, dataRecord(freeze({ id: historyEntryId, label: settings.label || 'Edit', changes: dataArray(freeze([...changes])), operationIds: dataArray<Id>([]), ...(settings.groupId ? { groupId: settings.groupId } : {}) })));
undo.push(historyEntryId);
}
while (undo.length > limit) entries.delete(undo.shift()!);
history = Object.freeze({ undo: Object.freeze(undo), redo: Object.freeze([]), entries: readMap(entries) });
}
return publish(document, freeze([...changes]), settings.origin || 'user', history, historyEntryId);
} catch (error) { active = false; return reject(error); }
finally { entered = false; }
}
function dispatch(command: Command, settings?: Parameters<typeof transaction>[1]): Result {
return transaction(tx => { tx.dispatch(command); }, settings);
}
function can(command: Command): Availability {
try { if (disposed) throw fault('editor_disposed'); const result = plan(state.document, command); validateDocument(result.document, options.registry); return { enabled: true }; }
catch (error) { const result = reject(error); return { enabled: false, code: result.code, targets: result.targets }; }
}
function traverse(direction: 'undo' | 'redo'): Result {
try { assertOpen(); } catch (error) { return reject(error); }
entered = true;
try {
const source = state.history[direction], id = source[source.length - 1];
if (!id) throw fault('history_empty');
const entry = state.history.entries.get(id)!;
const allowed = options.traversalPolicy?.(entry, direction);
if (allowed && !allowed.enabled) throw fault(allowed.code, allowed.targets);
const planned=direction==='undo'?invert(entry.changes):entry.changes;
const {document,changes,remap,restored}=planHistoryChanges(state.document,planned,options.registry);
const entries=new Map(state.history.entries);
// Later redo entries belong to the lifecycle restored by this explicit history action.
// Frozen operations retain their original identities and incarnations.
if(restored)for(const [otherId,other]of entries)if(otherId!==id)entries.set(otherId,dataRecord(freeze({...other,changes:dataArray(freeze(other.changes.map(remap)))})));
entries.set(id,dataRecord(freeze({...entry,changes:dataArray(freeze(direction==='undo'?invert(changes):changes))})));
const history: HistoryState = Object.freeze({ undo: Object.freeze(direction === 'undo' ? state.history.undo.slice(0, -1) : [...state.history.undo, id]), redo: Object.freeze(direction === 'redo' ? state.history.redo.slice(0, -1) : [...state.history.redo, id]), entries: readMap(entries) });
return publish(document, changes, direction, history, id);
} catch (error) { return reject(error); }
finally { entered = false; }
}
return Object.freeze({ getSnapshot: () => state,getQueries:(document=state.document)=>{const value=queriesByDocument.get(document);if(!value)throw fault('query_snapshot_unavailable');return value;}, registry: options.registry, dispatch, transaction, can,
/** Coordinator entry: document, amended history and outbound fact share one Root. */
resolveCreationSize(effect: CreationSizeEffect, expectedRootRevision: number): Result {
try { assertOpen(); } catch (error) { return reject(error); }
entered = true;
try {
if (expectedRootRevision !== state.rootRevision) throw fault('stale_plan');
validateCreationSizeEffect(effect);
const plan = planCreationSize(state.document, state.history, options.registry, effect.entryId, effect.target, effect.size);
const after = plan.document.elements.get(effect.target.id)!;
if (after.structure !== 'node') throw fault('creation_size_target_changed');
const allowed = options.policy?.({ type: 'element.geometry', target: effect.target, patch: { width: after.geometry.width, height: after.geometry.height } }, state.document);
if (allowed && !allowed.enabled) throw fault(allowed.code, allowed.targets);
if (!plan.changes.length) return Object.freeze({ status: 'applied', revision: state.revision, operationIds: Object.freeze([]) });
return publish(plan.document, plan.changes, 'asset-resolution', plan.history, effect.entryId, freeze(clone(effect)));
} catch (error) { return reject(error); }
finally { entered = false; }
},
subscribe(listener: (commit: Commit, snapshot: Snapshot<Runtime>) => void) { if (disposed) throw fault('editor_disposed'); listeners.add(listener); return () => { listeners.delete(listener); }; },
/** Engine-only metadata commit, sharing the document/history publication boundary. */
updateRuntime(reduce: (runtime: Runtime) => Runtime, expectedRootRevision = state.rootRevision): Result {
try { assertOpen(); } catch (error) { return reject(error); }
entered = true;
try {
if (expectedRootRevision !== state.rootRevision) throw fault('stale_plan');
const runtime = reduce(state.runtime);
if (isThenable(runtime)) throw fault('async_transaction');
if (runtime !== state.runtime) {
const nextState=prepareState(Object.freeze({ ...state, rootRevision: state.rootRevision + 1, runtime })),nextQueries=bindCoverage(nextState,queries);
state=nextState;queries=nextQueries;queriesByDocument.set(state.document,queries);
emit(freeze({ revision: state.revision, origin: 'ack', changes: [], operationIds: [] }));
}
return Object.freeze({ status: 'applied', revision: state.revision, operationIds: Object.freeze([]) });
} catch (error) { return reject(error); }
finally { entered = false; }
},
/** Engine-only canonical proposal: validation and runtime state publish as one root. */
reconcile(document: Document, runtime: Runtime, expectedRootRevision: number, replacement?: { readonly history: HistoryState; readonly operations: ReadonlyMap<Id,OperationIntent> }): Result {
try { assertOpen(); } catch (error) { return reject(error); }
entered = true;
try {
if (expectedRootRevision !== state.rootRevision) throw fault('stale_plan');
if (document.projectId !== state.document.projectId) throw fault('project_mismatch');
const next = createDocument(document.projectId, [...document.canvases.values()], [...document.elements.values()], document.tombstones);
validateDocument(next, options.registry);
let nextSequence=sequence;
if(replacement){
const {history,operations}=replacement,ids=[...history.undo,...history.redo];
if(new Set(ids).size!==ids.length||ids.length!==history.entries.size||ids.some(id=>!history.entries.has(id)))throw fault('invalid_history');
for(const [id,intent]of state.operations)if(!equal(intent,operations.get(id)))throw fault('operation_identity_conflict',[id]);
for(const [id,intent]of operations){if(id!==intent.id||!Number.isSafeInteger(intent.sequence)||intent.sequence<1)throw fault('invalid_operation_sequence');nextSequence=Math.max(nextSequence,intent.sequence);}
for(const [id,entry]of history.entries)if(entry.id!==id||entry.operationIds.some(operationId=>!operations.has(operationId)))throw fault('invalid_history');
}
const changed = !documentsEqual(next, state.document);
const nextState=prepareState(Object.freeze({ ...state, ...(replacement?{history:replacement.history,operations:replacement.operations}:{}), document: changed ? next : state.document, runtime, revision: state.revision + Number(changed), rootRevision: state.rootRevision + 1 })),nextQueries=bindCoverage(nextState,changed?advanceDocumentQueries(queries,next,options.registry,state.revision+1):queries);
state=nextState;queries=nextQueries;queriesByDocument.set(state.document,queries);
sequence=nextSequence;
emit(freeze({ revision: state.revision, origin: 'ack', changes: [], operationIds: [] }));
return Object.freeze({ status: 'applied', revision: state.revision, operationIds: Object.freeze([]) });
} catch (error) { return reject(error); }
finally { entered = false; }
},
history: Object.freeze({ undo: () => traverse('undo'), redo: () => traverse('redo'), canUndo: () => !!state.history.undo.length, canRedo: () => !!state.history.redo.length }),
dispose() { disposed = true; listeners.clear(); },
});
}
function documentsEqual(a: Document, b: Document): boolean {
if(a===b)return true;
if(a.canvases.size!==b.canvases.size||a.elements.size!==b.elements.size||a.tombstones.size!==b.tombstones.size)return false;
return equal([...a.canvases], [...b.canvases]) && equal([...a.elements], [...b.elements]) && equal([...a.tombstones], [...b.tombstones]);
}
+25
View File
@@ -0,0 +1,25 @@
import type {ElementRecord,ComponentDefinition} from './types.ts';
import {detachJsonData} from './json-data.ts';
// Provenance comes from detaching JSON data, never from an external freeze or
// document envelope. This does not assert record validity or registry support.
const owned=new WeakSet<object>();
export function detachElementData(record:ElementRecord):ElementRecord{
if(record&&typeof record==='object'&&owned.has(record))return record;
const value=detachJsonData(record);
if(value&&typeof value==='object')owned.add(value);
return value;
}
export const hasOwnedElementData=(record:ElementRecord)=>!!record&&typeof record==='object'&&owned.has(record);
// Framework-owned validators depend only on detached immutable data. Custom
// validators remain live, including their method receiver and external context.
const builtinValidators=new WeakSet<Function>(),dataProofs=new WeakMap<object,ComponentDefinition['validate']>();
export function builtinDataValidator<T extends ComponentDefinition['validate']>(validate:T):T{builtinValidators.add(validate);return validate;}
export function componentDataValid(record:ElementRecord,definition:ComponentDefinition):boolean{
const validate=definition.validate,reusable=hasOwnedElementData(record)&&builtinValidators.has(validate);
if(reusable&&dataProofs.get(record)===validate)return true;
const valid=Reflect.apply(validate,definition,[record.data]);
if(valid&&reusable)dataProofs.set(record,validate);
return valid;
}
+74
View File
@@ -0,0 +1,74 @@
import {builtinDataValidator} from './element-data.ts';
import {rectangleGeometry,bezierChainSegments,type BezierChain} from './geometry.ts';
import type { CapabilityHandler, ComponentDefinition, Data, FieldChange, Geometry, Json } from './types.ts';
import { defineComponent, defineFeature } from './registry.ts';
import { presentationUnitsValid, unitScale } from './units.ts';
import { equal, fault, validJson } from './values.ts';
const geometryHandler = (allowed: readonly (keyof Geometry)[], constrain?: (geometry: Geometry) => Geometry): CapabilityHandler => ({ element, input }) => {
if (element.structure !== 'node') throw fault('unsupported_capability', [element.id]);
const patch = input.patch as Partial<Geometry>;
if (!patch || Object.values(patch).some(value => typeof value !== 'number' || !Number.isFinite(value)) || Object.keys(patch).some(key => !allowed.includes(key as keyof Geometry))) throw fault('invalid_geometry_patch', [element.id]);
const after = constrain ? constrain({ ...element.geometry, ...patch }) : { ...element.geometry, ...patch };
return equal(element.geometry, after) ? [] : [{ kind: 'field', target: { id: element.id, incarnation: element.incarnation }, field: 'geometry', before: element.geometry, after }];
};
export const movable = () => defineFeature('geometry.move', { 'geometry.move': geometryHandler(['x', 'y']) });
export const resizable = (inputOptions: { min?: number; max?: number; aspect?: boolean } = {}) => {
const options = Object.freeze({ ...inputOptions });
if (options.min !== undefined && (!Number.isFinite(options.min) || options.min <= 0) || options.max !== undefined && (!Number.isFinite(options.max) || options.max < (options.min ?? 1))) throw fault('invalid_resize_constraint');
return defineFeature('geometry.resize', {
'geometry.resize': ({ element, input }) => {
if (element.structure !== 'node') throw fault('unsupported_capability', [element.id]);
const patch = input.patch as Partial<Geometry>;
return geometryHandler(['width', 'height'], geometry => {
const units=unitScale(element.data.presentation),min=(options.min??1)*units,max=(options.max??100000)*units;
let width = Math.min(max, Math.max(min, geometry.width)), height = Math.min(max, Math.max(min, geometry.height));
if (options.aspect) {
const ratio = element.geometry.width / element.geometry.height;
if (patch.width !== undefined) height = width / ratio; else width = height * ratio;
if (width < min || height < min || width > max || height > max) throw fault('aspect_constraint', [element.id]);
}
return { ...geometry, width, height };
})({ element, input });
},
});
};
export const rotatable = () => defineFeature('geometry.rotate', { 'geometry.rotate': geometryHandler(['rotation']) });
export const editable = () => defineFeature('content.edit', {
'content.edit': ({ element, input }) => {
const patch = input.patch as Data;
if (!patch || !validJson(patch) || Array.isArray(patch)) throw fault('invalid_data_patch', [element.id]);
return Object.entries(patch).flatMap(([key, after]): FieldChange[] => {
const before = element.data[key] ?? null;
return Object.hasOwn(element.data, key) && equal(before, after) ? [] : [{ kind: 'field', target: { id: element.id, incarnation: element.incarnation }, field: `data.${key}`, before, after, beforePresent: Object.hasOwn(element.data, key), afterPresent: true }];
});
},
});
const textData = builtinDataValidator((data: Data) => presentationUnitsValid(data.presentation) && validJson(data) && typeof data.title === 'string' && typeof data.text === 'string');
export function builtinComponents(): readonly ComponentDefinition[] {
return ['text', 'image', 'script', 'plain-text', 'plain-image', 'icon', 'frame'].map(id => defineComponent({
id, dataVersion: 1, structure: 'node', validate: textData,
features: [movable(), resizable(), ...(id === 'frame' ? [] : [rotatable()]), editable()],
resourceFields: ['image', 'plain-image'].includes(id) ? ['assetId','asset'] : [],
geometry:rectangleGeometry,copy: { references: id === 'frame' ? [{path:['ports','*','nodeId'],target:'element',external:'reject'}] : [], omit: [['taskId'], ['runId'], ['progress'], ['runStatus']] },
})).concat([defineComponent({ id: 'connection', dataVersion: 1, structure: 'connection', validate: builtinDataValidator((data:Data) => validJson(data)), features: [editable(), connectionEditable()], resourceFields: [],geometry:{kind:'connection',version:1,strokeWidth:({element})=>{const appearance=element.data.appearance;return (appearance&&typeof appearance==='object'&&!Array.isArray(appearance)?appearance.width:undefined) as number||1.5;},segments:context=>{if(context.displayPath===undefined||!connectionPathValid(context.displayPath))throw fault('unsupported_connection_geometry');return bezierChainSegments(context.source,context.target,context.displayPath as unknown as BezierChain|null);}}, copy: { references: [], omit: [] } })]);
}
export function connectionPathValid(path: Json): boolean {
if (path === null) return true;
if (typeof path !== 'object' || Array.isArray(path) || path.type !== 'bezier-chain' || !Array.isArray(path.knots) || path.knots.length > 64) return false;
const point = (value: Json | undefined) => value && typeof value === 'object' && !Array.isArray(value) && typeof value.x === 'number' && typeof value.y === 'number' && Number.isFinite(value.x) && Number.isFinite(value.y);
if (path.startControl !== undefined && !point(path.startControl) || path.endControl !== undefined && !point(path.endControl)) return false;
const ids = new Set<string>();
for (const knot of path.knots) {
if (!knot || typeof knot !== 'object' || Array.isArray(knot) || typeof knot.id !== 'string' || !knot.id || ids.has(knot.id) || !['corner', 'smooth', 'symmetric'].includes(knot.mode as string) || !point(knot.position) || !point(knot.in) || !point(knot.out)) return false;
ids.add(knot.id);
}
return true;
}
export const connectionEditable = () => defineFeature('connection.path', { 'connection.edit': ({ element, input }) => {
const path = (input.patch as { path?: Json }).path;
if (element.structure !== 'connection' || !connectionPathValid(element.path)) throw fault('unsupported_connection_path', [element.id]);
if (path === undefined || !validJson(path) || !connectionPathValid(path)) throw fault('invalid_connection_path', [element.id]);
return equal(element.path, path) ? [] : [{ kind: 'field', target: { id: element.id, incarnation: element.incarnation }, field: 'path', before: element.path, after: path }];
} });
+10
View File
@@ -0,0 +1,10 @@
import type {Geometry,Point} from './types.ts';
export type Rect={readonly x:number;readonly y:number;readonly width:number;readonly height:number};
export type Cubic=readonly[Point,Point,Point,Point];
export type Connector={readonly point:Point;readonly normal:Point};
export type BezierChain={readonly knots:readonly{readonly position:Point;readonly in:Point;readonly out:Point}[];readonly startControl?:Point;readonly endControl?:Point};
export function rectangleBounds(g:Geometry):Rect{const angle=g.rotation*Math.PI/180,c=Math.abs(Math.cos(angle)),s=Math.abs(Math.sin(angle)),width=g.width*c+g.height*s,height=g.width*s+g.height*c;return{x:g.x+g.width/2-width/2,y:g.y+g.height/2-height/2,width,height};}
export function hitRectangle(g:Geometry,point:Point,tolerance=0):boolean{const angle=-g.rotation*Math.PI/180,c=Math.cos(angle),s=Math.sin(angle),dx=point.x-g.x-g.width/2,dy=point.y-g.y-g.height/2,x=dx*c-dy*s,y=dx*s+dy*c,ox=Math.max(0,Math.abs(x)-g.width/2),oy=Math.max(0,Math.abs(y)-g.height/2);return Math.hypot(ox,oy)<=tolerance+Number.EPSILON*64*Math.max(1,Math.abs(point.x),Math.abs(point.y),g.width,g.height);}
export const rectangleGeometry=Object.freeze({kind:'node' as const,version:1 as const,dataDependencies:Object.freeze([] as string[]),bounds:({geometry}:{geometry:Geometry})=>rectangleBounds(geometry),hit:({geometry,point,tolerance}:{geometry:Geometry;point:Point;tolerance:number})=>hitRectangle(geometry,point,tolerance)});
/** Shared by the built-in model protocol and the SVG renderer; all arguments are in one display space. */
export function bezierChainSegments(from:Connector,to:Connector,path?:BezierChain|null):Cubic[]{const reach=Math.min(240,Math.max(40,Math.hypot(to.point.x-from.point.x,to.point.y-from.point.y)*.4)),knots=path?.knots??[],anchors=[from.point,...knots.map(knot=>knot.position),to.point],add=(a:Point,b:Point)=>({x:a.x+b.x,y:a.y+b.y});return anchors.slice(0,-1).map((point,index)=>[point,add(point,index?knots[index-1].out:path?.startControl??{x:from.normal.x*reach,y:from.normal.y*reach}),add(anchors[index+1],index<knots.length?knots[index].in:path?.endControl??{x:to.normal.x*reach,y:to.normal.y*reach}),anchors[index+1]]);}
+30
View File
@@ -0,0 +1,30 @@
import type {Document,Geometry,Id,Registry} from './types.ts';
import {fault} from './values.ts';
import {unitScale} from './units.ts';
type PlacementStep={canvasId:Id;parentCanvasId:Id;entryId:Id;incarnation:number;componentId:string;dataVersion:number;geometry:Geometry;contentScale:number;unitScale:number};
/** A short-lived reader of the visible owner chain relative to one view, not the project root.
* Cache by owner only during this admission/publication, so multi-selection shares ancestry work. */
export function createGesturePlacementReader(document:Document,viewCanvasId:Id,registry:Registry){
const values=new Map<Id,readonly PlacementStep[]>();
return(ownerCanvasId:Id):readonly PlacementStep[]=>{
const cached=values.get(ownerCanvasId);if(cached)return cached;
if(!document.canvases.has(viewCanvasId))throw fault('canvas_missing',[viewCanvasId]);
const chain:PlacementStep[]=[],seen=new Set<Id>();let id=ownerCanvasId;
while(id!==viewCanvasId){
if(seen.has(id))throw fault('canvas_cycle',[id]);seen.add(id);
const parent=document.canvases.get(id)?.parent;
if(!parent)throw fault('target_outside_view',[ownerCanvasId]);
const entry=document.elements.get(parent.entryElementId);
// A future/unknown entry is a collapsed placeholder; never interpret its payload.
if(!entry||entry.structure!=='node'||registry.resolve(entry)!=='known')throw fault('target_hidden',[ownerCanvasId]);
const raw=entry.data.presentation,presentation=raw&&typeof raw==='object'&&!Array.isArray(raw)?raw:{};
if(presentation.collapsed===true)throw fault('target_hidden',[ownerCanvasId]);
const contentScale=typeof presentation.contentScale==='number'&&presentation.contentScale>0?presentation.contentScale:1;
chain.push({canvasId:id,parentCanvasId:parent.canvasId,entryId:entry.id,incarnation:entry.incarnation,componentId:entry.componentId,dataVersion:entry.dataVersion,geometry:entry.geometry,contentScale,unitScale:unitScale(raw)});
id=parent.canvasId;
}
values.set(ownerCanvasId,chain);return chain;
};
}
+189
View File
@@ -0,0 +1,189 @@
import type { Editor } from './editor.ts';
import type { Availability, Capability, ElementRecord, Geometry, Id, Json, Result, Target } from './types.ts';
import { geometryValid, targetElement } from './document.ts';
import {createGesturePlacementReader} from './gesture-placement.ts';
import { clone, equal, fault, freeze, readMap, readSet, reject, validJson } from './values.ts';
export type ViewState = { readonly id: Id; readonly canvasId: Id; readonly selection: ReturnType<typeof readSet<Id>>; readonly viewport: { readonly x: number; readonly y: number; readonly zoom: number }; readonly tool: 'select' | 'pan'; readonly modes: ReadonlyMap<Id, string> };
export type GestureState = { readonly token: number; readonly viewId: Id; readonly targets: readonly Target[]; readonly preview: ReadonlyMap<Id, Geometry>; readonly phase: 'active' | 'committing' };
export type ConnectionGesture = { readonly token: number; readonly viewId: Id; readonly target: Target; readonly path: Json; readonly phase: 'active' | 'committing' };
export type SessionSnapshot = { readonly views: ReadonlyMap<Id, ViewState>; readonly gesture: GestureState | null; readonly connectionGesture: ConnectionGesture | null; readonly lastCancelReason: string | null };
/** Transient interaction state belongs to views; no pointer event enters the ledger. */
export function createSessions(editor: Editor) {
let snapshot: SessionSnapshot = Object.freeze({ views: readMap(new Map<Id, ViewState>()), gesture: null, connectionGesture: null, lastCancelReason: null });
let baseline = new Map<Id, ElementRecord>(), connectionBaseline: ElementRecord | undefined, disposed = false, nextGestureToken = 0;
// Compare adjacent published identities without duplicating the per-view mode table.
let publishedDocument=editor.getSnapshot().document;
const listeners = new Set<() => void>();
const notify = () => { for (const listener of [...listeners]) try { listener(); } catch { /* A renderer cannot invalidate model commits. */ } };
function publishViews(views:Map<Id,ViewState>,cancelOwnedView?:Id,reason='view_interrupted'){
const owned=cancelOwnedView!==undefined&&(snapshot.gesture?.viewId===cancelOwnedView||snapshot.connectionGesture?.viewId===cancelOwnedView);
if(owned){baseline.clear();connectionBaseline=undefined;}
snapshot=Object.freeze({...snapshot,views:readMap(views),...(owned?{gesture:null,connectionGesture:null,lastCancelReason:reason}:{})});notify();
}
function setView(view: ViewState,cancelOwnedReason?:string) { const views = new Map(snapshot.views); views.set(view.id, view); publishViews(views,cancelOwnedReason?view.id:undefined,cancelOwnedReason); }
function cancel(reason = 'canceled') {
if (!snapshot.gesture && !snapshot.connectionGesture) return;
baseline.clear(); connectionBaseline = undefined; snapshot = Object.freeze({ ...snapshot, gesture: null, connectionGesture: null, lastCancelReason: reason }); notify();
}
const detachEditor = editor.subscribe(() => {
const document = editor.getSnapshot().document,beforeDocument=publishedDocument;publishedDocument=document;
const views = new Map(snapshot.views); let changed = false;
for (const [id, view] of views) {
const canvasMissing = !document.canvases.has(view.canvasId);
const selection = [...view.selection].filter(target => document.elements.has(target)), modes = new Map([...view.modes].filter(([target]) => {
const before=beforeDocument.elements.get(target),after=document.elements.get(target);
return !!before&&!!after&&before.incarnation===after.incarnation&&before.ownerCanvasId===after.ownerCanvasId&&before.structure===after.structure&&before.componentId===after.componentId&&before.dataVersion===after.dataVersion;
}));
if (canvasMissing || selection.length !== view.selection.size || modes.size !== view.modes.size) { views.set(id, freeze({ ...view, canvasId: canvasMissing ? [...document.canvases.values()].find(canvas => !canvas.parent)!.id : view.canvasId, selection: readSet(canvasMissing ? [] : selection), modes: readMap(canvasMissing ? new Map() : modes) })); changed = true; }
}
let cancelReason:string|undefined;
const active=snapshot.gesture?.phase==='active'?snapshot.gesture:snapshot.connectionGesture?.phase==='active'?snapshot.connectionGesture:undefined;
const activeView=active?snapshot.views.get(active.viewId):undefined;
if(activeView&&!document.canvases.has(activeView.canvasId))cancelReason='canvas_changed';
const oldPlacement=activeView?createGesturePlacementReader(beforeDocument,activeView.canvasId,editor.registry):undefined,newPlacement=activeView?createGesturePlacementReader(document,activeView.canvasId,editor.registry):undefined;
const placementChanged=(owner:Id)=>{if(beforeDocument===document)return false;try{return !oldPlacement||!newPlacement||!equal(oldPlacement(owner),newPlacement(owner));}catch{return true;}};
if (!cancelReason&&snapshot.connectionGesture?.phase === 'active') {
const current = document.elements.get(snapshot.connectionGesture.target.id), before = connectionBaseline;
if (!current || current.structure !== 'connection' || before?.structure !== 'connection' || current.incarnation !== before.incarnation || current.ownerCanvasId !== before.ownerCanvasId || current.componentId!==before.componentId || current.dataVersion!==before.dataVersion || !equal(current.path, before.path)) cancelReason='target_changed';
else if(placementChanged(current.ownerCanvasId))cancelReason='coordinate_context_changed';
else if (!editor.can({ type: 'connection.path', target: snapshot.connectionGesture.target, path: current.path }).enabled) cancelReason='permission_changed';
}
if (snapshot.gesture?.phase === 'active'&&!cancelReason) for (const target of snapshot.gesture.targets) {
const before = baseline.get(target.id), current = document.elements.get(target.id);
if (!current || current.structure !== 'node' || before?.structure !== 'node' || current.incarnation !== before.incarnation || current.ownerCanvasId !== before.ownerCanvasId || current.componentId!==before.componentId || current.dataVersion!==before.dataVersion || !equal(current.geometry, before.geometry)) { cancelReason='target_changed'; break; }
if(placementChanged(current.ownerCanvasId)){cancelReason='coordinate_context_changed';break;}
if (!editor.can({ type: 'element.geometry', target, patch: {} }).enabled) { cancelReason='permission_changed'; break; }
}
if(changed||cancelReason){
if(cancelReason){baseline.clear();connectionBaseline=undefined;}
snapshot=Object.freeze({...snapshot,...(changed?{views:readMap(views)}:{}),...(cancelReason?{gesture:null,connectionGesture:null,lastCancelReason:cancelReason}:{})});notify();
}
});
function view(id: Id) { const found = snapshot.views.get(id); if (!found || disposed) throw fault('view_detached', [id]); return found; }
function update(viewId: Id, target: Target, patch: Partial<Geometry>): Availability {
try {
view(viewId);
if (snapshot.gesture?.viewId !== viewId || !snapshot.gesture.targets.some(value => value.id === target.id && value.incarnation === target.incarnation)) throw fault('gesture_not_owned', [target.id]);
const original = targetElement(editor.getSnapshot().document, target);
if (original.structure !== 'node') throw fault('unsupported_capability', [target.id]);
if (Object.keys(patch).some(key => !['x', 'y', 'width', 'height', 'rotation'].includes(key))) throw fault('invalid_geometry_patch', [target.id]);
let element: ElementRecord = freeze({ ...original, geometry: snapshot.gesture.preview.get(target.id) || original.geometry });
const parts: [Capability, Partial<Geometry>][] = [
['geometry.move', Object.fromEntries(Object.entries(patch).filter(([key]) => key === 'x' || key === 'y'))],
['geometry.resize', Object.fromEntries(Object.entries(patch).filter(([key]) => key === 'width' || key === 'height'))],
['geometry.rotate', Object.fromEntries(Object.entries(patch).filter(([key]) => key === 'rotation'))],
];
for (const [capability, value] of parts) if (Object.keys(value).length) {
if (!editor.registry.supports(element, capability)) throw fault('unsupported_capability', [target.id]);
const changes = editor.registry.handlers.get(element.componentId)!.get(capability)!({ element, input: freeze({ patch: value }) });
for (const change of changes) {
if (change.field !== 'geometry' || change.target.id !== target.id || change.target.incarnation !== target.incarnation) throw fault('invalid_handler_change', [target.id]);
const geometry = change.after as Geometry;
if (!geometryValid(geometry)) throw fault('invalid_geometry', [target.id]);
element = freeze({ ...element, geometry });
}
}
const preview = new Map(snapshot.gesture.preview); preview.set(target.id, (element as ElementRecord & { structure: 'node' }).geometry);
snapshot = Object.freeze({ ...snapshot, gesture: freeze({ ...snapshot.gesture, preview: readMap(preview) }) }); notify();
return { enabled: true };
} catch (error) { const result = reject(error); return { enabled: false, code: result.code, targets: result.targets }; }
}
return Object.freeze({
getSnapshot: () => snapshot,
attach(id: Id, canvasId = [...editor.getSnapshot().document.canvases.values()].find(canvas => !canvas.parent)!.id) {
if (disposed || snapshot.views.has(id)) throw fault('duplicate_view', [id]);
if (!editor.getSnapshot().document.canvases.has(canvasId)) throw fault('canvas_missing', [canvasId]);
setView(freeze({ id, canvasId, selection: readSet<Id>([]), viewport: { x: 0, y: 0, zoom: 1 }, tool: 'select', modes: readMap(new Map<Id, string>()) }));
let attached=true;return () => { if(!attached)return;attached=false;if(!snapshot.views.has(id))return;const views=new Map(snapshot.views);views.delete(id);publishViews(views,id,'view_detached'); };
},
canvas(id: Id, canvasId: Id) {
const current = view(id);
if (!editor.getSnapshot().document.canvases.has(canvasId)) throw fault('canvas_missing', [canvasId]);
setView(freeze({ ...current, canvasId, selection: readSet<Id>([]), modes: readMap(new Map<Id, string>()) }),'canvas_changed');
},
select(id: Id, ids: readonly Id[]) {
const current = view(id), document = editor.getSnapshot().document;
for (const target of ids) if (!document.elements.has(target)) throw fault('element_missing', [target]);
setView(freeze({ ...current, selection: readSet(ids) }));
},
viewport(id: Id, viewport: ViewState['viewport']) {
if (![viewport.x, viewport.y, viewport.zoom].every(Number.isFinite) || viewport.zoom <= 0) throw fault('invalid_viewport');
setView(freeze({ ...view(id), viewport: { ...viewport } }));
},
tool(id: Id, tool: ViewState['tool']) { const current=view(id);if(tool!=='select'&&tool!=='pan')throw fault('invalid_tool');if(current.tool===tool)return;setView(freeze({ ...current, tool }),'tool_changed'); },
mode(id: Id, target: Id, mode: string | null) { const current = view(id);if(mode!==null&&typeof mode!=='string')throw fault('invalid_mode');if(mode&&!editor.getSnapshot().document.elements.has(target))throw fault('element_missing',[target]);if((current.modes.get(target)??null)===(mode||null))return;const modes = new Map(current.modes); if (mode) modes.set(target, mode); else modes.delete(target); setView(freeze({ ...current, modes: readMap(modes) })); },
begin(viewId: Id, targets: readonly Target[]): Availability {
try {
if(view(viewId).tool!=='select')throw fault('tool_not_select');
if (snapshot.gesture || snapshot.connectionGesture || !targets.length || new Set(targets.map(target => target.id)).size !== targets.length) throw fault('gesture_busy');
const placement=createGesturePlacementReader(editor.getSnapshot().document,view(viewId).canvasId,editor.registry);
const plannedBaseline = new Map(targets.map(target => { const element = targetElement(editor.getSnapshot().document, target); if (element.structure !== 'node' || editor.registry.resolve(element) !== 'known') throw fault('unsupported_component', [target.id]); return [target.id, element]; }));
for (const target of targets) { placement(plannedBaseline.get(target.id)!.ownerCanvasId);const allowed = editor.can({ type: 'element.geometry', target, patch: {} }); if (!allowed.enabled) throw fault(allowed.code, allowed.targets); }
baseline=plannedBaseline;
snapshot = Object.freeze({ ...snapshot, lastCancelReason: null, gesture: freeze({ token: ++nextGestureToken, viewId, targets: targets.map(target => ({ ...target })), preview: readMap(new Map<Id, Geometry>()), phase: 'active' as const }) }); notify();
return { enabled: true };
} catch (error) { const result = reject(error); return { enabled: false, code: result.code, targets: result.targets }; }
},
beginConnection(viewId: Id, target: Target): Availability {
try {
if(view(viewId).tool!=='select')throw fault('tool_not_select');
if (snapshot.gesture || snapshot.connectionGesture) throw fault('gesture_busy');
const element = targetElement(editor.getSnapshot().document, target);
if (element.structure !== 'connection') throw fault('unsupported_capability', [target.id]);
createGesturePlacementReader(editor.getSnapshot().document,view(viewId).canvasId,editor.registry)(element.ownerCanvasId);
const allowed = editor.can({ type: 'connection.path', target, path: element.path });
if (!allowed.enabled) throw fault(allowed.code, allowed.targets);
connectionBaseline = element;
snapshot = Object.freeze({ ...snapshot, lastCancelReason: null, connectionGesture: freeze({ token: ++nextGestureToken, viewId, target: { ...target }, path: element.path, phase: 'active' as const }) }); notify();
return { enabled: true };
} catch (error) { const result = reject(error); return { enabled: false, code: result.code, targets: result.targets }; }
},
updateConnection(viewId: Id, path: Json): Availability {
try {
view(viewId);
if (snapshot.connectionGesture?.viewId !== viewId) throw fault('gesture_not_owned');
if (!validJson(path)) throw fault('invalid_connection_path');
snapshot = Object.freeze({ ...snapshot, connectionGesture: freeze({ ...snapshot.connectionGesture, path: clone(path) }) }); notify();
return { enabled: true };
} catch (error) { const result = reject(error); return { enabled: false, code: result.code, targets: result.targets }; }
},
commitConnection(viewId: Id): Result {
try {
view(viewId);
const gesture = snapshot.connectionGesture;
if (!gesture || gesture.viewId !== viewId) throw fault('gesture_not_owned');
snapshot = Object.freeze({ ...snapshot, connectionGesture: freeze({ ...gesture, phase: 'committing' as const }) });
const result = editor.dispatch({ type: 'connection.path', target: gesture.target, path: gesture.path }, { label: 'Edit connection' });
cancel(result.status === 'rejected' ? result.code : 'committed'); return result;
} catch (error) { cancel('commit_failed'); return reject(error); }
},
update,
commit(viewId: Id): Result {
try {
view(viewId);
const gesture = snapshot.gesture;
if (!gesture || gesture.viewId !== viewId) throw fault('gesture_not_owned');
snapshot = Object.freeze({ ...snapshot, gesture: freeze({ ...gesture, phase: 'committing' as const }) });
const result = editor.transaction(tx => {
for (const [id, geometry] of gesture.preview) {
const original = baseline.get(id);
if (!original || original.structure !== 'node') throw fault('target_changed', [id]);
const patch = Object.fromEntries(Object.entries(geometry).filter(([key, value]) => !equal(original.geometry[key as keyof Geometry], value)));
tx.dispatch({ type: 'element.geometry', target: { id, incarnation: original.incarnation }, patch });
}
}, { label: 'Transform' });
cancel(result.status === 'rejected' ? result.code : 'committed');
return result;
} catch (error) { cancel('commit_failed'); return reject(error); }
},
cancel,
effectiveGeometry(id: Id): Geometry | undefined {
const element = editor.getSnapshot().document.elements.get(id);
return snapshot.gesture?.preview.get(id) || (element?.structure === 'node' ? element.geometry : undefined);
},
subscribe(listener: () => void) { if (disposed) throw fault('sessions_disposed'); listeners.add(listener); return () => { listeners.delete(listener); }; },
dispose() { if(disposed)return;disposed = true;detachEditor();baseline.clear();connectionBaseline=undefined;snapshot = Object.freeze({ ...snapshot, views: readMap(new Map<Id, ViewState>()),gesture:null,connectionGesture:null,lastCancelReason:'disposed' }); notify();listeners.clear(); },
});
}
+71
View File
@@ -0,0 +1,71 @@
import type { Change, HistoryEntry, HistoryState, Id } from './types.ts';
import { equal, fault, freeze, readMap, validJson } from './values.ts';
type Splice<T>={readonly offset:number;readonly beforeLength:number;readonly afterLength:number;readonly before:readonly T[];readonly after:readonly T[]};
type Meta={readonly label:string;readonly groupId?:Id};
type EntryPatch={readonly id:Id;readonly before:Meta|null;readonly after:Meta|null;readonly changes:Splice<Change>;readonly operationIds:Splice<Id>};
export type HistoryTransition={readonly version:1;readonly rootRevision:number;readonly entryId?:Id;readonly before:{readonly undo:readonly Id[];readonly redo:readonly Id[]};readonly after:{readonly undo:readonly Id[];readonly redo:readonly Id[]};readonly entries:readonly EntryPatch[]};
// Resource scans revisit the same transition across many durability cuts. Cache
// only its flattened changes, never registry-dependent references or callbacks.
const transitionChanges=new WeakMap<HistoryTransition,readonly Change[]>();
const immutableData=new WeakSet<object>();
function immutablePlainData(value:unknown,depth=0):boolean{
if(depth>64)return false;
if(value===null||typeof value!=='object')return typeof value!=='function';
if(immutableData.has(value))return true;
if(!Object.isFrozen(value)||Object.getPrototypeOf(value)!==(Array.isArray(value)?Array.prototype:Object.prototype))return false;
if(Array.isArray(value))for(let i=0;i<value.length;i++)if(!Object.hasOwn(value,i))return false;
// Inspect every own descriptor, including non-enumerable properties. A frozen
// getter or a shallow-frozen container cannot establish reusable data.
for(const key of Reflect.ownKeys(value)){
const descriptor=Object.getOwnPropertyDescriptor(value,key)!;
if(!Object.hasOwn(descriptor,'value')||!immutablePlainData(descriptor.value,depth+1))return false;
}
immutableData.add(value);return true;
}
const meta=(entry:HistoryEntry|undefined):Meta|null=>{if(!entry)return null;const{id:_id,changes:_changes,operationIds:_operations,...remaining}=entry;return remaining;};
function splice<T>(before:readonly T[],after:readonly T[]):Splice<T>{
let offset=0,suffix=0;while(offset<before.length&&offset<after.length&&equal(before[offset],after[offset]))offset++;
while(suffix<before.length-offset&&suffix<after.length-offset&&equal(before[before.length-suffix-1],after[after.length-suffix-1]))suffix++;
return{offset,beforeLength:before.length,afterLength:after.length,before:before.slice(offset,before.length-suffix),after:after.slice(offset,after.length-suffix)};
}
/** Store changed ranges only. Clearing redo/eviction still retains the removed entry records needed to reverse that transition. */
export function describeHistoryTransition(before:HistoryState,after:HistoryState,rootRevision:number,entryId?:Id,recordNoop=false):HistoryTransition|undefined{
const entries:EntryPatch[]=[];
for(const id of new Set([...before.entries.keys(),...after.entries.keys()])){const a=before.entries.get(id),b=after.entries.get(id);if(equal(a,b))continue;entries.push({id,before:meta(a),after:meta(b),changes:splice(a?.changes??[],b?.changes??[]),operationIds:splice(a?.operationIds??[],b?.operationIds??[])});}
if(!recordNoop&&!entries.length&&equal(before.undo,after.undo)&&equal(before.redo,after.redo))return undefined;
return freeze({version:1,rootRevision,...(entryId?{entryId}:{}),before:{undo:before.undo,redo:before.redo},after:{undo:after.undo,redo:after.redo},entries});
}
export function validateHistoryTransition(value:unknown,checkChanges?:(value:unknown)=>unknown):asserts value is HistoryTransition{
const object=(v:unknown):Record<string,unknown>=>{if(!v||typeof v!=='object'||Array.isArray(v))throw fault('invalid_history_transition');return v as Record<string,unknown>;};
const integer=(v:unknown)=>{if(typeof v!=='number'||!Number.isSafeInteger(v)||v<0)throw fault('invalid_history_transition');};
const id=(v:unknown)=>{if(typeof v!=='string'||!v)throw fault('invalid_history_transition');};
const ids=(v:unknown):string[]=>{if(!Array.isArray(v))throw fault('invalid_history_transition');v.forEach(id);return v;};
const raw=object(value);if(raw.version!==1)throw fault('history_transition_interpreter_unavailable');if(!validJson(value))throw fault('invalid_history_transition');integer(raw.rootRevision);if(raw.entryId!==undefined)id(raw.entryId);
for(const side of [raw.before,raw.after]){const s=object(side),list=[...ids(s.undo),...ids(s.redo)];if(new Set(list).size!==list.length)throw fault('invalid_history_transition');}
if(!Array.isArray(raw.entries))throw fault('invalid_history_transition');const seen=new Set<string>();
for(const item of raw.entries){const p=object(item);id(p.id);if(seen.has(p.id as string))throw fault('invalid_history_transition');seen.add(p.id as string);
for(const side of [p.before,p.after])if(side!==null){const m=object(side);if(typeof m.label!=='string'||['id','changes','operationIds'].some(key=>Object.hasOwn(m,key)))throw fault('invalid_history_transition');if(m.groupId!==undefined)id(m.groupId);}
for(const key of ['changes','operationIds']){const patch=object(p[key]);for(const field of ['offset','beforeLength','afterLength'])integer(patch[field]);if(!Array.isArray(patch.before)||!Array.isArray(patch.after))throw fault('invalid_history_transition');const{offset,beforeLength,afterLength}=patch as unknown as Splice<unknown>;if(offset+patch.before.length>beforeLength||offset+patch.after.length>afterLength||beforeLength-patch.before.length!==afterLength-patch.after.length)throw fault('invalid_history_transition');if(key==='operationIds'){ids(patch.before);ids(patch.after);}else{checkChanges?.(patch.before);checkChanges?.(patch.after);}
if(p.before===null&&beforeLength!==0||p.after===null&&afterLength!==0)throw fault('invalid_history_transition');
}
}
}
/** Conditional metadata-only replay. A mismatch leaves the supplied history untouched. */
export function applyHistoryTransition(history:HistoryState,transition:HistoryTransition,direction:'forward'|'reverse'):HistoryState{
validateHistoryTransition(transition);const reverse=direction==='reverse',source=reverse?transition.after:transition.before,destination=reverse?transition.before:transition.after;
if(!equal({undo:history.undo,redo:history.redo},source))throw fault('history_transition_conflict');
function apply<T>(current:readonly T[],patch:Splice<T>):readonly T[]{const expected=reverse?patch.after:patch.before,insert=reverse?patch.before:patch.after,length=reverse?patch.afterLength:patch.beforeLength;if(current.length!==length||!equal(current.slice(patch.offset,patch.offset+expected.length),expected))throw fault('history_transition_conflict');return[...current.slice(0,patch.offset),...insert,...current.slice(patch.offset+expected.length)];}
const entries=new Map(history.entries);
for(const patch of transition.entries){const entry=entries.get(patch.id),expected=reverse?patch.after:patch.before,next=reverse?patch.before:patch.after;if(!equal(meta(entry),expected))throw fault('history_transition_conflict');const changes=apply(entry?.changes??[],patch.changes),operationIds=apply(entry?.operationIds??[],patch.operationIds);if(next)entries.set(patch.id,freeze({id:patch.id,...next,changes,operationIds}));else entries.delete(patch.id);}
const ids=[...destination.undo,...destination.redo];if(ids.length!==entries.size||ids.some(id=>!entries.has(id)))throw fault('history_transition_conflict');
return Object.freeze({undo:freeze([...destination.undo]),redo:freeze([...destination.redo]),entries:readMap(entries)});
}
export function historyTransitionChanges(transition:HistoryTransition):readonly Change[]{
const cached=transitionChanges.get(transition);if(cached)return cached;
validateHistoryTransition(transition);
const changes=transition.entries.flatMap(patch=>[...patch.changes.before,...patch.changes.after]);
const ownRanges=Object.hasOwn(transition,'entries')&&transition.entries.every(patch=>Object.hasOwn(patch,'changes')&&Object.hasOwn(patch.changes,'before')&&Object.hasOwn(patch.changes,'after'));
if(!ownRanges||!immutablePlainData(transition))return changes;
const result=Object.freeze(changes);transitionChanges.set(transition,result);return result;
}
+22
View File
@@ -0,0 +1,22 @@
import type { Change, Document, ElementRecord, Id, Registry } from './types.ts';
import { applyChanges, validateDocument } from './document.ts';
import { freeze } from './values.ts';
/** Pure history execution, also used to validate recovery proposals before publication. */
export function planHistoryChanges(initial:Document,planned:readonly Change[],registry:Registry){
const generations=new Map<Id,Map<number,number>>();
const incarnation=(id:Id,value:number)=>{const mapping=generations.get(id);while(mapping?.has(value))value=mapping.get(value)!;return value;};
const remap=(change:Change):Change=>{
if(change.kind==='field'){const next=incarnation(change.target.id,change.target.incarnation);return next===change.target.incarnation?change:freeze({...change,target:{...change.target,incarnation:next}});}
if(change.kind==='element'){const record=(value:ElementRecord|null)=>value?{...value,incarnation:incarnation(value.id,value.incarnation)}:null;return freeze({...change,before:record(change.before),after:record(change.after)});}
return change;
};
let document=initial;const changes:Change[]=[];
for(const original of planned){
let change=remap(original);
if(change.kind==='element'&&!change.before&&change.after){const dead=document.tombstones.get(change.id);if(dead!==undefined&&change.after.incarnation<=dead){const mapping=generations.get(change.id)??new Map<number,number>();mapping.set(change.after.incarnation,dead+1);generations.set(change.id,mapping);change=remap(change);}}
document=applyChanges(document,[change]);changes.push(change);
}
validateDocument(document,registry);
return{document,changes:freeze(changes),remap,restored:generations.size>0};
}
+20
View File
@@ -0,0 +1,20 @@
export * from './types.ts';
export {fault,reject,freeze,clone,equal,readMap,readSet,validJson} from './values.ts';
export * from './document.ts';
export * from './registry.ts';
export * from './features.ts';
export * from './editor.ts';
export * from './gestures.ts';
export * from './coordinates.ts';
export * from './structure.ts';
export * from './units.ts';
export * from './history-transition.ts';
export {planDuplicate,planDuplicateSteps,remapCopyData} from './duplicate.ts';
export type {DuplicatePlan,DuplicateOptions} from './duplicate.ts';
export {createDuplicatePlanner,validateDuplicateCheckpoint} from './duplicate-checkpoint.ts';
export type {DuplicateCheckpoint} from './duplicate-checkpoint.ts';
export {createDocumentQueries,advanceDocumentQueries} from './queries.ts';
export type {DocumentQueries,Bounds,QueryBudget,QueryCoverage,ElementLookup,IdQueryResult} from './queries.ts';
export {rectangleGeometry,rectangleBounds,hitRectangle,bezierChainSegments} from './geometry.ts';
+11
View File
@@ -0,0 +1,11 @@
import {clone,freeze} from './values.ts';
// Private provenance for a fresh, recursively frozen JSON clone. A caller can
// request a detached copy, but cannot grant this fact to an existing object.
const detached=new WeakSet<object>();
export function detachJsonData<T>(input:T):T {
const value=freeze(clone(input));
if(value&&typeof value==='object')detached.add(value);
return value;
}
export const hasDetachedJsonData=(value:object)=>detached.has(value);
+121
View File
@@ -0,0 +1,121 @@
import {rectangleBounds,hitRectangle} from './geometry.ts';
import type {Change,Document,ElementRecord,Geometry,Id,Point,ReadonlyValueSet,Registry} from './types.ts';
import {copyReferenceIds} from './copy-paths.ts';
import {equal,fault,readMap,readSet} from './values.ts';
export type Bounds={readonly x:number;readonly y:number;readonly width:number;readonly height:number};
type Box={readonly minX:number;readonly minY:number;readonly maxX:number;readonly maxY:number};
type Relation=Map<Id,ReadonlyValueSet<Id>>;
type Spatial={cells:Relation;entries:Map<Id,Box>;oversized:ReadonlyValueSet<Id>};
type References={elements:ReadonlySet<Id>;canvases:ReadonlySet<Id>;complete:boolean};
export type QueryBudget={readonly cellSize?:number;readonly maxElementCells?:number;readonly maxQueryCells?:number};
type ResolvedBudget=Required<QueryBudget>;
type Tables={registry:Registry;budget:ResolvedBudget;members:Relation;children:Relation;childByEntry:Map<Id,Id>;incident:Relation;elementRefs:Relation;canvasRefs:Relation;forward:Map<Id,References>;opaque:ReadonlyValueSet<Id>;spatial:Map<Id,Spatial>;order:Map<Id,number>;canvasOrder:Map<Id,number>};
const defaultBudget:ResolvedBudget=Object.freeze({cellSize:512,maxElementCells:256,maxQueryCells:4096});
function budget(input:QueryBudget):ResolvedBudget{const result={...defaultBudget,...input};if(!Number.isFinite(result.cellSize)||result.cellSize<=0||![result.maxElementCells,result.maxQueryCells].every(value=>Number.isSafeInteger(value)&&value>=1&&value<=1000000))throw fault('invalid_query_budget');return Object.freeze(result);}
const privateTables=new WeakMap<DocumentQueries,Tables>();
const empty=readSet<Id>([]);
const orderedBuckets=new WeakMap<object,WeakMap<object,readonly Id[]>>();
function sortedBucket(bucket:ReadonlyValueSet<Id>,ranks:Map<Id,number>){let versions=orderedBuckets.get(bucket);if(!versions){versions=new WeakMap();orderedBuckets.set(bucket,versions);}let ids=versions.get(ranks);if(!ids){ids=Object.freeze([...bucket].sort((a,b)=>ranks.get(a)!-ranks.get(b)!));versions.set(ranks,ids);}return ids;}
/** Copy only touched buckets. Final buckets expose no native Set mutation methods. */
function editRelation(base:Relation){const changed=new Map<Id,Set<Id>>();return{change(key:Id,id:Id,add:boolean){const current=changed.get(key)??base.get(key);if(!!current?.has(id)===add)return;let bucket=changed.get(key);if(!bucket){bucket=new Set(current??[]);changed.set(key,bucket);}if(add)bucket.add(id);else bucket.delete(id);},finish(){let next=base;for(const[key,ids]of changed){const before=base.get(key);if((before?.size??0)===ids.size&&[...ids].every(id=>before!.has(id)))continue;if(next===base)next=new Map(base);if(ids.size)next.set(key,readSet(ids));else next.delete(key);}return next;}};}
function nodeBox(record:Extract<ElementRecord,{structure:'node'}>,registry:Registry):Box{try{const protocol=registry.resolve(record)==='known'?registry.components.get(record.componentId)?.geometry:undefined,logical=rectangleBounds(record.geometry),hit=protocol?.kind==='node'?protocol.bounds({element:record,geometry:record.geometry,role:'hit'}):logical;if(![hit.x,hit.y,hit.width,hit.height].every(Number.isFinite)||hit.width<0||hit.height<0)throw fault('invalid_geometry_bounds');const bounds={x:Math.min(logical.x,hit.x),y:Math.min(logical.y,hit.y),width:Math.max(logical.x+logical.width,hit.x+hit.width)-Math.min(logical.x,hit.x),height:Math.max(logical.y+logical.height,hit.y+hit.height)-Math.min(logical.y,hit.y)};if(![bounds.x,bounds.y,bounds.width,bounds.height].every(Number.isFinite)||bounds.width<0||bounds.height<0)throw fault('invalid_geometry_bounds');const epsilon=Number.EPSILON*32*Math.max(1,Math.abs(bounds.x),Math.abs(bounds.y),bounds.width,bounds.height);return Object.freeze({minX:bounds.x-epsilon,minY:bounds.y-epsilon,maxX:bounds.x+bounds.width+epsilon,maxY:bounds.y+bounds.height+epsilon});}catch{return Object.freeze({minX:NaN,minY:NaN,maxX:NaN,maxY:NaN});}}
function cells(box:Box,limit:number,cellSize:number):string[]|undefined{
const x0=Math.floor(box.minX/cellSize),x1=Math.floor(box.maxX/cellSize),y0=Math.floor(box.minY/cellSize),y1=Math.floor(box.maxY/cellSize),count=(x1-x0+1)*(y1-y0+1);
if(![x0,x1,y0,y1,count].every(Number.isSafeInteger)||count<1||count>limit)return;
const keys:string[]=[];for(let x=x0;x<=x1;x++)for(let y=y0;y<=y1;y++)keys.push(`${x},${y}`);return keys;
}
function refs(record:ElementRecord,registry:Registry):References{
const elements=new Set<Id>(),canvases=new Set<Id>(),schema=registry.components.get(record.componentId)?.copy;
if(registry.resolve(record)!=='known'||!schema)return{elements,canvases,complete:false};
try{for(const ref of schema.references)for(const id of copyReferenceIds(record.data,ref))(ref.target==='element'?elements:canvases).add(id);return{elements,canvases,complete:true};}catch{return{elements:new Set(),canvases:new Set(),complete:false};}
}
function order(previous:Map<Id,number>|undefined,ids:Iterable<Id>){const list=[...ids];if(previous&&previous.size===list.length&&list.every((id,index)=>previous.get(id)===index))return previous;return new Map(list.map((id,index)=>[id,index]));}
function protectSpatial(spatial:Spatial){return Object.freeze({cells:readMap(spatial.cells),entries:readMap(spatial.entries),oversized:spatial.oversized});}
function intersect(a:Box,b:Box){return a.minX<=b.maxX&&a.maxX>=b.minX&&a.minY<=b.maxY&&a.maxY>=b.minY;}
function finite(box:Box){return Object.values(box).every(Number.isFinite);}
export type QueryCoverage={readonly elementIds?:ReadonlyValueSet<Id>;readonly loadedElementIds?:ReadonlyValueSet<Id>;readonly projectComplete:boolean;readonly topologyComplete:boolean;readonly completeCanvasIds:readonly Id[]};
export type ElementLookup={readonly status:'present';readonly element:ElementRecord}|{readonly status:'absent'|'unloaded'};
export type IdQueryResult={readonly ids:readonly Id[];readonly complete:boolean};
export type DocumentQueries={
readonly coverage:QueryCoverage;
lookupElement(id:Id):ElementLookup;
membersResult(canvas:Id):IdQueryResult;
childCanvasesResult(canvas:Id):IdQueryResult;
incidentResult(element:Id):IdQueryResult;
readonly document:Document;readonly registry:Registry;readonly revision:number;readonly budget:ResolvedBudget;
readonly relations:{readonly ownerMembers:ReadonlyMap<Id,ReadonlyValueSet<Id>>;readonly canvasChildren:ReadonlyMap<Id,ReadonlyValueSet<Id>>;readonly childCanvasByEntry:ReadonlyMap<Id,Id>;readonly incidentEdges:ReadonlyMap<Id,ReadonlyValueSet<Id>>;readonly elementReferrers:ReadonlyMap<Id,ReadonlyValueSet<Id>>;readonly canvasReferrers:ReadonlyMap<Id,ReadonlyValueSet<Id>>;readonly opaqueReferences:ReadonlyValueSet<Id>;readonly referenceComplete:boolean};
readonly spatial:ReadonlyMap<Id,{readonly cells:ReadonlyMap<Id,ReadonlyValueSet<Id>>;readonly entries:ReadonlyMap<Id,Box>;readonly oversized:ReadonlyValueSet<Id>}>;
members(canvas:Id):readonly Id[];childCanvases(canvas:Id):readonly Id[];incident(element:Id):readonly Id[];orderedElements(ids:Iterable<Id>):readonly Id[];orderedCanvases(ids:Iterable<Id>):readonly Id[];candidateNodes(canvas:Id,rect:Bounds):readonly Id[];hitNodeBounds(canvas:Id,point:Point,tolerance?:number):{readonly ids:readonly Id[];readonly complete:boolean};hitNodes(canvas:Id,point:Point,tolerance?:number):{readonly ids:readonly Id[];readonly complete:boolean;readonly unavailable:readonly Id[]};
};
// No mapping callback captures the previous query: retained methods must not
// accidentally keep its context alive, including after consumer bundling.
function spatialViews(tables:Tables,previous?:DocumentQueries){
const old=previous?privateTables.get(previous)!:undefined;
if(previous&&tables.spatial===old!.spatial)return previous.spatial;
const result=new Map<Id,ReturnType<typeof protectSpatial>>();
for(const[id,value]of tables.spatial)result.set(id,previous&&old!.spatial.get(id)===value?previous.spatial.get(id)!:protectSpatial(value));
return readMap(result);
}
function expose(document:Document,revision:number,tables:Tables,previous?:DocumentQueries):DocumentQueries{
const sorted=(ids:Iterable<Id>,ranks=tables.order)=>Object.freeze([...ids].filter(id=>ranks.has(id)).sort((a,b)=>ranks.get(a)!-ranks.get(b)!));
const relation=Object.freeze({ownerMembers:previous&&tables.members===privateTables.get(previous)!.members?previous.relations.ownerMembers:readMap(tables.members),canvasChildren:previous&&tables.children===privateTables.get(previous)!.children?previous.relations.canvasChildren:readMap(tables.children),childCanvasByEntry:previous&&tables.childByEntry===privateTables.get(previous)!.childByEntry?previous.relations.childCanvasByEntry:readMap(tables.childByEntry),incidentEdges:previous&&tables.incident===privateTables.get(previous)!.incident?previous.relations.incidentEdges:readMap(tables.incident),elementReferrers:previous&&tables.elementRefs===privateTables.get(previous)!.elementRefs?previous.relations.elementReferrers:readMap(tables.elementRefs),canvasReferrers:previous&&tables.canvasRefs===privateTables.get(previous)!.canvasRefs?previous.relations.canvasReferrers:readMap(tables.canvasRefs),opaqueReferences:tables.opaque,referenceComplete:tables.opaque.size===0});
const spatial=spatialViews(tables,previous);
function candidates(canvas:Id,rect:Bounds){
if(!document.canvases.has(canvas))throw fault('canvas_not_loaded',[canvas]);if(![rect.x,rect.y,rect.width,rect.height].every(Number.isFinite)||rect.width<0||rect.height<0)throw fault('invalid_query_bounds');
const table=tables.spatial.get(canvas);if(!table)return Object.freeze([]) as readonly Id[];const box={minX:rect.x,minY:rect.y,maxX:rect.x+rect.width,maxY:rect.y+rect.height},keys=cells(box,tables.budget.maxQueryCells,tables.budget.cellSize),ids=new Set<Id>(keys?table.oversized:table.entries.keys());if(keys)for(const key of keys)for(const id of table.cells.get(key)??[])ids.add(id);
return sorted([...ids].filter(id=>{const entry=table.entries.get(id)!;return !finite(entry)||intersect(box,entry);}));
}
const view:DocumentQueries=Object.freeze({
coverage:Object.freeze({projectComplete:true,topologyComplete:true,completeCanvasIds:Object.freeze([...document.canvases.keys()])}),
lookupElement:(id:Id):ElementLookup=>{const element=document.elements.get(id);return element?Object.freeze({status:'present',element}):Object.freeze({status:'absent'});},
membersResult:(canvas:Id)=>Object.freeze({ids:view.members(canvas),complete:true}),
childCanvasesResult:(canvas:Id)=>Object.freeze({ids:view.childCanvases(canvas),complete:true}),
incidentResult:(element:Id)=>Object.freeze({ids:view.incident(element),complete:true}),
document,registry:tables.registry,revision,budget:tables.budget,relations:relation,spatial,
members:(canvas:Id)=>{if(!document.canvases.has(canvas))throw fault('canvas_not_loaded',[canvas]);return sortedBucket(tables.members.get(canvas)??empty,tables.order);},childCanvases:(canvas:Id)=>{if(!document.canvases.has(canvas))throw fault('canvas_not_loaded',[canvas]);return sortedBucket(tables.children.get(canvas)??empty,tables.canvasOrder);},incident:(element:Id)=>{if(!document.elements.has(element))throw fault('element_missing',[element]);return sortedBucket(tables.incident.get(element)??empty,tables.order);},orderedElements:(ids:Iterable<Id>)=>sorted(ids),orderedCanvases:(ids:Iterable<Id>)=>sorted(ids,tables.canvasOrder),candidateNodes:candidates,
hitNodes(canvas:Id,point:Point,tolerance=0){if(![point.x,point.y,tolerance].every(Number.isFinite)||tolerance<0)throw fault('invalid_query_point');const hits:Id[]=[],unavailable:Id[]=[];for(const id of candidates(canvas,{x:point.x-tolerance,y:point.y-tolerance,width:2*tolerance,height:2*tolerance})){const record=document.elements.get(id)! as Extract<ElementRecord,{structure:'node'}>;try{if(!finite(tables.spatial.get(canvas)!.entries.get(id)!))throw fault('geometry_unavailable');const protocol=tables.registry.resolve(record)==='known'?tables.registry.components.get(record.componentId)?.geometry:undefined,result=protocol?.kind==='node'?protocol.hit({element:record,geometry:record.geometry,point,tolerance}):hitRectangle(record.geometry,point,tolerance);if(typeof result!=='boolean')throw fault('invalid_geometry_response');if(result)hits.push(id);}catch{unavailable.push(id);}}return Object.freeze({ids:Object.freeze(hits),complete:!unavailable.length,unavailable:Object.freeze(unavailable)});},
hitNodeBounds(canvas:Id,point:Point,tolerance=0){if(![point.x,point.y,tolerance].every(Number.isFinite)||tolerance<0)throw fault('invalid_query_point');const ids=candidates(canvas,{x:point.x-tolerance,y:point.y-tolerance,width:2*tolerance,height:2*tolerance}),hits:Id[]=[];let complete=true;for(const id of ids){const g=(document.elements.get(id)! as Extract<ElementRecord,{structure:'node'}>).geometry,box=tables.spatial.get(canvas)!.entries.get(id)!;if(!finite(box)){complete=false;continue;}const angle=-g.rotation*Math.PI/180,c=Math.cos(angle),s=Math.sin(angle),dx=point.x-g.x-g.width/2,dy=point.y-g.y-g.height/2,x=dx*c-dy*s,y=dx*s+dy*c,ox=Math.max(0,Math.abs(x)-g.width/2),oy=Math.max(0,Math.abs(y)-g.height/2);if(Math.hypot(ox,oy)<=tolerance+Number.EPSILON*64*Math.max(1,Math.abs(point.x),Math.abs(point.y),g.width,g.height))hits.push(id);}return Object.freeze({ids:Object.freeze(hits),complete});},
});privateTables.set(view,tables);return view;
}
const queryBases=new WeakMap<DocumentQueries,DocumentQueries>();
const namespaceCopies=new WeakMap<object,ReadonlyValueSet<Id>>();
function ownedNamespace(ids:ReadonlyValueSet<Id>){let value=namespaceCopies.get(ids);if(!value){value=readSet(ids);namespaceCopies.set(ids,value);}return value;}
/** Bind completeness to the same publication as the model, without rebuilding indexes. */
export function withQueryCoverage(queries:DocumentQueries,input:QueryCoverage):DocumentQueries {
const base=queryBases.get(queries)??queries,ids=Object.freeze([...input.completeCanvasIds]);
if(new Set(ids).size!==ids.length||ids.some(id=>!base.document.canvases.has(id))||input.projectComplete&&(!input.topologyComplete||ids.length!==base.document.canvases.size))throw fault('invalid_query_coverage');
const coverage=Object.freeze({...(input.elementIds?{elementIds:ownedNamespace(input.elementIds)}:{}),...(input.loadedElementIds?{loadedElementIds:ownedNamespace(input.loadedElementIds)}:{}),projectComplete:input.projectComplete,topologyComplete:input.topologyComplete,completeCanvasIds:ids}),complete=new Set(ids);
const view:DocumentQueries=Object.freeze({...base,coverage,
relations:Object.freeze({...base.relations,referenceComplete:base.relations.referenceComplete&&coverage.projectComplete}),
lookupElement:(id:Id):ElementLookup=>{const element=base.document.elements.get(id);return element?Object.freeze({status:'present',element}):Object.freeze({status:coverage.projectComplete||coverage.elementIds&&(!coverage.elementIds.has(id)||coverage.loadedElementIds?.has(id)&&base.document.tombstones.has(id))?'absent':'unloaded'});},
membersResult:(canvas:Id)=>Object.freeze({ids:base.members(canvas),complete:complete.has(canvas)}),
childCanvasesResult:(canvas:Id)=>Object.freeze({ids:base.childCanvases(canvas),complete:coverage.topologyComplete}),
incidentResult:(element:Id)=>Object.freeze({ids:base.incident(element),complete:coverage.projectComplete}),
hitNodes(canvas:Id,point:Point,tolerance=0){const result=base.hitNodes(canvas,point,tolerance);return Object.freeze({...result,complete:result.complete&&complete.has(canvas)});},
hitNodeBounds(canvas:Id,point:Point,tolerance=0){const result=base.hitNodeBounds(canvas,point,tolerance);return Object.freeze({...result,complete:result.complete&&complete.has(canvas)});},
});queryBases.set(view,base);const tables=privateTables.get(base);if(tables)privateTables.set(view,tables);return view;
}
/** Pure derived query snapshot. Never serialize these tables or publish them as a second document. */
export function createDocumentQueries(document:Document,registry:Registry,revision=0,options:QueryBudget={}):DocumentQueries{return advanceDocumentQueries(undefined,document,registry,revision,undefined,options);}
export function advanceDocumentQueries(previous:DocumentQueries|undefined,document:Document,registry:Registry,revision:number,changes?:readonly Change[],options:QueryBudget={}):DocumentQueries{
if(!Number.isSafeInteger(revision)||revision<0||previous&&(previous.document.projectId!==document.projectId||revision<previous.revision||revision===previous.revision&&document!==previous.document))throw fault('invalid_query_revision');
const old=previous?privateTables.get(previous):undefined;if(previous&&!old)throw fault('invalid_query_snapshot');if(old&&old.registry!==registry)throw fault('query_registry_changed');if(old&&Object.keys(options).length&&!equal(old.budget,budget(options)))throw fault('query_budget_changed');if(previous?.document===document&&previous.revision===revision)return previous;
const configuration=old?.budget??budget(options),oldDoc=previous?.document,ids=changes?new Set(changes.filter(change=>change.kind!=='canvas').map(change=>change.kind==='field'?change.target.id:change.id)):new Set([...(oldDoc?.elements.keys()??[]),...document.elements.keys()]),canvasIds=changes?new Set(changes.filter(change=>change.kind==='canvas').map(change=>change.id)):new Set([...(oldDoc?.canvases.keys()??[]),...document.canvases.keys()]);
const members=editRelation(old?.members??new Map()),children=editRelation(old?.children??new Map()),incident=editRelation(old?.incident??new Map()),elementRefs=editRelation(old?.elementRefs??new Map()),canvasRefs=editRelation(old?.canvasRefs??new Map());let childByEntry=old?.childByEntry??new Map<Id,Id>(),forward=old?.forward??new Map<Id,References>(),opaque=old?.opaque??empty,spatial=old?.spatial??new Map<Id,Spatial>();
const replaced=new Set<Id>(),dataFields=new Map<Id,Set<string>>();if(changes)for(const change of changes){if(change.kind==='element')replaced.add(change.id);else if(change.kind==='field'&&change.field.startsWith('data.')){let fields=dataFields.get(change.target.id);if(!fields){fields=new Set();dataFields.set(change.target.id,fields);}fields.add(change.field.slice(5));}}
const spatialChanges=new Map<Id,Map<Id,Box|null>>();
const spatialChange=(canvas:Id,id:Id,box:Box|null)=>{let list=spatialChanges.get(canvas);if(!list){list=new Map();spatialChanges.set(canvas,list);}list.set(id,box);};
for(const id of ids){const before=oldDoc?.elements.get(id),after=document.elements.get(id);if(before===after)continue;
if(before?.ownerCanvasId!==after?.ownerCanvasId){if(before)members.change(before.ownerCanvasId,id,false);if(after)members.change(after.ownerCanvasId,id,true);}
const beforeEndpoints=before?.structure==='connection'?new Set([before.source.elementId,before.target.elementId]):new Set<Id>(),afterEndpoints=after?.structure==='connection'?new Set([after.source.elementId,after.target.elementId]):new Set<Id>();for(const endpoint of beforeEndpoints)if(!afterEndpoints.has(endpoint))incident.change(endpoint,id,false);for(const endpoint of afterEndpoints)if(!beforeEndpoints.has(endpoint))incident.change(endpoint,id,true);
const oldRefs=forward.get(id),schema=after?registry.components.get(after.componentId)?.copy:undefined,referenceFieldsUnchanged=!!changes&&!replaced.has(id)&&!!schema&&schema.references.every(ref=>!dataFields.get(id)?.has(ref.path[0])),nextRefs=after?(before&&oldRefs&&before.componentId===after.componentId&&before.dataVersion===after.dataVersion&&(referenceFieldsUnchanged||equal(before.data,after.data))?oldRefs:refs(after,registry)):undefined;
if(oldRefs!==nextRefs)for(const kind of ['elements','canvases'] as const){const table=kind==='elements'?elementRefs:canvasRefs;for(const target of oldRefs?.[kind]??[])if(!nextRefs?.[kind].has(target))table.change(target,id,false);for(const target of nextRefs?.[kind]??[])if(!oldRefs?.[kind].has(target))table.change(target,id,true);}
if(oldRefs!==nextRefs&&(!!oldRefs?.complete!==!!nextRefs?.complete||!equal([...(oldRefs?.elements??[])],[...(nextRefs?.elements??[])])||!equal([...(oldRefs?.canvases??[])],[...(nextRefs?.canvases??[])])||!oldRefs&&nextRefs||oldRefs&&!nextRefs)){if(forward===old?.forward)forward=new Map(forward);if(nextRefs)forward.set(id,nextRefs);else forward.delete(id);}
const shouldOpaque=!!after&&!nextRefs?.complete;if(opaque.has(id)!==shouldOpaque){const next=new Set(opaque);if(shouldOpaque)next.add(id);else next.delete(id);opaque=readSet(next);}
const was=before?.structure==='node'?before:undefined,now=after?.structure==='node'?after:undefined,protocol=now&&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));}
}
for(const id of canvasIds){const before=oldDoc?.canvases.get(id)?.parent,after=document.canvases.get(id)?.parent;if(equal(before,after))continue;if(before){children.change(before.canvasId,id,false);if(childByEntry===old?.childByEntry)childByEntry=new Map(childByEntry);childByEntry.delete(before.entryElementId);}}
for(const id of canvasIds){const before=oldDoc?.canvases.get(id)?.parent,after=document.canvases.get(id)?.parent;if(equal(before,after)||!after)continue;children.change(after.canvasId,id,true);if(childByEntry===old?.childByEntry)childByEntry=new Map(childByEntry);childByEntry.set(after.entryElementId,id);}
if(spatialChanges.size){spatial=new Map(spatial);for(const[canvas,updates]of spatialChanges){const base=spatial.get(canvas),entries=new Map(base?.entries),grid=editRelation(base?.cells??new Map()),large=new Set(base?.oversized??[]);for(const[id,box]of updates){const before=entries.get(id);if(before)for(const key of cells(before,configuration.maxElementCells,configuration.cellSize)??[])grid.change(key,id,false);large.delete(id);if(box){entries.set(id,box);const keys=cells(box,configuration.maxElementCells,configuration.cellSize);if(keys)for(const key of keys)grid.change(key,id,true);else large.add(id);}else entries.delete(id);}if(entries.size)spatial.set(canvas,{entries,cells:grid.finish(),oversized:readSet(large)});else spatial.delete(canvas);}}
const tables:Tables={registry,budget:configuration,members:members.finish(),children:children.finish(),childByEntry,incident:incident.finish(),elementRefs:elementRefs.finish(),canvasRefs:canvasRefs.finish(),forward,opaque,spatial,order:old&&changes&&!changes.some(change=>change.kind==='element'&&(!change.before||!change.after))?old.order:order(old?.order,document.elements.keys()),canvasOrder:old&&changes&&!changes.some(change=>change.kind==='canvas'&&(!change.before||!change.after))?old.canvasOrder:order(old?.canvasOrder,document.canvases.keys())};return expose(document,revision,tables,previous);
}
+80
View File
@@ -0,0 +1,80 @@
import type { Capability, CapabilityHandler, ComponentDefinition, Data, ElementRecord, Feature, Json, Registry, ReadonlyValueSet } from './types.ts';
import { clone, fault, freeze, readMap, readSet } from './values.ts';
export function defineFeature(id: string, handlers: Partial<Record<Capability, CapabilityHandler>>): Feature {
if (!id || !Object.values(handlers).length || Object.values(handlers).some(value => typeof value !== 'function')) throw fault('invalid_feature');
return freeze({ id, handlers: { ...handlers } });
}
export function defineComponent(definition: ComponentDefinition): ComponentDefinition {
if (!definition.id || !Number.isSafeInteger(definition.dataVersion) || definition.dataVersion < 1 || typeof definition.validate !== 'function') throw fault('invalid_component');
const pathValid = (path: readonly string[]) => Array.isArray(path) && path.length > 0 && path.every(key => typeof key === 'string' && !!key && !['__proto__','prototype','constructor'].includes(key));
if (definition.copy) {
const references = definition.copy.references, omitted = definition.copy.omit;
if (!Array.isArray(references) || !Array.isArray(omitted) || omitted.some(path => !pathValid(path)) || references.some(ref => !pathValid(ref.path) || !['element','canvas'].includes(ref.target) || !['preserve','reject'].includes(ref.external) || ref.many !== undefined && typeof ref.many !== 'boolean')) throw fault('invalid_copy_schema');
const paths: readonly (readonly string[])[] = [...references.map(ref => ref.path), ...omitted];
for (let i = 0; i < paths.length; i++) for (let j = 0; j < i; j++) if (paths[i].slice(0, Math.min(paths[i].length, paths[j].length)).every((key, index) => (key === paths[j][index] || key === '*' || paths[j][index] === '*'))) throw fault('conflicting_copy_paths');
}
const geometry=definition.geometry;if(geometry&&(geometry.version!==1||geometry.kind!==definition.structure||geometry.kind==='node'&&(typeof geometry.bounds!=='function'||typeof geometry.hit!=='function'||geometry.dataDependencies!==undefined&&(!Array.isArray(geometry.dataDependencies)||geometry.dataDependencies.some(key=>typeof key!=='string'||!key)))||geometry.kind==='connection'&&(typeof geometry.segments!=='function'||typeof geometry.strokeWidth!=='function')))throw fault('invalid_geometry_protocol');
return freeze({ ...definition, geometry:geometry?{...geometry,...(geometry.kind==='node'&&geometry.dataDependencies?{dataDependencies:[...geometry.dataDependencies]}:{})}:undefined, copy: definition.copy ? clone(definition.copy) : undefined, features: definition.features.map(feature => defineFeature(feature.id, feature.handlers)), migrations: definition.migrations ? { ...definition.migrations } : undefined, resourceFields: definition.resourceFields ? [...definition.resourceFields] : undefined });
}
export function createRegistry(definitions: readonly ComponentDefinition[]): Registry {
const components = new Map<string, ComponentDefinition>(), capabilities = new Map<string, ReadonlyValueSet<Capability>>(), handlers = new Map<string, ReadonlyMap<Capability, CapabilityHandler>>();
for (const input of definitions) {
const definition = defineComponent(input);
if (components.has(definition.id)) throw fault('duplicate_component', [definition.id]);
const found = new Map<Capability, CapabilityHandler>(), features = new Set<string>();
for (const feature of definition.features) {
if (features.has(feature.id)) throw fault('duplicate_feature', [feature.id]);
features.add(feature.id);
for (const [key, handler] of Object.entries(feature.handlers)) {
const capability = key as Capability;
if (!handler) throw fault('invalid_feature');
if (found.has(capability)) throw fault('duplicate_capability', [definition.id, capability]);
found.set(capability, handler);
}
}
components.set(definition.id, definition); capabilities.set(definition.id, readSet(found.keys())); handlers.set(definition.id, readMap(found));
}
const resolve: Registry['resolve'] = element => {
const definition = components.get(element.componentId);
if (!definition) return 'unknown-component';
if (definition.dataVersion !== element.dataVersion) return 'unsupported-version';
return 'known';
};
return Object.freeze({ components: readMap(components), capabilities: readMap(capabilities), handlers: readMap(handlers), resolve,
supports: (element: ElementRecord, capability: Capability) => resolve(element) === 'known' && !!capabilities.get(element.componentId)?.has(capability) });
}
/** Capture each field once; JSON.stringify would silently erase invalid values. */
function captureMigrationData(input:unknown,id:string):Data {
const invalid=():never=>{throw fault('invalid_migration',[id]);};
const capture=(value:unknown,depth=0):Json=>{
if(depth>64)return invalid();
if(value===null||typeof value==='string'||typeof value==='boolean')return value;
if(typeof value==='number')return Number.isFinite(value)?value:invalid();
if(!value||typeof value!=='object')return invalid();
if(Object.getOwnPropertySymbols(value).some(key=>Object.prototype.propertyIsEnumerable.call(value,key)))return invalid();
if(Array.isArray(value)){
if(Object.keys(value).length!==value.length)return invalid();
const result:Json[]=[];for(let index=0;index<value.length;index++){if(!Object.hasOwn(value,index))return invalid();result.push(capture(value[index],depth+1));}return freeze(result);
}
if(Object.getPrototypeOf(value)!==Object.prototype)return invalid();
const source=value as Record<string,unknown>;
return freeze(Object.fromEntries(Object.keys(source).map(key=>[key,capture(source[key],depth+1)])));
};
const result=capture(input);
if(!result||typeof result!=='object'||Array.isArray(result))return invalid();
return result;
}
export function migrateElement(element: ElementRecord, registry: Registry): ElementRecord {
const definition = registry.components.get(element.componentId);
if (!definition || element.dataVersion > definition.dataVersion) return element;
let data = captureMigrationData(element.data,element.id), version = element.dataVersion;
while (version < definition.dataVersion) {
const migrate = definition.migrations?.[version];
if (!migrate) throw fault('missing_migration', [element.id]);
data = captureMigrationData(migrate(data),element.id); version++;
}
if (!definition.validate(data)) throw fault('invalid_component_data', [element.id]);
return freeze({ ...element, dataVersion: version, data });
}
+38
View File
@@ -0,0 +1,38 @@
import type {DocumentQueries} from './queries.ts';
import type { Change, Document, Id, Target } from './types.ts';
import { commonCanvas, targetElement } from './document.ts';
import { canvasTransform, convertPath } from './coordinates.ts';
import { equal, freeze, fault } from './values.ts';
/** Own the subtree explicitly; geometry/visibility never selects which descendants are deleted. */
export function removalChanges(document:Document,target:Target,queries?:DocumentQueries):readonly Change[] {
if(queries){
if(queries.document!==document)throw fault('stale_query_snapshot');const element=targetElement(document,target),elements=new Set<Id>([element.id]),canvases=new Set<Id>(),child=queries.relations.childCanvasByEntry.get(element.id),stack=child?[child]:[];
while(stack.length){const id=stack.pop()!;if(canvases.has(id))continue;canvases.add(id);stack.push(...queries.childCanvases(id));for(const member of queries.members(id))elements.add(member);}
for(const id of [...elements])for(const edge of queries.incident(id))elements.add(edge);
return freeze([...queries.orderedElements(elements).map(id=>({kind:'element' as const,id,before:document.elements.get(id)!,after:null})),...queries.orderedCanvases(canvases).map(id=>({kind:'canvas' as const,id,before:document.canvases.get(id)!,after:null}))]);
}
const element=targetElement(document,target),elements=new Set<Id>([element.id]),canvases=new Set<Id>(),children=new Map<Id,Id[]>();
for(const canvas of document.canvases.values())if(canvas.parent){const list=children.get(canvas.parent.canvasId)??[];list.push(canvas.id);children.set(canvas.parent.canvasId,list);}
const stack=[...document.canvases.values()].filter(canvas=>canvas.parent?.entryElementId===element.id).map(canvas=>canvas.id);
while(stack.length){const id=stack.pop()!;if(canvases.has(id))continue;canvases.add(id);for(const child of children.get(id)??[])stack.push(child);}
for(const record of document.elements.values())if(canvases.has(record.ownerCanvasId))elements.add(record.id);
for(const record of document.elements.values())if(record.structure==='connection'&&(elements.has(record.source.elementId)||elements.has(record.target.elementId)))elements.add(record.id);
return freeze([
...[...document.elements.values()].filter(record=>elements.has(record.id)).map(record=>({kind:'element' as const,id:record.id,before:record,after:null})),
...[...document.canvases.values()].filter(canvas=>canvases.has(canvas.id)).map(canvas=>({kind:'canvas' as const,id:canvas.id,before:canvas,after:null})),
]);
}
/** Ownership changes and their curve coordinates are one operation, with the same stable edge identity. */
export function repairConnectionOwners(before:Document,document:Document):readonly Change[] {
const changes:Change[]=[];
for(const edge of document.elements.values())if(edge.structure==='connection'){
const source=document.elements.get(edge.source.elementId),target=document.elements.get(edge.target.elementId);if(!source||!target)continue;
const owner=commonCanvas(document,source.ownerCanvasId,target.ownerCanvasId);if(owner===edge.ownerCanvasId)continue;
const path=convertPath(edge.path,canvasTransform(before.canvases.has(edge.ownerCanvasId)?before:document,edge.ownerCanvasId),canvasTransform(document,owner));
changes.push({kind:'field',target:{id:edge.id,incarnation:edge.incarnation},field:'ownerCanvasId',before:edge.ownerCanvasId,after:owner});
if(!equal(edge.path,path))changes.push({kind:'field',target:{id:edge.id,incarnation:edge.incarnation},field:'path',before:edge.path,after:path});
}
return freeze(changes);
}
+61
View File
@@ -0,0 +1,61 @@
import type { HistoryTransition } from './history-transition.ts';
/** Canonical, serializable contracts. No renderer, browser, or service types. */
export type Json = null | boolean | number | string | Json[] | { [key: string]: Json };
export type Data = { readonly [key: string]: Json };
export type Id = string;
export type Point = { readonly x: number; readonly y: number };
export type Geometry = Point & { readonly width: number; readonly height: number; readonly rotation: number };
export type CanvasRecord = { readonly id: Id; readonly parent: null | { readonly canvasId: Id; readonly entryElementId: Id } };
export type Target = { readonly id: Id; readonly incarnation: number };
export type Endpoint = { readonly elementId: Id; readonly portId?: string };
export type ElementBase = Target & { readonly ownerCanvasId: Id; readonly componentId: string; readonly dataVersion: number; readonly data: Data };
export type ElementRecord = ElementBase & (
| { readonly structure: 'node'; readonly geometry: Geometry }
| { readonly structure: 'connection'; readonly source: Endpoint; readonly target: Endpoint; readonly path: Json }
);
export type Document = { readonly projectId: Id; readonly canvases: ReadonlyMap<Id, CanvasRecord>; readonly elements: ReadonlyMap<Id, ElementRecord>; readonly tombstones: ReadonlyMap<Id, number> };
export type Field = `data.${string}` | 'geometry' | 'ownerCanvasId' | 'source' | 'target' | 'path';
export type FieldChange = { readonly kind: 'field'; readonly target: Target; readonly field: Field; readonly before: Json; readonly after: Json; readonly beforePresent?: boolean; readonly afterPresent?: boolean };
export type Change = FieldChange
| { readonly kind: 'element'; readonly id: Id; readonly before: ElementRecord | null; readonly after: ElementRecord | null }
| { readonly kind: 'canvas'; readonly id: Id; readonly before: CanvasRecord | null; readonly after: CanvasRecord | null };
export type Command =
| { readonly type: 'element.create'; readonly element: ElementRecord }
| { readonly type:'connection.endpoints'; readonly target:Target; readonly source?:Endpoint; readonly destination?:Endpoint }
| { readonly type: 'connection.path'; readonly target: Target; readonly path: Json }
| { readonly type: 'element.remove'; readonly target: Target }
| { readonly type: 'element.data'; readonly target: Target; readonly patch: Data }
| { readonly type: 'element.geometry'; readonly target: Target; readonly patch: Partial<Geometry> }
| { readonly type: 'element.move'; readonly target: Target; readonly canvasId: Id; readonly geometry?: Geometry }
| { readonly type: 'canvas.create'; readonly canvas: CanvasRecord }
| { readonly type: 'canvas.reparent'; readonly canvasId: Id; readonly parent: CanvasRecord['parent'] }
| { readonly type: 'canvas.dissolve'; readonly canvasId: Id; readonly entry: Target }
| { readonly type: 'changes.apply'; readonly changes: readonly Change[] };
export type Origin = 'user' | 'undo' | 'redo' | 'remote' | 'ack' | 'replay' | 'asset-resolution' | 'migration';
export type Rejected = { readonly status: 'rejected'; readonly code: string; readonly targets: readonly Id[] };
export type Applied = { readonly status: 'applied'; readonly revision: number; readonly operationIds: readonly Id[]; readonly historyEntryId?: Id };
export type Result = Applied | Rejected;
export type HistoryEntry = { readonly id: Id; readonly label: string; readonly changes: readonly Change[]; readonly operationIds: readonly Id[]; readonly groupId?: Id };
export type HistoryState = { readonly undo: readonly Id[]; readonly redo: readonly Id[]; readonly entries: ReadonlyMap<Id, HistoryEntry> };
export type CreationSizeEffect = { readonly version: 1; readonly kind: 'creation-size'; readonly entryId: Id; readonly target: Target; readonly size: { readonly width: number; readonly height: number } };
type IntentBase = { readonly historyEffect?:CreationSizeEffect; readonly historyTransition?:HistoryTransition; readonly id: Id; readonly operationSchemaVersion: 1; readonly sequence: number; readonly origin: Origin; readonly canvasIds: readonly Id[]; readonly changes: readonly Change[]; readonly dependencies: readonly Id[] };
export type RunInput = { readonly target: Target; readonly boardId: Id; readonly modelId: string; readonly prompt: string; readonly parameters: Data; readonly propagate: boolean };
export type RunCancelInput = { readonly runId: Id; readonly boardId: Id };
export type RunApplyInput = RunCancelInput & { readonly targetCanvasId: Id; readonly target: Target; readonly outputId: Id };
export type OperationIntent = IntentBase & ({ readonly operationType: 'document.change' } | { readonly operationType: 'asset.upload'; readonly resourceId: Id } | { readonly operationType: 'run.start'; readonly run: RunInput } | { readonly operationType: 'run.cancel'; readonly action: RunCancelInput } | { readonly operationType: 'run.apply'; readonly action: RunApplyInput });
export type Commit = { readonly revision: number; readonly origin: Origin; readonly changes: readonly Change[]; readonly operationIds: readonly Id[] };
export type CoreSnapshot = { readonly revision: number; readonly document: Document; readonly history: HistoryState; readonly operations: ReadonlyMap<Id, OperationIntent> };
/** One publication boundary. Runtime metadata changes do not dirty the document revision. */
export type Snapshot<Runtime = unknown> = CoreSnapshot & { readonly rootRevision: number; readonly runtime: Runtime };
export type Availability = { readonly enabled: true } | { readonly enabled: false; readonly code: string; readonly targets: readonly Id[] };
export type ReadonlyValueSet<T> = Pick<ReadonlySet<T>, 'size' | 'has' | 'entries' | 'keys' | 'values' | typeof Symbol.iterator> & { forEach(callback: (value: T, value2: T, set: ReadonlyValueSet<T>) => void): void };
export type Capability = 'geometry.move' | 'geometry.resize' | 'geometry.rotate' | 'content.edit' | 'connection.edit';
export type CapabilityHandler = (context: { readonly element: ElementRecord; readonly input: Readonly<Record<string, unknown>> }) => readonly FieldChange[];
export type Feature = { readonly id: string; readonly handlers: Readonly<Partial<Record<Capability, CapabilityHandler>>> };
/** Path segments address own fields; * iterates array entries. many marks a terminal ID array. */
export type CopyReference = { readonly path: readonly string[]; readonly target: 'element' | 'canvas'; readonly many?: boolean; readonly external: 'preserve' | 'reject' };
export type CopySchema = { readonly references: readonly CopyReference[]; readonly omit: readonly (readonly string[])[] };
export type NodeGeometryContext={readonly element:Extract<ElementRecord,{structure:'node'}>;readonly geometry:Geometry};
export type GeometryProtocol=({readonly kind:'node';readonly version:1;readonly dataDependencies?:readonly string[];bounds(context:NodeGeometryContext&{readonly role:'logical'|'hit'|'paint'}):import('./geometry.ts').Rect;hit(context:NodeGeometryContext&{readonly point:Point;readonly tolerance:number}):boolean}|{readonly kind:'connection';readonly version:1;strokeWidth(context:{readonly element:Extract<ElementRecord,{structure:'connection'}>;readonly transform:{readonly x:number;readonly y:number;readonly scale:number;readonly rotation:number}}):number;segments(context:{readonly element:Extract<ElementRecord,{structure:'connection'}>;readonly source:import('./geometry.ts').Connector;readonly target:import('./geometry.ts').Connector;readonly displayPath:Json|undefined;readonly transform:{readonly x:number;readonly y:number;readonly scale:number;readonly rotation:number}}):readonly import('./geometry.ts').Cubic[]});
export type ComponentDefinition = { readonly id: string; readonly dataVersion: number; readonly structure: ElementRecord['structure']; readonly features: readonly Feature[]; readonly validate: (data: Data) => boolean; readonly migrations?: Readonly<Record<number, (data: Data) => Data>>; readonly resourceFields?: readonly string[]; readonly geometry?:GeometryProtocol; readonly copy?: CopySchema };
export type Registry = { readonly components: ReadonlyMap<string, ComponentDefinition>; readonly capabilities: ReadonlyMap<string, ReadonlyValueSet<Capability>>; readonly handlers: ReadonlyMap<string, ReadonlyMap<Capability, CapabilityHandler>>; readonly resolve: (element: ElementRecord) => 'known' | 'unknown-component' | 'unsupported-version'; readonly supports: (element: ElementRecord, capability: Capability) => boolean };

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