211 Commits

Author SHA1 Message Date
codex-bot 74748d6e23 docs(tests): explain input routing and subscription lifecycle coverage 2026-11-12 02:36:36 +08:00
codex-bot 3da19e288a docs(tests): explain query geometry and creation history scenarios 2026-11-12 02:33:00 +08:00
codex-bot 47e318682e docs(tests): explain core invariants and history evidence scenarios 2026-11-12 02:29:37 +08:00
Zayden-Jung 6b74629d98 Merge pull request 'docs(canvas): 说明旧画布树状态与事务协调' (#4) from codex/canvas-tree-comments into main
Reviewed-on: #4
2026-11-11 18:16:16 +00:00
codex-bot 33f5c076f6 docs(canvas): explain legacy tree state and transaction coordination 2026-11-12 02:13:31 +08:00
Zayden-Jung bcf810998d Merge pull request 'docs(canvas): 补充加载预览与旧任务 hooks 注释' (#3) from codex/canvas-panel-comments into main
Reviewed-on: #3
2026-11-11 18:10:50 +00:00
codex-bot f657052fe9 docs(canvas): explain loading previews and legacy task hooks 2026-11-12 02:07:50 +08:00
Zayden-Jung fb737eeb0b Merge pull request 'docs(canvas): 补充任务面板注释并恢复 PR 交付' (#2) from codex/canvas-panel-comments into main
Reviewed-on: #2
2026-11-11 18:07:38 +00:00
codex-bot 6576a45c0a docs(canvas): explain task panels and restore PR delivery workflow 2026-11-12 02:04:00 +08:00
codex-bot 838d3e5c46 docs(canvas): explain text tools and geometry gesture lifecycles 2026-11-12 01:59:22 +08:00
codex-bot 01c4a9d8c5 docs(canvas): explain node editing scopes and display interactions 2026-11-12 01:55:38 +08:00
codex-bot b51d78b47d docs(ui): explain shared controls and canvas display adapters 2026-11-12 01:51:59 +08:00
codex-bot 1306df2b35 docs(workspace): explain host page and sidebar lifecycles 2026-11-12 01:46:09 +08:00
codex-bot 86dac408c3 docs(app): explain route proxy authentication and page composition 2026-11-12 01:41:22 +08:00
codex-bot aacafa7a9b docs(app): explain common API session workspace and geometry helpers 2026-11-12 01:36:26 +08:00
codex-bot c488bc86b6 docs(canvas): explain legacy state history synchronization and subgraphs 2026-11-12 01:32:05 +08:00
codex-bot f28a2a96bf docs(canvas): explain editor entry migration gates and loading lifecycles 2026-11-12 01:26:22 +08:00
codex-bot 450fa3f719 docs(canvas): explain Foundry transport model streams and service receipts 2026-11-12 01:20:49 +08:00
codex-bot 11eaac1f9f docs(canvas): explain React bindings renderer registration and view leases 2026-11-12 01:14:47 +08:00
codex-bot 3f7f8e4b7c docs(canvas): explain scene projection and conservative paint caches 2026-11-12 01:09:39 +08:00
codex-bot 45309eaf87 docs(canvas): explain binding input subscriptions and hit queries 2026-11-12 01:05:17 +08:00
codex-bot c3b7cf93d6 docs(canvas): explain engine recovery public APIs and shutdown 2026-11-12 01:00:57 +08:00
codex-bot 28f1a74d40 docs(canvas): explain resumable import assembly and batch ownership 2026-11-12 00:57:17 +08:00
codex-bot c59850e7a3 docs(canvas): explain receipt coordination run events and image preparation 2026-11-12 00:53:32 +08:00
codex-bot f8e5a07233 docs(canvas): explain engine ownership durability and file retention 2026-11-12 00:49:56 +08:00
codex-bot 6b86ab1d53 docs(canvas): explain run polling and explicit conflict recovery 2026-11-12 00:44:58 +08:00
codex-bot 06a422045f docs(canvas): explain import execution and lazy copy source recovery 2026-11-12 00:37:50 +08:00
codex-bot 72e84a6cc1 docs(canvas): explain import parsers, identities and integrity gates 2026-11-12 00:31:08 +08:00
codex-bot 68e655b52c docs(canvas): explain streamed loading and certified scope admission 2026-11-12 00:25:26 +08:00
codex-bot b471d88520 docs(canvas): explain compute workers and display resource lifetimes 2026-11-12 00:19:19 +08:00
codex-bot af8e2fe76b docs(canvas): explain recovery encoding and history replay evidence 2026-11-12 00:13:52 +08:00
codex-bot da00490ded docs(canvas): explain host ownership, asset retention and durable storage 2026-11-12 00:07:21 +08:00
codex-bot 4c1b35e0cf docs(canvas): explain ledger scheduling and recovery invariants 2026-11-12 00:00:36 +08:00
codex-bot 391cd48be8 docs(canvas): explain query indexes, copy checkpoints and runtime ports 2026-11-11 23:54:16 +08:00
codex-bot 62a0755350 docs(canvas): explain gestures, coordinate spaces and creation planning 2026-11-11 23:46:22 +08:00
codex-bot afa30d4b35 docs(canvas): explain registry contracts and editor publication flow 2026-11-11 23:41:13 +08:00
codex-bot aaeb5acf6e docs(canvas): explain document changes and history lifecycle in Chinese 2026-11-11 23:35:52 +08:00
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
348 changed files with 26861 additions and 198 deletions
+1
View File
@@ -1,5 +1,6 @@
node_modules/
.next/
.headless-dist/
.env*
!.env.example
*.tsbuildinfo
+1 -1
View File
@@ -14,6 +14,6 @@ This block is written and re-added by `next dev` — verify at `node_modules/nex
- 按逻辑完整、可验证的工作单元提交;同一目标可以包含多个 commit。目标或可交付阶段完成并通过适当检查后,主动推送,不必每次提交后立即推送。
- 连续收到需求时,结合当前目标判断是继续同一工作单元,还是拆成独立提交;不要把无关需求混进同一个 commit。尚未验证或未完成的工作如实报告,不把推送成功当作功能验证通过。
- 每次提交前审查差异和暂存区,推送前检查远程状态和待推送 commit;只纳入本次负责的改动。保留用户或其他任务的未提交、未跟踪文件,不擅自暂存、丢弃、stash 或提交。
- 分支和 PR 方式遵循用户当前明确授权。本项目当前阶段已获授权直接推送 main、暂不创建 PR;该阶段结束或用户调整约定后,恢复全局工作分支和 PR 流程。不得把主动管理 Git 理解为强推、重写共享历史、自行合并 PR 或绕过权限的授权。
- 分支和 PR 方式遵循用户当前明确授权。本项目已恢复工作分支和 PR 流程:完成并验证后推送工作分支、创建或更新面向 main 的 PR,等待用户手动合并;不直接推送 main,也不自行合并 PR。不得把主动管理 Git 理解为强推、重写共享历史、自行合并 PR 或绕过权限的授权。
- 涉及 gitea.entroia.com 时始终使用专用 codex-bot 身份和隔离 SSH 配置,遵守全局身份约定。
- 每次交付用中文简要说明涉及的文件与修改目的、检查结果、分支、commit 标识及 push 状态;有阻碍及时说明,已获授权的常规操作无需反复询问。
+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;');
};
+55 -9
View File
@@ -1,14 +1,25 @@
/**
* Foundry的同源服务端代理。浏览器只持HttpOnly会话Cookie,代理转为上游Bearer;
* 限定可转发路径/查询/header,处理体积、超时、素材类型和会话续期。领域回执/模型校验仍由客户端服务适配器执行。
* canonical文档和AI冻结写入保留原字节,模型/图片大响应流式转发,避免代理重编码破坏请求身份或额外缓存整份模型。
*/
import { fetchCanvasResponse } from "@/lib/canvas-response";
import { sessionCookie, sessionMaxAge, SESSION_EXPIRY_HEADER } from "@/lib/session-cookie";
import { NextRequest, NextResponse } from "next/server";
/** 普通账户/工作区/material接口白名单;canvas与asset细分端点由下方独立表达式允许。 */
const allowed =
/^(profile|auth\/(login|register|verify-email|exchange|me|logout)|workspaces(?:\/[a-zA-Z0-9_-]+(?:\/(?:materials(?:\/[a-zA-Z0-9_-]+(?:\/canvas)?)?|search))?)?)$/;
const canvasRoute = /^workspaces\/[a-zA-Z0-9_-]+\/materials\/[a-zA-Z0-9_-]+\/canvas(?:\/(?:connections|capabilities|subgraphs(?:\/(?:migrate|[a-zA-Z0-9_-]+\/(?:members|dissolve)))?|transactions\/[a-zA-Z0-9_-]+\/undo|ai\/(?:models|runs(?:\/[a-zA-Z0-9_-]+(?:\/(?:cancel|apply))?)?)))?$/;
const assetRoute = /^workspaces\/[a-zA-Z0-9_-]+\/assets(?:\/[a-zA-Z0-9_-]+)?$/;
/** 明确列出旧canvas、新model增量/迁移、子图事务和AI端点,拒绝把代理变成任意上游路径通道。 */
const canvasRoute = /^workspaces\/[a-zA-Z0-9_-]+\/materials\/[a-zA-Z0-9_-]+\/canvas(?:\/(?:model(?:\/(?:migrate|operations(?:\/[a-zA-Z0-9_-]+)?))?|operations(?:\/[a-zA-Z0-9_-]+)?|connections|capabilities|subgraphs(?:\/(?:migrate|[a-zA-Z0-9_-]+\/(?:members|dissolve)))?|transactions\/[a-zA-Z0-9_-]+\/undo|ai\/(?:models|operations\/[a-zA-Z0-9_-]+|runs(?:\/[a-zA-Z0-9_-]+(?:\/(?:cancel|apply))?)?)))?$/;
/** 素材上传/原件读取、操作回执和授权元数据是不同路径,后续按类别选择解析与响应方式。 */
const assetRoute = /^workspaces\/[a-zA-Z0-9_-]+\/assets(?:\/(?:(?:operations|metadata)\/[a-zA-Z0-9_-]+|[a-zA-Z0-9_-]+))?$/;
/** 普通请求体上限1MiB;画布5MiB、multipart素材21MiB由handler显式覆盖。 */
const bodyLimit = 1024 * 1024;
// Route Handlers do not have the Pages Router bodyParser size limit.
/** 逐块计数后再组装请求体,超限取消reader,finally释放锁。
* binary原样返回字节供冻结JSON/multipart转发,其它请求才解析JSON;这不是无限body缓冲。 */
async function readBody(req: NextRequest, limit = bodyLimit, binary = false) {
const reader = req.body?.getReader();
if (!reader) return {};
@@ -28,11 +39,14 @@ async function readBody(req: NextRequest, limit = bodyLimit, binary = false) {
} finally {
reader.releaseLock();
}
// 空body普通模式用空对象,binary模式保留空字节;无reader则早已返回空对象。
if (!size) return binary ? new Uint8Array() : {};
const bytes = Buffer.concat(chunks);
return binary ? bytes : JSON.parse(bytes.toString("utf8"));
}
/** 共用GET/POST/PATCH/DELETE处理器。params是当前Next版本的异步路由参数,先await再匹配路径。
* 所有本代理响应默认no-store;HTTP成功/失败保持上游状态,上游调用/响应解析异常另转为502或超时504。 */
async function handler(
req: NextRequest,
{ params }: { params: Promise<{ path: string[] }> },
@@ -40,8 +54,10 @@ async function handler(
const requestStarted = performance.now();
const requestStartedAt = new Date().toISOString();
const responseHeaders = new Headers({ "Cache-Control": "no-store" });
/** 复用当前响应头集合,JSON错误和普通结果都带相同缓存/续期/诊断头策略。 */
const json = (body: unknown, status: number) =>
NextResponse.json(body, { status, headers: responseHeaders });
// 非GET且带Origin时比对host;缺Origin的请求在此不被拒绝,最终身份/权限仍由上游校验。
const requestOrigin = req.headers.get("origin");
if (req.method !== "GET" && requestOrigin) {
try {
@@ -53,46 +69,65 @@ async function handler(
}
const path = (await params).path.join("/");
if (!allowed.test(path) && !canvasRoute.test(path) && !assetRoute.test(path)) return json({ error: "not_found" }, 404);
const asset = assetRoute.test(path);
// operations/metadata返回JSON,不能被当成原图流,也不使用图片multipart请求体上限。
const assetReceipt = assetRoute.test(path) && /\/assets\/operations\//.test(path);
const assetMetadata = assetRoute.test(path) && /\/assets\/metadata\//.test(path);
const asset = assetRoute.test(path) && !assetReceipt && !assetMetadata;
const canvas = canvasRoute.test(path);
const canonical = canvas && /\/canvas\/model(?:\/|$)/.test(path);
const frozenAI = canvas && /\/canvas\/ai\/runs(?:\/|$)/.test(path);
let requestBody: unknown;
// 在网络转发前完成请求体限额:原始图片含multipart开销,新模型/AI写入保留原请求字节。
if (req.method !== "GET") {
try {
requestBody = await readBody(req, asset ? 21 * 1024 * 1024 : path.endsWith("/canvas") ? 5 * 1024 * 1024 : bodyLimit, asset);
requestBody = await readBody(req, asset ? 21 * 1024 * 1024 : canonical || path.endsWith("/canvas") || path.endsWith("/canvas/operations") ? 5 * 1024 * 1024 : bodyLimit, asset || canonical || frozenAI);
} catch (error) {
const tooLarge = error instanceof Error && error.message === "body_too_large";
return json({ error: tooLarge ? "body_too_large" : "invalid_json" }, tooLarge ? 413 : 400);
}
}
const bodyReceived = performance.now();
// 上游地址来自服务端环境,客户端path白名单不会让调用者自己指定任意目标源站。
const origin = process.env.FOUNDRY_API_ORIGIN || "https://api.foundry.entroia.com";
const project = process.env.NEXT_PUBLIC_FOUNDRY_PROJECT || "endoleap";
// multipart沿用浏览器Content-Type和boundary,其余发JSON;不透传浏览器任意请求头。
const headers: Record<string, string> = { "Content-Type": asset ? (req.headers.get("content-type") || "application/octet-stream") : "application/json" };
if (canvas) headers.Accept = "application/vnd.foundry.canvas+json;version=2";
// 仅精确协商的canonical GET模型流改变Accept,普通canvas请求继续兼容v2表示。
const modelStream = canonical && path.endsWith('/canvas/model') && req.method==='GET' && req.headers.get('accept')==='application/vnd.foundry.canvas-model+ndjson;version=1';
if(modelStream)headers.Accept='application/vnd.foundry.canvas-model+ndjson;version=1';
// Bearer从服务端读Cookie,禁止直接依赖客户端自行提供的Authorization冒充当前会话。
const session = req.cookies.get("foundry_session")?.value;
if (session) headers.Authorization = "Bearer " + session;
for (const h of ["x-foundry-code", "x-foundry-verifier", "if-match", "idempotency-key"]) {
// 明确允许OAuth交换、条件版本、幂等键、账户绑定和资源ID;这些值仍需上游验证。
for (const h of ["x-foundry-code", "x-foundry-verifier", "if-match", "idempotency-key", "x-expected-account", "x-resource-id"]) {
const value = req.headers.get(h);
if (value !== null) headers[h] = value;
}
// 查询参数限定名单、单值且每值<300字符,重复值不选择性取第一项,避免前后端解释分歧。
const query = new URLSearchParams();
for (const key of ["kind", "sort", "q", "limit", "offset", "after_seq", "status", "include_descendants", "include_subgraphs", "cursor", "variant", "width"]) {
const values = req.nextUrl.searchParams.getAll(key);
if (values.length === 1 && values[0].length < 300) query.set(key, values[0]);
}
const encodingStarted = performance.now();
const outboundBody = req.method === "GET" ? undefined : asset ? requestBody as BodyInit : JSON.stringify(requestBody || {});
// Frozen document and AI requests cross the proxy byte-for-byte.
// binary Buffer直接转发,不JSON.parse/stringify;普通业务JSON才重编码,GET不带body。
const outboundBody = req.method === "GET" ? undefined : asset || canonical || frozenAI ? requestBody as BodyInit : JSON.stringify(requestBody || {});
const encodeMs = performance.now() - encodingStarted;
const started = performance.now();
try {
const snapshotRead = canvas && path.endsWith("/canvas") && req.method === "GET";
// 模型/旧快照与图片读取使用分层超时流;其它请求组合15秒总超时和客户端取消。
const snapshotRead = canvas && (path.endsWith("/canvas") || canonical) && req.method === "GET";
const streamedRead = snapshotRead || asset && req.method === "GET";
const snapshotWrite = canvas && path.endsWith("/canvas") && req.method === "PATCH";
const traceId = streamedRead || snapshotWrite ? crypto.randomUUID() : "";
/** 大响应只记录传输时序/字节数/资源路径,不把图像/模型内容或会话令牌写入trace。 */
const requestUpstream = streamedRead ? (url: string, options: RequestInit) => fetchCanvasResponse(url, options, undefined, fetch, (trace) => {
console.info(snapshotRead ? "[canvas-transfer]" : "[asset-transfer]", JSON.stringify({ trace_id: traceId, resource: path, ...trace }));
}) : fetch;
if (streamedRead || snapshotWrite) responseHeaders.set("X-Canvas-Trace-Id", traceId);
// 禁止fetch缓存和自动跟随上游跳转;流读取继承请求取消,具体头/body/idle计时由fetchCanvasResponse管理。
const upstream = await requestUpstream(
origin + "/projects/" + encodeURIComponent(project) + "/" + path +
(query.size ? "?" + query.toString() : ""),
@@ -105,17 +140,20 @@ async function handler(
cache: "no-store",
},
);
// 转发明确允许的重试、版本和schema提示,不能用代理自己的时间推算服务端写入版本。
const retryAfter = upstream.headers.get("retry-after");
if (retryAfter) responseHeaders.set("Retry-After", retryAfter);
const etag = upstream.headers.get("etag");
if (etag) responseHeaders.set("ETag", etag);
const schema = upstream.headers.get("x-canvas-schema-version");
if (schema) responseHeaders.set("X-Canvas-Schema-Version", schema);
// 既有会话仅在非401且上游给合法剩余秒数时续期;Secure还取决于生产环境及代理HTTPS头。
const renewal = upstream.headers.get(SESSION_EXPIRY_HEADER);
if (session && upstream.status !== 401 && renewal !== null && /^\d+$/.test(renewal)) {
const secure = process.env.NODE_ENV === "production" && req.headers.get("x-forwarded-proto") === "https";
responseHeaders.set("Set-Cookie", sessionCookie(session, sessionMaxAge(renewal), secure));
}
// 成功素材GET只透传允许的图片MIME并加nosniff;不是任意内容的同源文件托管。
if (asset && upstream.ok && req.method === "GET") {
const type = upstream.headers.get("content-type") || "";
if (!/^image\/(png|jpeg|webp|gif)(?:;|$)/i.test(type)) { await upstream.body?.cancel(); return json({ error: "invalid_asset" }, 502); }
@@ -128,12 +166,15 @@ async function handler(
const headersReceived = performance.now();
// Snapshots contain no login exchange payload. Forward them without buffering
// and parsing/re-encoding multi-megabyte inline image JSON in the proxy.
if (canvas && path.endsWith("/canvas") && req.method === "GET" && upstream.ok) {
responseHeaders.set("Content-Type", "application/json; charset=utf-8");
// canonical含错误回执也原流透传;旧snapshot只有成功GET走此路径。此处不读取/删除模型payload字段。
if (canonical || canvas && path.endsWith("/canvas") && req.method === "GET" && upstream.ok) {
responseHeaders.set("Content-Type", modelStream && upstream.ok && upstream.headers.get('content-type')?.startsWith('application/vnd.foundry.canvas-model+ndjson;') ? upstream.headers.get('content-type')! : "application/json; charset=utf-8");
responseHeaders.set("Server-Timing", `upstream;dur=${(headersReceived - started).toFixed(1)}, proxy_headers;dur=${(performance.now() - headersReceived).toFixed(1)}`);
return new NextResponse(upstream.body, { status: upstream.status, headers: responseHeaders });
}
// 普通响应整体解JSON,因为登录/交换成功时须把access_token换成HttpOnly Cookie并从body移除。
const body = upstream.status === 204 ? null : await upstream.json();
// 旧快照写入trace分出接收/重编码/上游/响应耗时,不输出请求正文;字节数仅对字符串body计算。
if (snapshotWrite) console.info("[canvas-save]", JSON.stringify({
trace_id: traceId, resource: path, status: upstream.status, started_at: requestStartedAt, finished_at: new Date().toISOString(),
client_body_ms: Math.round((bodyReceived - requestStarted) * 10) / 10,
@@ -148,6 +189,7 @@ async function handler(
`upstream;dur=${(headersReceived - started).toFixed(1)}, total;dur=${(performance.now() - started).toFixed(1)}`,
);
const secure = process.env.NODE_ENV === "production" && req.headers.get("x-forwarded-proto") === "https";
// 登录/交换令牌只进Set-Cookie,不回到浏览器可读JSON;其它会话响应按上游剩余时间更新。
if (body?.access_token) {
responseHeaders.set("Set-Cookie", sessionCookie(body.access_token, sessionMaxAge(body.expires_in), secure));
delete body.access_token;
@@ -155,6 +197,7 @@ async function handler(
const renewed = upstream.headers.get(SESSION_EXPIRY_HEADER);
if (renewed !== null && /^\d+$/.test(renewed)) responseHeaders.set("Set-Cookie", sessionCookie(session, sessionMaxAge(renewed), secure));
}
// 仅上游确认登出成功后清Cookie,不把一次网络失败当成成功退出。
if (path === "auth/logout" && upstream.ok)
responseHeaders.set(
"Set-Cookie",
@@ -163,6 +206,7 @@ async function handler(
if (upstream.status === 204)
return new NextResponse(null, { status: 204, headers: responseHeaders });
return json(body || { error: "upstream_unavailable" }, upstream.status);
// 这里只归一化代理传输/解析错误;成功开始转发后的流错误由流自身传播,不能补发第二个JSON响应。
} catch (error) {
responseHeaders.set("Server-Timing", `total;dur=${(performance.now() - started).toFixed(1)}`);
const timeout = error instanceof Error && error.name === "TimeoutError";
@@ -170,6 +214,7 @@ async function handler(
}
}
/** 显式拒绝PUT/HEAD/OPTIONS并说明允许方法,不依赖框架自动推导另一套接口行为。 */
function methodNotAllowed() {
return NextResponse.json({ error: "method_not_allowed" }, {
status: 405,
@@ -177,5 +222,6 @@ function methodNotAllowed() {
});
}
// 同一白名单/体积/会话策略覆盖允许的四种方法;具体操作是否合法仍由服务端端点决定。
export { handler as GET, handler as POST, handler as PATCH, handler as DELETE };
export { methodNotAllowed as PUT, methodNotAllowed as HEAD, methodNotAllowed as OPTIONS };
+7
View File
@@ -1,5 +1,11 @@
/**
* 第三方登录的服务端跳转入口。只接受固定provider及单一PKCE challenge,
* 重定向到配置的Foundry项目,不交换令牌、不读取verifier,也不在此创建会话Cookie。
*/
import { NextRequest, NextResponse } from "next/server";
/** await异步provider参数,challenge须为43字符base64url;多值或未知provider直接400。
* 302显式no-store,避免浏览器把某一次登录challenge的跳转当成可复用响应。 */
export async function GET(
req: NextRequest,
{ params }: { params: Promise<{ provider: string }> },
@@ -13,6 +19,7 @@ export async function GET(
!/^[-_a-zA-Z0-9]{43}$/.test(challenge)
)
return NextResponse.json({ error: "invalid_request" }, { status: 400 });
// 上游源站/项目由宿主环境装配,不让查询参数决定任意OAuth重定向目标。
const origin = process.env.FOUNDRY_API_ORIGIN || "https://api.foundry.entroia.com";
const project = process.env.NEXT_PUBLIC_FOUNDRY_PROJECT || "endoleap";
return NextResponse.redirect(
+4
View File
@@ -1 +1,5 @@
/**
* 认证回调页面复用登录页。真正读取URL片段、邮箱验证或PKCE交换的逻辑集中在login/page,
* 防止两条入口分别实现不同的令牌/Cookie处理规则。
*/
export { default } from "../../login/page";
@@ -1,6 +1,11 @@
/**
* 工作台画布路由的服务器入口,只解析异步路径参数并装配GraphPage。
* 具体board引用解析、账户作用域、新旧模型选择与编辑器生命周期由GraphPage及其加载器负责。
*/
import { Suspense } from "react";
import GraphPage from "@/components/workspace/graph-page";
/** params按当前Next版本await;Suspense给客户端画布准备过程提供状态占位,不在此发模型请求。 */
export default async function Page({ params }: { params: Promise<{ workspace: string; board: string }> }) {
const { workspace, board } = await params;
return (
+35 -7
View File
@@ -1,3 +1,8 @@
/**
* 工作台共享侧栏与浮动快捷条。管理账户/工作区目录、搜索、折叠、改名和账户面板,
* 活动board元数据来自Context,画布业务状态由RuntimeHost拥有;这里不持有第二份元素文档。
* 浮动条在画布路由或侧栏折叠时显示,展开按钮仅在折叠时显示,收起按钮留在展开侧栏中。
*/
"use client";
import { useEffect, useRef, useState } from "react";
@@ -12,14 +17,17 @@ import { useActiveBoard } from "@/components/workspace/active-board";
import toolbar from "@/components/ui/toolbar.module.css";
import { EditableTitle } from "@/components/workspace/editable-title";
import { UserAvatar } from "@/components/workspace/user-avatar";
import { useCanvasRuntimeHost } from "@/components/workspace/canvas-runtime-host";
import { AccountPanel } from "@/components/workspace/account-panel";
import { readDemoBoards } from "@/lib/demo-workspace";
import { loadBoardDirectory } from "@/lib/board-directory";
import { BoardDirectory } from "@/components/workspace/board-directory";
import styles from "@/components/workspace/workspace.module.css";
/** 路径/查询参数优先定位空间,目录解析真实ID,UI状态与账户/目录异步生命周期分别维护。 */
export default function DashboardSidebar() {
const router = useRouter();
const runtimeHost = useCanvasRuntimeHost();
const { board: activeBoard, setBoard: setActiveBoard } = useActiveBoard();
const reduceMotion = useReducedMotion();
const params = useParams<{ workspace?: string; board?: string }>();
@@ -33,19 +41,23 @@ export default function DashboardSidebar() {
const [status, setStatus] = useState<"loading" | "ready" | "error">("loading");
const [error, setError] = useState("");
const [collapsed, setCollapsed] = useState(false);
const [accountAnchor, setAccountAnchor] = useState<"sidebar" | "floating">("sidebar");
const [query, setQuery] = useState("");
const [creating, setCreating] = useState(false);
const [panel, setPanel] = useState<"settings" | "workspace" | null>(null);
const expandButton = useRef<HTMLButtonElement>(null);
const collapseButton = useRef<HTMLButtonElement>(null);
const hasToggled = useRef(false);
// 同步ref防重复点击创建,listVersion隔离迟到目录列表;二者都不是runtime账户authEpoch。
const createLock = useRef(false);
const listVersion = useRef(0);
const accountPopup = useRef<HTMLDivElement>(null);
// 路径workspace优先于查询和本地选择;slug由目录解析,活动board可补充该路由引用的真实ID。
const workspaceRef = params.workspace || searchParams.get("workspace") || selectedWorkspace;
const currentWorkspace = workspaces.find((item) => matchesRef(item, workspaceRef));
const workspace = currentWorkspace?.id || (activeBoard?.workspace_ref === workspaceRef ? activeBoard.workspace_id : workspaceRef);
/** demo用本地资料,真实模式并行加载profile/空间;active+signal阻止卸载后的资料结果写入。 */
useEffect(() => {
if (demo) {
setProfile({ id: "demo", username: "creator", display_name: "创作者", email: null });
@@ -74,6 +86,7 @@ export default function DashboardSidebar() {
return () => { active = false; controller.abort(); };
}, [demo, router]);
/** 每次目录加载先留本空间项,再取完整分页;仅最新listVersion可采用列表,换空间时中断旧请求。 */
useEffect(() => {
if (!workspace || status !== "ready") return;
const version = ++listVersion.current;
@@ -90,13 +103,17 @@ export default function DashboardSidebar() {
return () => { active = false; controller.abort(); };
}, [workspace, demo, status, directoryRevision]);
/** 同工作区目录变化事件合并100ms后重读,清理时撤销计时和监听,不跨空间误刷新。 */
useEffect(() => {
let timer: ReturnType<typeof setTimeout>;
/** 忽略其它workspace事件;多次事件只触发一次revision递增和后续完整目录读取。 */
const refresh = (event: Event) => { if ((event as CustomEvent).detail?.workspace !== workspace) return; clearTimeout(timer); timer = setTimeout(() => setDirectoryRevision((value) => value + 1), 100); };
window.addEventListener("endoleap:board-directory-changed", refresh);
return () => { clearTimeout(timer); window.removeEventListener("endoleap:board-directory-changed", refresh); };
}, [workspace]);
/** 就绪且未重复创建才执行,真实模式必要时先建个人空间,demo只写本地目录。
* 成功递增listVersion防旧列表盖掉新项,再清搜索并导航;创建锁仅保护此UI操作,不替代账户身份护栏。 */
async function newPage() {
if (createLock.current || status !== "ready") return;
createLock.current = true;
@@ -118,6 +135,7 @@ export default function DashboardSidebar() {
finally { createLock.current = false; setCreating(false); }
}
/** 用户实际切换折叠后,下一帧聚焦新出现的展开/收起按钮;初始渲染不抢焦点。 */
useEffect(() => {
if (!hasToggled.current) return;
const frame = requestAnimationFrame(() => {
@@ -126,31 +144,38 @@ export default function DashboardSidebar() {
return () => cancelAnimationFrame(frame);
}, [collapsed]);
/** 先关闭账户popover再切布局,避免旧锚点残留弹层;焦点转移交给effect。 */
function toggleSidebar(value: boolean) {
accountPopup.current?.hidePopover();
hasToggled.current = true;
setCollapsed(value);
}
/** 关闭popover后打开设置/新空间对话框,避免两层交互同时争焦点。 */
function openPanel(mode: "settings" | "workspace") {
accountPopup.current?.hidePopover();
setPanel(mode);
}
/** 真实退出先retireIdentity并完成本地关闭/保存,再请求服务登出;失败留在当前界面报告,不跳过保存。 */
async function logout() {
try {
if (!demo) await api("auth/logout", { method: "POST" });
if (!demo) { await runtimeHost.retireIdentity(); await api("auth/logout", { method: "POST" }); }
router.push("/login");
} catch (e) { setError(errorText(e)); }
}
// 已解析活动board标题优先,退回目录标题或加载文案;不从另一工作区的活动记录借名。
const currentTitle = activeBoard && matchesRef(activeBoard, params.board) && activeBoard.workspace_id === workspace
? activeBoard.title
: boards.find((board) => matchesRef(board, params.board))?.title || (params.board ? "正在读取图谱…" : "工作空间");
const name = profile?.display_name || profile?.username || "账户";
// 计数用标题直接匹配;树中保留的搜索祖先由BoardDirectory独立派生,因此数目不等于所有可见行数。
const filtered = boards.filter((b) => b.title.toLocaleLowerCase().includes(query.trim().toLocaleLowerCase()));
// UI能力只作提示/禁用,真实创建权限仍由服务端判定,未知空间不能当成已有编辑权限。
const canCreate = status === "ready" && (!workspace || Boolean(currentWorkspace && currentWorkspace.role !== "viewer"));
/** 成功后同时更新本地目录与匹配的活动board元数据;错误转换成文案交EditableTitle保留草稿显示。 */
async function renameTitle(title: string) {
const id = activeBoard?.id || params.board;
if (!id || !workspace) return;
@@ -164,21 +189,24 @@ export default function DashboardSidebar() {
} catch (error) { throw new Error(errorText(error)); }
}
// 创建入口复用同一busy/权限状态,降低重复点击,但不在render中发请求。
const createButton = <IconButton icon={FilePlus2} label={creating ? "正在创建图谱" : "新建页面"} disabled={!canCreate || creating} onClick={newPage} aria-busy={creating} />;
// 尊重减少动态效果偏好;折叠侧栏同时inert/aria-hidden,避免不可见内容仍接受键盘交互。
const transition = { duration: reduceMotion ? 0 : 0.24, ease: [0.22, 1, 0.36, 1] as const };
const roleLabel = currentWorkspace?.role === "admin" ? "管理员" : currentWorkspace?.role === "editor" ? "编辑者" : currentWorkspace?.role === "viewer" ? "查看者" : "工作空间";
// 展示分为浮动条、侧栏目录、共享账户popover和账户面板;路由/空间作为key让标题及目录局部状态重置。
return (
<>
<AnimatePresence initial={false}>
{collapsed && (
<motion.div key="sidebar-controls" className={`${toolbar.surface} ${toolbar.relaxed} ${styles.floating}`} aria-label="侧边栏快捷操作"
{(collapsed || Boolean(params.board)) && (
<motion.div key="sidebar-controls" className={`${toolbar.surface} ${toolbar.relaxed} ${styles.floating}`} data-sidebar-collapsed={collapsed} aria-label={params.board ? "画布快捷操作" : "侧边栏快捷操作"}
initial={{ opacity: 0, y: reduceMotion ? 0 : -6 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: reduceMotion ? 0 : -6 }}
transition={{ duration: reduceMotion ? 0 : 0.14 }}>
<IconButton ref={expandButton} icon={PanelLeftOpen} label="展开侧边栏" aria-expanded={false} aria-controls="workspace-sidebar" onClick={() => toggleSidebar(false)} />
{collapsed && <IconButton ref={expandButton} icon={PanelLeftOpen} label="展开侧边栏" aria-expanded={false} aria-controls="workspace-sidebar" onClick={() => toggleSidebar(false)} />}
<Link href="/" className={styles.logoLink} aria-label="啡跃 Endoleap 主页" title="返回主页">
<img src="/brand/png/logo-light/endoleap-32.png"
srcSet="/brand/png/logo-light/endoleap-32.png 1x, /brand/png/logo-light/endoleap-64.png 2x, /brand/png/logo-light/endoleap-128.png 4x"
@@ -189,7 +217,7 @@ export default function DashboardSidebar() {
editable={Boolean(params.board && activeBoard && matchesRef(activeBoard, params.board) && activeBoard.workspace_id === workspace && canCreate)}
onSave={renameTitle} className={styles.floatingTitle} />
<span className={toolbar.divider} aria-hidden="true" />
<button type="button" className={styles.floatingAvatarButton} popoverTarget="workspace-account" aria-label="账户和工作空间">
<button type="button" className={styles.floatingAvatarButton} popoverTarget="workspace-account" aria-label="账户和工作空间" onClick={() => setAccountAnchor("floating")}>
<UserAvatar name={name} url={profile?.avatar_url} />
</button>
{error && <p className={styles.floatingError} role="alert">{error}</p>}
@@ -220,7 +248,7 @@ export default function DashboardSidebar() {
</nav>
{error && <p className={styles.error} role="alert">{error}</p>}
<div className={styles.accountArea}>
<button type="button" className={styles.accountButton} popoverTarget="workspace-account" aria-label="账户和工作空间">
<button type="button" className={styles.accountButton} popoverTarget="workspace-account" aria-label="账户和工作空间" onClick={() => setAccountAnchor("sidebar")}>
<UserAvatar name={name} url={profile?.avatar_url} />
<span className={styles.accountText}><strong>{name}</strong><small>{currentWorkspace?.name || (demo ? "本地演示" : "个人账户")}</small></span>
<ChevronsUpDown size={14} strokeWidth={1.6} />
@@ -228,7 +256,7 @@ export default function DashboardSidebar() {
</div>
</motion.aside>
</motion.div>
<div ref={accountPopup} id="workspace-account" popover="auto" className={styles.accountPopup} data-floating={collapsed} aria-label="账户和工作空间菜单">
<div ref={accountPopup} id="workspace-account" popover="auto" className={styles.accountPopup} data-floating={accountAnchor === "floating"} data-sidebar-collapsed={collapsed} aria-label="账户和工作空间菜单">
<div className={styles.accountHeading}>
<span className={styles.workspaceAvatar} aria-hidden="true">{(currentWorkspace?.name || "啡跃").slice(0, 1)}</span>
<div><strong>{currentWorkspace?.name || "你的工作空间"}</strong><small>{roleLabel}{demo ? " · 本地演示" : ""}</small></div>
+5
View File
@@ -1,9 +1,14 @@
/**
* 工作台共享布局,装配当前画布Context、侧栏及子路由,并加载React Flow默认样式。
* 布局本身只放侧栏与children;根叶页面为空,画布子路由通过children提供主编辑区。
*/
import "@xyflow/react/dist/style.css";
import { Suspense, type ReactNode } from "react";
import { ActiveBoardProvider } from "@/components/workspace/active-board";
import DashboardSidebar from "./dashboard";
import styles from "@/components/workspace/workspace.module.css";
/** 当前board信息由ActiveBoardProvider跨侧栏/画布共享;Suspense只包侧栏,fallback保留其布局宽度。 */
export default function DashboardLayout({ children }: { children: ReactNode }) {
return (
<ActiveBoardProvider>
+5
View File
@@ -1,3 +1,8 @@
/**
* 工作台根路径的空叶页面。共享dashboard布局继续显示侧栏,
* 这里返回null是路由占位,不代表工作区没有内容或需要创建一张空画布。
*/
/** 保留共享布局,不额外重复渲染工作台内容。 */
export default function DashboardPage() {
return null;
}
+8 -1
View File
@@ -1,8 +1,14 @@
/**
* 整个App Router的根布局:统一页面元数据、全局CSS、应用反馈和CanvasRuntimeHost装配。
* 子页面/画布视图借用同一个宿主,普通路由切换不应各自创建另一份项目运行时。
*/
import { CanvasRuntimeHost } from "@/components/workspace/canvas-runtime-host";
import { AppFeedback } from "@/components/ui/app-feedback";
import type { Metadata } from "next";
import type { ReactNode } from "react";
import "@/styles/globals.css";
/** 标题/描述及不同色彩偏好的favicon,交给Next元数据系统生成页面头,不在客户端effect中改DOM。 */
export const metadata: Metadata = {
title: "啡跃 Endoleap · 让灵感有形",
description: "收藏灵感、连接知识,在自己的创作空间让想法慢慢成形。",
@@ -16,10 +22,11 @@ export const metadata: Metadata = {
},
};
/** 服务端布局输出中文html/body壳;客户端反馈Provider包在runtime宿主外侧,供宿主状态提示使用。 */
export default function RootLayout({ children }: { children: ReactNode }) {
return (
<html lang="zh-CN" data-scroll-behavior="smooth">
<body><AppFeedback>{children}</AppFeedback></body>
<body><AppFeedback><CanvasRuntimeHost>{children}</CanvasRuntimeHost></AppFeedback></body>
</html>
);
}
+14
View File
@@ -1,3 +1,7 @@
/**
* 登录、注册及认证回调共用的客户端页面。左侧品牌说明为展示内容,右侧管理表单/OAuth/验证状态。
* 成功会话由同源代理写HttpOnly Cookie,组件不持有access_token;busy仅控制当前表单交互和加载提示。
*/
"use client";
import { useEffect, useState, type FormEvent } from "react";
@@ -13,6 +17,7 @@ import {
import { Brand } from "@/components/brand";
import { Button } from "@/components/ui/button";
import { api, errorText, startOAuth } from "@/lib/api";
/** 状态分别表示登录/注册模式、邮箱密码、密码可见性、请求忙碌和成功/错误文案,均不存入业务文档。 */
export default function Login() {
const router = useRouter();
const [register, setRegister] = useState(false),
@@ -22,6 +27,8 @@ export default function Login() {
[busy, setBusy] = useState(false),
[message, setMessage] = useState(""),
[error, setError] = useState("");
/** 页面挂载读取回调片段,先清掉URL查询/片段再发验证或交换请求;这样重跑不会再次消费同一片段。
* 此effect没有单独AbortController,不具备画布loader的导航代次隔离;结果由此页面的router/state回调处理。 */
useEffect(() => {
const hash = new URLSearchParams(location.hash.slice(1));
const code = hash.get("code"),
@@ -34,8 +41,10 @@ export default function Login() {
return;
}
setBusy(true);
// verifier从本标签页sessionStorage取出即删除,避免后续登录误复用;verify_email分支优先于code交换。
const verifier = sessionStorage.getItem("foundry_verifier");
sessionStorage.removeItem("foundry_verifier");
// 邮箱验证提交token;OAuth交换只用一次性code+verifier,请求代理负责把返回令牌改写成Cookie。
(verify
? api("auth/verify-email", {
method: "POST",
@@ -55,6 +64,8 @@ export default function Login() {
.catch((e) => setError(errorText(e)))
.finally(() => setBusy(false));
}, [router]);
/** 阻止原生表单跳转,提交当前模式对应端点;注册后提示验证邮件,登录成功才push工作台。
* HTML输入限制给即时反馈,服务端仍负责真实凭证/注册校验。 */
async function submit(e: FormEvent) {
e.preventDefault();
setBusy(true);
@@ -74,6 +85,7 @@ export default function Login() {
setBusy(false);
}
}
/** 清旧提示,创建PKCE后整页跳转;发起失败恢复表单状态,成功保持busy等待浏览器导航。 */
async function oauth(p: "google" | "slack") {
setError("");
setMessage("正在前往 " + (p === "google" ? "Google" : "Slack") + "…");
@@ -86,6 +98,8 @@ export default function Login() {
setError("无法启动登录,请重新尝试。");
}
}
// 模式切换只清提示,已有邮箱/密码和可见性独立保留。
// 渲染分为品牌故事、模式切换、受控凭证表单、OAuth按钮与demo入口;错误用alert,非错误提示用status。
return (
<div className="auth-page">
<aside className="auth-story">
+7
View File
@@ -1,7 +1,12 @@
/**
* 公开首页与已登录工作台的服务器分流。Cookie存在不是有效会话证明,需向profile端点验证后才重定向。
* 会话无效或服务暂不可达仍显示公开LandingPage,不因为一枚旧Cookie隐藏公开内容。
*/
import { cookies } from "next/headers";
import { redirect } from "next/navigation";
import LandingPage from "@/components/landing-page";
/** 异步读取请求Cookie,带15秒时限/禁缓存验证上游;只需成功状态,不消费完整profile JSON。 */
export default async function Home() {
const session = (await cookies()).get("foundry_session")?.value;
let authenticated = false;
@@ -15,12 +20,14 @@ export default async function Home() {
redirect: "manual",
signal: AbortSignal.timeout(15000),
});
// 按响应状态确定是否已认证,再丢弃不需要的body;此处不解析profile字段。
authenticated = response.ok;
await response.body?.cancel();
} catch {
// An unverified session must not hide the public landing page.
}
}
// 仅在确认成功后跳工作台,未认证时沿用同一静态展示组件。
if (authenticated) redirect("/dashboard");
return <LandingPage />;
}
+5
View File
@@ -1,4 +1,9 @@
/**
* 公用品牌首页链接。组合Logo、中文名与英文名,供登录页、落地页等位置复用;不读取用户或工作区状态。
*/
import Link from "next/link";
/** light选择brand-light展示样式以及配套Logo资源:true用logo-dark.svg,false用logo-light.svg。
* 链接的aria-label已经说明品牌与目的地,图片使用空alt避免重复朗读;固定尺寸减少加载时布局跳动。 */
export function Brand({ light = false }: { light?: boolean }) {
return (
<Link
+14
View File
@@ -1,3 +1,7 @@
/**
* 旧版画布的节点AI浮层。通过useCanvasAI返回的控制接口管理生成,模型目录由旧画布AI接口读取。
* 配置草稿保存在本地state,save回调将配置写回旧节点;本组件不承担新版ledger调度与恢复。
*/
"use client";
import { useEffect, useState } from "react";
import { NodeToolbar, Position } from "@xyflow/react";
@@ -8,6 +12,7 @@ import { CANVAS_ACCEPT, managedImageSource } from "@/lib/canvas-v2";
import type { CanvasNode } from "@/lib/canvas";
import type { CanvasModel, useCanvasAI } from "./use-canvas-ai";
import styles from "./canvas.module.css";
/** 以boardId+node.id定位run步骤,列表最后一项作为当前任务;NodeToolbar将面板附着在显示节点下方。 */
export function AIPanel({ id, node, workspace, boardId, ai, save }: { id: string; node: CanvasNode; workspace: string; boardId: string; ai: ReturnType<typeof useCanvasAI>; save: (patch: NonNullable<CanvasNode["data"]["ai"]>) => void }) {
const [models, setModels] = useState<CanvasModel[]>([]);
const [model, setModel] = useState(node.data.ai?.modelId || "");
@@ -19,7 +24,10 @@ export function AIPanel({ id, node, workspace, boardId, ai, save }: { id: string
const image = ["image", "plain-image"].includes(node.data.kind);
const relevant = [...ai.runs.values()].filter((run) => run.steps?.some((step) => step.board_id === boardId && step.node_id === node.id));
const run = relevant.at(-1), step = run?.steps?.find((step) => step.board_id === boardId && step.node_id === node.id);
// starting防止本地重复启动,之后以步骤queued/running判断生成忙碌;这不是整份画布的同步锁。
const busy = starting || step?.status === "queued" || step?.status === "running";
// 演示/非live路径不请求模型;真实路径按输出模态筛选,并保留仍兼容选择或回退到首个可用模型。
// 清理时abort,失败和finally检查signal再更新;成功分支直接采用api返回的结果。
useEffect(() => {
if (!ai.live) { setLoading(false); return; }
const controller = new AbortController();
@@ -30,10 +38,16 @@ export function AIPanel({ id, node, workspace, boardId, ai, save }: { id: string
}).catch((error) => { if (!controller.signal.aborted) setError(errorText(error)); }).finally(() => { if (!controller.signal.aborted) setLoading(false); });
return () => controller.abort();
}, [workspace, boardId, image, ai.live]);
/** 构造当前输入配置,参数固定为空对象;状态切换事件需显式替换刚改变的值,避免读取旧React state。 */
const config = () => ({ enabled: true, modelId: model, prompt, propagate, parameters: {} });
/** 输入blur时,有模型且live才调用save;仅持久化配置,不启动服务端任务。 */
const persist = () => { if (model && ai.live) save(config()); };
// output取当前步骤资源,文本内容则从最后一个step.output事件取;两者按同一board/node限定。
const output = run?.outputs?.find((item) => item.step_id === step?.step_id)?.output;
const textOutput = run?.events?.filter((event) => event.type === "step.output" && event.node_id === node.id && event.board_id === boardId).at(-1)?.output?.text;
// 模型和传播开关变化即时save,正文blur保存;执行由ai.run负责,starting在finally复位。
// 上传中、演示或没有可用模型禁止启动;与新版不同,此处未要求prompt非空,也未在busy时禁用textarea。
// 冲突时保留结果并给显式apply入口,错误优先显示本面板错误;关闭/取消任务的行为交由父层与ai接口决定。
return <NodeToolbar nodeId={id} isVisible position={Position.Bottom} offset={26} style={{ zIndex: 110 }}><section className={`${styles.aiPanel} nodrag nopan nowheel`} aria-label="元素 AI 工具"
onPointerDown={(event) => event.stopPropagation()} onClick={(event) => event.stopPropagation()} onDoubleClick={(event) => event.stopPropagation()} onKeyDown={(event) => event.stopPropagation()}>
<header><Sparkles size={14} /><strong>{image ? "图像创作" : "文本创作"}</strong><span>{node.data.title}</span></header>
+9
View File
@@ -1,3 +1,6 @@
/**
* 从节点连接点发起的分支类型选择菜单。Portal挂在body,使用屏幕坐标定位;选择结果交给choose创建内容与连线。
*/
"use client";
import { useEffect, useRef } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
@@ -6,10 +9,14 @@ import { FileText, ImagePlus, StickyNote, X } from "lucide-react";
import type { XYPosition } from "@xyflow/react";
import type { NodeDirection } from "@/lib/canvas";
import styles from "./canvas.module.css";
/** id/direction标识发起节点和边,point是菜单屏幕位置,at可由调用方保存拟创建位置;菜单不解释at。 */
export type BranchRequest = { id: string; direction: NodeDirection; point: XYPosition; at?: XYPosition };
/** request为空时不显示,类型固定为文本/图片/脚本;这里只派发选择,不执行上传或文档修改。 */
export function BranchMenu({ request, close, choose }: { request: BranchRequest | null; close: () => void; choose: (kind: "text" | "image" | "script") => void }) {
const ref = useRef<HTMLDivElement>(null);
const reduce = useReducedMotion();
// 菜单打开后保存原焦点并聚焦首个类型按钮;点击外部关闭,Escape关闭且恢复原焦点。
// 仅在request存在时安装全局监听,变化/卸载时拆除;普通选择或外部关闭不在此强制恢复焦点。
useEffect(() => {
if (!request) return;
const focus = document.activeElement as HTMLElement | null;
@@ -19,6 +26,8 @@ export function BranchMenu({ request, close, choose }: { request: BranchRequest
window.addEventListener("pointerdown", outside); window.addEventListener("keydown", escape, true);
return () => { window.removeEventListener("pointerdown", outside); window.removeEventListener("keydown", escape, true); };
}, [request, close]);
// 无document时不创建Portal。菜单位置按预设宽高限制在视口边距内;上下键循环所有按钮(含关闭)。
// 减少动态效果偏好禁用位移/缩放动画;退出动画仍通过AnimatePresence管理。
if (typeof document === "undefined") return null;
return createPortal(<AnimatePresence>{request && <motion.div ref={ref} key={request.id + request.direction} initial={{ opacity: 0, scale: reduce ? 1 : 0.96, y: reduce ? 0 : 4 }} animate={{ opacity: 1, scale: 1, y: 0 }} exit={{ opacity: 0, scale: reduce ? 1 : 0.96 }} transition={{ duration: reduce ? 0 : 0.14 }} role="dialog" aria-label="选择连接节点类型" className={styles.branchMenu} style={{ left: Math.max(12, Math.min(request.point.x + 12, window.innerWidth - 222)), top: Math.max(12, Math.min(request.point.y + 12, window.innerHeight - 208)) }}
onKeyDown={(event) => { event.stopPropagation(); if (event.key === "ArrowDown" || event.key === "ArrowUp") { event.preventDefault(); const buttons = Array.from(ref.current?.querySelectorAll<HTMLButtonElement>("button") || []); const index = buttons.indexOf(document.activeElement as HTMLButtonElement); buttons[(index + (event.key === "ArrowDown" ? 1 : -1) + buttons.length) % buttons.length]?.focus(); } }}>
+81 -21
View File
@@ -1,26 +1,33 @@
/**
* 画布连线显示与贝塞尔链编辑。根据端点生成路径,叠加箭头、选中轮廓和动画,选中时提供曲线点及控制柄。
* 指针拖动通过租约预览后提交;旧接口保留transient patch兼容路径。组件不自行同步服务端或管理撤销栈。
*/
"use client";
import { useContext, useRef, useState, type PointerEvent } from "react";
import { useContext, useEffect, useRef, useState, type PointerEvent } from "react";
import { BaseEdge, useInternalNode, useReactFlow, useViewport, type ConnectionLineComponentProps, type EdgeProps, type InternalNode } from "@xyflow/react";
import { useReducedMotion } from "motion/react";
import type { ProjectionNode } from "@/lib/canvas-bindings/projection";
import type { CanvasNode } from "@/lib/canvas";
import { connectorBezier, nodeConnector, type ConnectorSide, type Connector } from "@/lib/canvas-geometry";
import {compatConnector} from "@/lib/canvas-bindings/connectors";
import { connectorBezier, type ConnectorSide, type Connector } from "@/lib/canvas-geometry";
import { curvePath, curveSegments, cubicPoint, insertCurveKnot, minus, plus } from "@/lib/canvas-curves";
import type { CanvasEdgeModel, EdgeMarker } from "@/lib/canvas-v2";
import type { EditingLease } from "./editing-lease";
import { EdgeEditing } from "./edge-editing";
import { EdgeToolbarControls } from "./edge-toolbar";
import {ownsPointerCapture,acquirePointerCapture,releasePointerCapture} from "@/lib/canvas-bindings/pointer-capture";
import styles from "./canvas.module.css";
function connector(node: InternalNode<CanvasNode>, handle: string | null | undefined, fallback: ConnectorSide): Connector {
const port = node.data.ports?.find((port) => `port:${port.id}` === handle);
const side = handle === "top" || handle === "right" || handle === "bottom" || handle === "left" ? handle : port?.direction === "input" ? "left" : port ? "right" : fallback;
const result = nodeConnector({ ...node, position: node.internals.positionAbsolute }, side);
if (port) {
const peers = node.data.ports!.filter((item) => item.direction === port.direction);
result.point.y += ((peers.indexOf(port) + 1) / (peers.length + 1) - .5) * (node.measured?.height || node.height || 400);
}
return result;
/** 旧节点用绝对位置替换局部position后,交compatConnector计算旋转/缩放等兼容端点。 */
function connector(node:InternalNode<CanvasNode>,handle:string|null|undefined,fallback:ConnectorSide):Connector{return compatConnector({...node,position:node.internals.positionAbsolute},handle,fallback);}
/** 新投影已经给出端点坐标,只需按连接边补朝外法向;避免再应用一遍旧节点几何变换。 */
function nativeConnector(x:number,y:number,side:ConnectorSide):Connector{
const normals={top:{x:0,y:-1},right:{x:1,y:0},bottom:{x:0,y:1},left:{x:-1,y:0}};
return {point:{x,y},normal:normals[side]};
}
/** 生成可复用SVG端部标记;none不绘制,开放箭头不填充,其余按filled使用线色或画布底色。
* userSpaceOnUse使大小不随线宽缩放,auto-start-reverse支持起终两端朝向。 */
function Marker({ id, value, color }: { id: string; value?: EdgeMarker; color: string }) {
const type = value?.type || "none";
if (type === "none") return null;
@@ -28,6 +35,7 @@ function Marker({ id, value, color }: { id: string; value?: EdgeMarker; color: s
<path d={type === "triangle" ? "M1 1L11 6L1 11Z" : type === "open-arrow" ? "M1 1L11 6L1 11" : type === "diamond" ? "M1 6L6 1L11 6L6 11Z" : type === "square" ? "M1 1H11V11H1Z" : "M11 6a5 5 0 1 0-10 0a5 5 0 1 0 10 0"} fill={type === "open-arrow" ? "none" : value?.filled !== false ? color : "#fffefb"} stroke={color} strokeWidth={1.2} />
</marker>;
}
/** 读取两端内部节点、视口与编辑上下文;selectedKnot仅是当前控件选择,不进入连线数据。 */
export function CanvasEdge(props: EdgeProps<CanvasEdgeModel>) {
const source = useInternalNode<CanvasNode>(props.source), target = useInternalNode<CanvasNode>(props.target);
const editing = useContext(EdgeEditing);
@@ -35,40 +43,68 @@ export function CanvasEdge(props: EdgeProps<CanvasEdgeModel>) {
const { zoom } = useViewport();
const reduce = useReducedMotion();
const [selectedKnot, setSelectedKnot] = useState<string | null>(null);
const gesture = useRef<{ id: string; part: "position" | "in" | "out" | "startControl" | "endControl" } | null>(null);
// 手势记录控制点id/part与捕获目标;事件监听通过AbortController统一拆除,lease可缺省以兼容旧编辑器。
const gesture = useRef<{ capture: Element; controller: AbortController; pointerId: number; lease?: EditingLease<NonNullable<CanvasEdgeModel['data']>['path']>; id: string; part: "position" | "in" | "out" | "startControl" | "endControl" } | null>(null);
// 原生监听通过ref使用最新move/stop/cancel,避免投影更新后继续用过期path或端点闭包。
const pointerHandlers = useRef<{ move(event: PointerEvent<SVGCircleElement>): void; stop(event: PointerEvent<SVGCircleElement>): void; cancel(event: PointerEvent<SVGCircleElement>): void } | null>(null);
// 只挂载一次的失焦/卸载监听经ref调用最新cancel,释放未完成手势。
const interrupt=useRef(()=>{});interrupt.current=()=>cancel();
useEffect(()=>{const blur=()=>interrupt.current();window.addEventListener('blur',blur);return()=>{window.removeEventListener('blur',blur);interrupt.current();};},[]);
// 任一端点节点未就绪就不绘制;新版端点使用ReactFlow计算结果,旧版由兼容几何计算。
if (!source || !target) return null;
const from = connector(source, props.sourceHandleId, "right"), to = connector(target, props.targetHandleId, "left");
const from = (source.data as ProjectionNode['data']).__headlessRecord ? nativeConnector(props.sourceX,props.sourceY,props.sourcePosition) : connector(source, props.sourceHandleId, "right"), to = (target.data as ProjectionNode['data']).__headlessRecord ? nativeConnector(props.targetX,props.targetY,props.targetPosition) : connector(target, props.targetHandleId, "left");
const data = props.data || {}, appearance = data.appearance || {}, animation = data.animation || {};
// 贝塞尔链生成可绘制path;工具栏锚点取中间一段的t=0.5,属于近似中心而非整条曲线的弧长中点。
const segments = curveSegments(from, to, data.path), path = curvePath(segments);
const midpoint = cubicPoint(segments[Math.floor(segments.length / 2)], .5);
const color = appearance.color || "#aa968b", width = appearance.width || 1.5;
// 清洗边ID供SVG marker引用;动画尊重减少动态效果,executing模式只在active集合含本边时播放。
const markerId = `edge-${props.id.replace(/[^a-zA-Z0-9_-]/g, "_")}`;
const animated = !reduce && (animation.mode === "always" || animation.mode === "executing" && editing.active.has(props.id));
const knots = data.path?.knots || [];
/** 最多64个中间曲线点;insertCurveKnot按给定点或默认位置拆分曲线,提交后选择新点。 */
function add(point?: { x: number; y: number }) {
if (knots.length >= 64) return;
const next = insertCurveKnot(from, to, data.path, point);
editing.patch(props.id, { ...data, path: next.path }); setSelectedKnot(next.knot.id);
}
/** 仅未处理的主指针左键可开始。已有手势不被重复接管,另一指针到来先取消原手势。
* 捕获优先放在稳定的画布surface上,避免投影替换SVG控制点后丢掉后续事件。 */
function start(event: PointerEvent<SVGCircleElement>, id: string, part: NonNullable<typeof gesture.current>["part"]) {
event.preventDefault(); event.stopPropagation(); editing.begin(props.id); gesture.current = { id, part }; setSelectedKnot(id);
event.currentTarget.setPointerCapture(event.pointerId);
if(event.button!==0||event.defaultPrevented)return;if(gesture.current){if(gesture.current.pointerId!==event.pointerId)cancel();return;}if(event.isPrimary===false)return;
// The SVG control can be replaced by a projection update. The view surface survives it.
const capture = event.currentTarget.closest('[data-headless-canvas]') ?? event.currentTarget.closest('.react-flow') ?? event.currentTarget;
// begin返回false拒绝;返回对象则保存租约,其余返回值走旧接口,控制点选择仍是组件局部状态。
const lease = editing.begin(props.id); if (lease === false) return;
const controller = new AbortController();
gesture.current = { id, part, capture, controller, pointerId: event.pointerId, lease: lease && typeof lease === 'object' ? lease : undefined }; setSelectedKnot(id);
/** 原生监听先按pointerId过滤,再交当前处理器;取消和lostpointercapture仅在手势仍存在时处理。 */
const owned = (input: Event) => (input as globalThis.PointerEvent).pointerId === event.pointerId;
capture.addEventListener('pointermove', input => { if (owned(input)) pointerHandlers.current?.move(input as unknown as PointerEvent<SVGCircleElement>); }, { signal: controller.signal });
capture.addEventListener('pointerup', input => { if (owned(input)) pointerHandlers.current?.stop(input as unknown as PointerEvent<SVGCircleElement>); }, { signal: controller.signal });
for (const name of ['pointercancel', 'lostpointercapture']) capture.addEventListener(name, input => { if (owned(input) && gesture.current) pointerHandlers.current?.cancel(input as unknown as PointerEvent<SVGCircleElement>); }, { signal: controller.signal });
if(!acquirePointerCapture(capture,event.pointerId)){cancel();return;}event.preventDefault();event.stopPropagation();
}
/** 屏幕点转画布点,并克隆当前path后更新选中部分;只接受当前捕获指针的事件。 */
function move(event: PointerEvent<SVGCircleElement>) {
const current = gesture.current;
if (!current) return;
event.stopPropagation();
if(!current)return;event.stopPropagation();
if (!ownsPointerCapture(current,event)) return;
if(!current)return;
const point = flow.screenToFlowPosition({ x: event.clientX, y: event.clientY });
const next = structuredClone(data.path || { type: "bezier-chain" as const, knots: [] });
// 端部控制柄保存相对端点的向量;中间点位置为绝对画布点,中间控制柄保存相对knot.position的向量。
if (current.part === "startControl" || current.part === "endControl") next[current.part] = minus(point, current.part === "startControl" ? from.point : to.point);
else {
const knot = next.knots.find((knot) => knot.id === current.id);
if (!knot) return;
if (current.part === "position") knot.position = point;
else {
// corner两柄独立;smooth保持另一柄长度且方向共线反向;symmetric两柄长度也相同。
const vector = minus(point, knot.position), other = current.part === "in" ? "out" : "in";
const length = Math.hypot(knot[other].x, knot[other].y), nextLength = Math.hypot(vector.x, vector.y);
knot[current.part] = vector;
// Shift把该曲线点模式改为symmetric,这个mode会随本次path修改一起提交。
if (event.shiftKey) knot.mode = "symmetric";
if (knot.mode !== "corner") {
const scale = knot.mode === "symmetric" ? 1 : nextLength ? length / nextLength : 0;
@@ -76,14 +112,32 @@ export function CanvasEdge(props: EdgeProps<CanvasEdgeModel>) {
}
}
}
editing.patch(props.id, { ...data, path: next }, true);
// 优先本次租约的preview;旧版以transient=true派发path修改,结束时另行finish/cancel。
if (current.lease) current.lease.preview(next); else editing.patch(props.id, { ...data, path: next }, true);
}
/** 抬起前再处理一次最后位置,随后清引用、拆监听、提交并释放捕获,避免释放事件重复结束。 */
function stop(event: PointerEvent<SVGCircleElement>) {
event.stopPropagation(); gesture.current = null;
if (event.currentTarget.hasPointerCapture(event.pointerId)) event.currentTarget.releasePointerCapture(event.pointerId);
if(!gesture.current)return;event.stopPropagation();
if (!ownsPointerCapture(gesture.current,event)) return; const current = gesture.current!; move(event); gesture.current = null; current.controller.abort();
if (current.lease) current.lease.commit(); else editing.finish?.();
releasePointerCapture(current.capture,current.pointerId);
}
/** 事件取消需匹配捕获;无参数用于失焦/卸载。先清引用和监听,再取消预览并释放捕获。 */
function cancel(event?:{pointerId:number;type?:string;target?:unknown;stopPropagation?():void}) {
if(event&&gesture.current)event.stopPropagation?.();
if(event&&!ownsPointerCapture(gesture.current,event))return;
const current = gesture.current; gesture.current = null;
if (!current) return;
current.controller.abort();
if (current.lease) current.lease.cancel(); else editing.cancel?.();
releasePointerCapture(current.capture,current.pointerId);
}
// 每次渲染刷新原生事件转发目标;控制点自身也挂React事件,所有处理器均检查手势/捕获归属。
pointerHandlers.current = { move, stop, cancel };
/** 控制点屏幕半径与描边按zoom反向补偿。点击选择点,指针可拖;键盘仅移动曲线点位置或端部柄。
* 方向键一步1单位、Shift为10单位;中间in/out柄的键盘分支直接返回,不修改曲线。 */
const control = (point: { x: number; y: number }, id: string, part: NonNullable<typeof gesture.current>["part"], label: string) => <circle key={part + id} cx={point.x} cy={point.y} r={(part === "position" ? 5 : 3.5) / zoom} className={styles.curveControl} fill={part === "position" ? "#fffefb" : "#a47764"} stroke="#a47764" strokeWidth={1 / zoom} role="button" aria-label={label} tabIndex={0}
onPointerDown={(event) => start(event, id, part)} onPointerMove={move} onPointerUp={stop} onPointerCancel={stop} onLostPointerCapture={() => { gesture.current = null; }} onClick={(event) => { event.stopPropagation(); setSelectedKnot(id); }}
onPointerDown={(event) => start(event, id, part)} onPointerMove={move} onPointerUp={stop} onPointerCancel={cancel} onLostPointerCapture={(event) => { if (gesture.current) cancel(event); }} onClick={(event) => { event.stopPropagation(); setSelectedKnot(id); }}
onKeyDown={(event) => {
event.stopPropagation(); const delta = { ArrowLeft: [-1, 0], ArrowRight: [1, 0], ArrowUp: [0, -1], ArrowDown: [0, 1] }[event.key];
if (delta) { event.preventDefault(); const next = structuredClone(data.path || { type: "bezier-chain" as const, knots: [] }); const vector = { x: delta[0] * (event.shiftKey ? 10 : 1), y: delta[1] * (event.shiftKey ? 10 : 1) };
@@ -93,6 +147,9 @@ export function CanvasEdge(props: EdgeProps<CanvasEdgeModel>) {
editing.patch(props.id, { ...data, path: next });
}
}} />;
// 双击路径按点击位置插点;选中描边和动画不接收指针,BaseEdge提供较宽的交互区域。
// flow用SVG animateMotion沿path运行;dash/pulse使用CSS动画,方向与时长来自连线数据。
// 选中时绘制工具栏、所有knot位置和首尾柄,仅被选中knot显示自己的in/out柄。
return <g onDoubleClick={(event) => { event.preventDefault(); event.stopPropagation(); add(flow.screenToFlowPosition({ x: event.clientX, y: event.clientY })); }}>
<defs><Marker id={markerId + "-head"} value={appearance.head} color={color} /><Marker id={markerId + "-tail"} value={appearance.tail} color={color} /></defs>
{props.selected && <path d={path} fill="none" stroke="#a47764" strokeOpacity={.18} strokeWidth={width + 7 / zoom} pointerEvents="none" />}
@@ -100,6 +157,7 @@ export function CanvasEdge(props: EdgeProps<CanvasEdgeModel>) {
{animated && (animation.effect === "flow" || !animation.effect ? <g pointerEvents="none"><circle r={Math.max(3, width * 1.5)} fill={animation.color || "#fff"}><animateMotion path={path} dur={`${animation.durationMs || 1600}ms`} repeatCount="indefinite" keyPoints={animation.direction === "reverse" ? "1;0" : "0;1"} keyTimes="0;1" calcMode="linear" /></circle></g> : <path d={path} fill="none" pointerEvents="none" stroke={animation.color || "#fff"} strokeWidth={width + 1} strokeDasharray={animation.effect === "dash" ? "6 14" : undefined} className={animation.effect === "dash" ? styles.edgeDashAnimation : styles.edgePulseAnimation} style={{ animationDuration: `${animation.durationMs || 1600}ms`, animationDirection: animation.direction === "reverse" ? "reverse" : "normal" }} />)}
{props.selected && <>
<EdgeToolbarControls id={props.id} x={midpoint.x} y={midpoint.y - 24 / zoom} data={data} change={(data) => editing.patch(props.id, data)} count={knots.length} knotMode={(knots.find((knot) => knot.id === selectedKnot) || knots.at(-1))?.mode} changeKnotMode={(mode) => {
// 工具栏模式应用于选中knot,找不到时退到最后一点;平滑/镜像优先以out柄为方向基准重算in柄。
const next = structuredClone(data.path!); const knot = next.knots.find((knot) => knot.id === selectedKnot) || next.knots.at(-1); if (!knot) return;
knot.mode = mode; if (mode !== "corner") {
if (!Math.hypot(knot.out.x, knot.out.y)) knot.out = { x: -knot.in.x, y: -knot.in.y };
@@ -117,6 +175,8 @@ export function CanvasEdge(props: EdgeProps<CanvasEdgeModel>) {
</>}
</g>;
}
/** 连接拖动中的临时预览线。两端命中Handle时使用兼容连接点,尚未命中时以指针位置作终点并反转起点法向。
* 只绘制单段connectorBezier,不创建或保存实际连线;调用方样式可覆盖默认线色/线宽。 */
export function CanvasConnectionLine(props: ConnectionLineComponentProps<CanvasNode>) {
const from = connector(props.fromNode, props.fromHandle.id, "right");
const to = props.toNode && props.toHandle ? connector(props.toNode, props.toHandle.id, "left") : { point: { x: props.toX, y: props.toY }, normal: { x: -from.normal.x, y: -from.normal.y } };
+14
View File
@@ -1,11 +1,21 @@
/**
* 按名称异步加载画布SVG图标。先读取共享缓存,再订阅图标加载器,展示加载中/失败/已就绪三种图形。
* 组件负责显示和重试入口;网络并发、缓存以及订阅取消的实现位于canvas-icon-loader。
*/
"use client";
import { createElement, useEffect, useState } from "react";
import type { LucideProps } from "lucide-react";
import { loadIcon, peekIcon, type IconPaths } from "@/lib/canvas-icon-loader";
/** 兼容Lucide尺寸与描边属性,name默认Star;width/height未提供时跟随size。
* 成功后按加载器返回的SVG标签与属性创建子元素,避免把整段SVG作为HTML插入。 */
export function CanvasIcon({ name = "Star", size = 24, width = size, height = size, strokeWidth = 2, absoluteStrokeWidth, ...props }: LucideProps & { name?: string }) {
// 结果携带图标名,渲染时只采用当前name对应的内容,换名时不会短暂展示上一个图标。
const [result, setResult] = useState<{ name: string; paths?: IconPaths; error?: boolean }>(() => ({ name, paths: peekIcon(name) }));
// retry仅用于触发同名图标重新订阅;它不是全局缓存版本或网络请求ID。
const [retry, setRetry] = useState(0);
// 换名或点击重试后,先采用可同步取得的缓存,再等待loadIcon。清理时取消本组件订阅;
// AbortError不显示失败,其它错误显示重试占位。底层决定共享请求是否仍有其它使用者。
useEffect(() => {
const controller = new AbortController();
setResult({ name, paths: peekIcon(name) });
@@ -14,8 +24,12 @@ export function CanvasIcon({ name = "Star", size = 24, width = size, height = si
});
return () => controller.abort();
}, [name, retry]);
// effect可能还没处理新name,故读取结果前再次比对名称;失败标记也必须属于当前图标。
const paths = result.name === name ? result.paths : undefined;
const failed = result.name === name && result.error;
// 以24×24为标准坐标;absoluteStrokeWidth在size为数值时按尺寸反向补偿描边宽度。
// 成功时保留调用方onClick;失败时点击改为停止冒泡并重试,避免同时触发外层节点点击。
// 加载中用虚线方框,失败用叉号与title,data-icon-state供样式或检查观察显示状态。
return <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width={width} height={height} fill="none" stroke="currentColor" strokeWidth={absoluteStrokeWidth && typeof size === "number" ? Number(strokeWidth) * 24 / size : strokeWidth} strokeLinecap="round" strokeLinejoin="round"
{...props} data-icon-id={name} data-icon-state={paths ? "loaded" : failed ? "error" : "loading"}
onClick={failed ? (event) => { event.stopPropagation(); setRetry((value) => value + 1); } : props.onClick}>
+11
View File
@@ -1,9 +1,20 @@
/**
* 画布图片显示层:远端图片异步加载时,允许用Provider提供的本地预览覆盖,减轻上传或资源切换时的空白。
* 组件不上传文件、不管理Blob URL寿命;这些职责留给提供sources与loaded回调的上层。
*/
"use client";
import { createContext, useContext } from "react";
/** 按元素id查找预览URL,并在底层图片加载成功时通知上层;默认空Map与空回调支持独立渲染。 */
export const ImagePreviews = createContext<{ sources: Map<string,string>; loaded: (id: string) => void }>({ sources: new Map(), loaded: () => {} });
/** src用于底层正式图片,title作为其替代文本;图片禁止原生拖拽,避免干扰画布手势。
* 底图使用懒加载、异步解码并禁止发送Referer;onLoad只通知上层,不直接删除预览。 */
export function CanvasImage({ id, src, title, className }: { id: string; src?: string; title: string; className: string }) {
// 本地源按元素id而非远端URL索引,同一资源在不同节点中可拥有各自的显示交接状态。
const previews = useContext(ImagePreviews), local = previews.sources.get(id);
const remote = <img loading="lazy" decoding="async" className={className} src={src} alt={title} draggable={false} referrerPolicy="no-referrer" onLoad={() => previews.loaded(id)} />;
// 没有预览或预览已等于正式源时,只绘制一张图片,避免冗余叠层。
if (!local || local === src) return remote;
// 正式图片继续在底层加载,本地预览绝对定位覆盖在上方;Provider清掉该id后自然移除覆盖。
// 覆盖图是装饰性的、忽略指针事件,读屏与节点交互都交给底层正式图片和外层节点。
return <div style={{position:"relative",width:"100%",height:"100%",minHeight:0,flex:1,borderRadius:"inherit"}}>{remote}<img className={className} src={local} alt="" aria-hidden draggable={false} style={{position:"absolute",inset:0,pointerEvents:"none"}} /></div>;
}
+35
View File
@@ -0,0 +1,35 @@
/**
* 将Portal中的节点浮层注册到视图OverlayIndex,提供屏幕边界、焦点及输入法组合态。
* 索引用于输入路由与浮层归属判断;DOM标记与事件监听在ref更换或卸载时清理,不写入文档。
*/
"use client";
import {createContext,useContext,useCallback,useLayoutEffect,useId,useRef} from 'react';
import {useViewport} from '@xyflow/react';
import type {OverlayIndex} from '@/lib/canvas-bindings/overlays';
import type {ProjectEngine} from '@/lib/canvas-runtime/workspace';
/** 新版Provider提供视图索引与engine;null允许旧编辑器或无engine的展示组件直接复用Hook。 */
export const CanvasOverlays=createContext<{index:OverlayIndex;engine:ProjectEngine}|null>(null);
/** Optional for legacy/engine-less renderers; a portal keeps its explicit element target. */
/** 返回DOM ref回调。注册目标包含id/incarnation,避免同ID元素重建后仍沿用旧浮层身份。
* useId区分组件实例,placement区分用途;Portal位置不影响目标归属。 */
export function useCanvasOverlay(targetId:string,placement:string){
const scope=useContext(CanvasOverlays),instance=useId(),refresh=useRef<()=>void>(()=>{}),cleanup=useRef<()=>void>(()=>{});
// 订阅视口变化触发重渲染,后续layout effect再读取浮层屏幕矩形。
useViewport();
const incarnation=scope?.engine.getSnapshot().document.elements.get(targetId)?.incarnation;
// 每次换DOM或作用域先清理旧注册;缺少DOM/Provider/元素生命周期时保持空注册。
const ref=useCallback((element:HTMLElement|null)=>{cleanup.current();refresh.current=()=>{};cleanup.current=()=>{};if(!element||!scope||incarnation===undefined)return;
// 随机owner标记区分同一DOM上的新旧绑定,旧清理不能删除新绑定写入的数据属性。
const marker=crypto.randomUUID();element.dataset.canvasOverlayView=scope.index.viewId;element.dataset.canvasOverlayOwner=marker;element.dataset.canvasOverlayTarget=targetId;element.dataset.canvasOverlayPlacement=placement;
// getBoundingClientRect给出屏幕坐标,四周扩大8px作为输入命中边界;焦点读取当前activeElement。
let composing=false;const value=()=>{const rect=element.getBoundingClientRect();return{placement,target:{id:targetId,incarnation},bounds:{x:rect.x-8,y:rect.y-8,width:rect.width+16,height:rect.height+16},focused:element.contains(document.activeElement),composing};};
// 注册返回lease,后续测量只更新这一条;composition事件同步更新组合态供快捷键路由参考。
const lease=scope.index.register(instance,value()),measure=()=>lease.update(value()),start=()=>{composing=true;measure();},end=()=>{composing=false;measure();};refresh.current=measure;
// focusout后等微任务再测量,让新焦点落定;ResizeObserver与首帧测量补充尺寸/挂载后的变化。
const blur=()=>queueMicrotask(measure),observer=new ResizeObserver(measure);observer.observe(element);element.addEventListener('focusin',measure);element.addEventListener('focusout',blur);element.addEventListener('compositionstart',start);element.addEventListener('compositionend',end);let frame=requestAnimationFrame(measure);
// 清除本绑定拥有的DOM标记、首帧任务、观察器和所有监听,再释放索引租约;不删除目标元素。
cleanup.current=()=>{if(element.dataset.canvasOverlayOwner===marker){delete element.dataset.canvasOverlayView;delete element.dataset.canvasOverlayOwner;delete element.dataset.canvasOverlayTarget;delete element.dataset.canvasOverlayPlacement;}cancelAnimationFrame(frame);observer.disconnect();element.removeEventListener('focusin',measure);element.removeEventListener('focusout',blur);element.removeEventListener('compositionstart',start);element.removeEventListener('compositionend',end);lease.release();};
},[scope,instance,targetId,incarnation,placement]);
// 每次布局提交刷新边界;卸载清理使用ref中的最新函数,兼容期间发生的DOM/目标替换。
useLayoutEffect(()=>{refresh.current();});useLayoutEffect(()=>()=>cleanup.current(),[]);return ref;
}
@@ -0,0 +1,24 @@
/**
* 内置画布类型到React组件的装配表。把bindings层的renderer描述绑定到实际节点/连线组件,生成默认React渲染注册表。
* 这张表保存可复用的显示定义,不保存每个元素的数据、选择或编辑状态。
*/
"use client";
import type { NodeProps } from '@xyflow/react';
import type { CanvasNode } from '@/lib/canvas-elements';
import { builtinRendererDefinitions } from '@/lib/canvas-bindings/renderers';
import { createReactRenderers, type ReactRendererDefinition } from '@/lib/canvas-bindings/react-flow';
import { MaterialNode } from './material-node';
import { PlainTextNode } from './plain-text-node';
import { PlainImageNode } from './plain-image-node';
import { FrameNode } from './frame-node';
import { IconNode } from './icon-node';
import { CanvasEdge } from './canvas-edge';
/** 新版分区复用FrameNode,但开启directAnchors,使它使用该模式的直接连接点。
* 其它NodeProps透传,包装层不再创建一份分区模型。 */
function HeadlessFrameNode(props:NodeProps<CanvasNode>){return <FrameNode {...props} directAnchors />;}
/** rendererId到节点组件的固定映射;这里的键与builtinRendererDefinitions中节点的rendererId配套。 */
const nodes={material:MaterialNode,plainText:PlainTextNode,plainImage:PlainImageNode,frame:HeadlessFrameNode,icon:IconNode};
/** 每项保留原始类型/结构描述,node按rendererId查表,connection绑定CanvasEdge。
* render与preview目前共用同一个组件;createReactRenderers负责生成供ReactFlow使用的注册结果。
* 本表没有自定义类型的通配组件,兼容占位由投影/渲染选择链路处理。 */
export const defaultCanvasRenderers=createReactRenderers(builtinRendererDefinitions.map(definition=>{const render=definition.structure==='node'?nodes[definition.rendererId as keyof typeof nodes]:CanvasEdge;return{...definition,render,preview:render} as ReactRendererDefinition;}));
+18 -4
View File
@@ -1,33 +1,47 @@
/**
* 卡片与纯文本共用的正文编辑交互。展示态用覆盖层接收选择/双击,编辑态让textarea接收原生文本操作。
* 正文直接来自data.text,每次输入交给actions.edit;本组件只保存是否正在编辑,不另存正文副本。
*/
"use client";
import { useContext, useEffect, useRef, useState } from "react";
import { useEffect, useRef, useState } from "react";
import type { CanvasNodeData } from "@/lib/canvas-elements";
import { NodeEditing } from "./transformable-node";
import {useScopedNodeEditing} from "./node-editing";
import { textAppearance } from "./text-appearance";
import styles from "./canvas.module.css";
/** Single clicks select; native double-click timing controls entry into editing. */
/** plain选择纯文本样式、标签和占位文案;非plain按kind区分脚本/笔记占位。
* 对外动作经过渲染目标身份检查,具体文本事务与history分组由Provider实现。 */
export function CanvasTextEditor({ id, data, selected, plain = false }: { id: string; data: CanvasNodeData; selected: boolean; plain?: boolean }) {
const actions = useContext(NodeEditing);
const actions = useScopedNodeEditing(id,data.__headlessTarget);
const [editing, setEditing] = useState(false);
const textarea = useRef<HTMLTextAreaElement>(null);
// 按下位置和移动标记只用于区分拖动与双击;移动超过4px后不因双击事件进入编辑。
const press = useRef({ x: 0, y: 0, moved: false });
// 失去选择退出本地编辑态;此effect不直接调用endEditing,正常输入框blur另走结束回调。
useEffect(() => { if (!selected) setEditing(false); }, [selected]);
// 进入编辑后聚焦到文本末尾,并禁止浏览器自动滚动,以免画布视口跳动。
useEffect(() => {
if (!editing) return;
const element = textarea.current;
element?.focus({ preventScroll: true });
element?.setSelectionRange(element.value.length, element.value.length);
}, [editing]);
/** 通知控制器开始文本编辑会话,再开启本地输入态;真正可写还要求节点保持选中。 */
function begin() { actions.begin(id); setEditing(true); }
// 同时满足editing与selected才允许输入;展示时textarea只读且不进入Tab顺序。
const active = editing && selected;
const label = plain ? "纯文本" : data.title;
// 输入最多100000字符;onChange立即发动作,blur退出并通知endEditing。Escape只结束编辑,不回滚已输入文本。
// 非编辑态覆盖层不接管单击逻辑,让外层画布选择/拖动;双击使用浏览器原生时序并按移动标记过滤。
// 键盘Enter/F2仅在已选中时进入;双击未选中节点会先发select,再开始编辑。
return <div className={`${styles.textEditor} ${active ? "nodrag" : ""}`}>
<textarea ref={textarea} style={textAppearance(data)} className={`${plain ? styles.plainText : styles.cardText} nodrag nowheel`}
aria-label={label} readOnly={!active} tabIndex={active ? 0 : -1}
placeholder={plain ? "写下文字…" : data.kind === "script" ? "写下你的脚本…" : "留住一个想法…"}
value={data.text} maxLength={100000} onChange={(event) => actions.edit(id, event.target.value)}
onBlur={() => setEditing(false)} onKeyDown={(event) => { if (event.key === "Escape") { event.preventDefault(); event.stopPropagation(); setEditing(false); event.currentTarget.blur(); } }} />
onBlur={() => { setEditing(false); actions.endEditing?.(id); }} onKeyDown={(event) => { if (event.key === "Escape") { event.preventDefault(); event.stopPropagation(); setEditing(false); event.currentTarget.blur(); } }} />
{!active && <div className={styles.textSelectLayer} role="button" tabIndex={selected ? 0 : -1} aria-label={`单击选中,双击编辑${label}`}
onPointerDown={(event) => { press.current = { x: event.clientX, y: event.clientY, moved: false }; }}
onPointerMove={(event) => { if (Math.hypot(event.clientX - press.current.x, event.clientY - press.current.y) > 4) press.current.moved = true; }}
+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; }
@@ -1,6 +1,11 @@
/**
* 通用内容节点的四向连接点。只提供稳定方向ID和几何位置,实际连线命令由画布层接收。
*/
"use client";
import { Handle, Position } from "@xyflow/react";
import styles from "./canvas.module.css";
/** 四边均使用source Handle以配合画布连接模式;selected控制能否开始连线以及读屏可见性。
* 不在这里创建端点记录,也不判断跨分区连线的归属。 */
export function ContentHandles({ selected }: { selected: boolean }) {
return <>{([["top", Position.Top], ["right", Position.Right], ["bottom", Position.Bottom], ["left", Position.Left]] as const).map(([id, position]) => <Handle key={id} id={id} type="source" position={position} className={styles.centerHandle} isConnectableStart={selected} aria-hidden={!selected} aria-label={`从 ${id} 连接元素`} />)}</>;
}
+7
View File
@@ -1,10 +1,17 @@
/**
* 旧版画布未同步草稿的选择对话框。通过原生dialog提供模态展示,把恢复、导出或使用服务端版本交给调用方。
* 本组件不读取草稿、不写服务端,也不判断本地内容是否能安全覆盖;canRestore由上层计算。
*/
"use client";
import { useEffect, useRef } from "react";
import { createPortal } from "react-dom";
import styles from "./canvas.module.css";
/** 可恢复时显示恢复按钮,否则提示子图关系变化;导出按钮仅在提供exportDraft回调时出现。 */
export function DraftRecovery({ restore, discard, canRestore = true, exportDraft }: { restore: () => void; discard: () => void; canRestore?: boolean; exportDraft?: () => void }) {
const dialog = useRef<HTMLDialogElement>(null);
// 挂载时showModal启用浏览器模态行为,卸载时close;不额外保存一份open状态。
useEffect(() => { const element = dialog.current; element?.showModal(); return () => element?.close(); }, []);
// 无document不创建Portal;取消事件阻止默认Escape关闭,要求用户通过明确的版本选择按钮处理。
if (typeof document === "undefined") return null;
return createPortal(<dialog ref={dialog} className={styles.recoveryDialog} aria-labelledby="draft-recovery-title" onCancel={(event) => event.preventDefault()}>
<h2 id="draft-recovery-title">发现未同步的本地草稿</h2><p>{canRestore ? "服务端画布已读取。恢复草稿会以本地内容替换当前服务端版本,请选择要继续编辑的版本。" : "画布的子图关系已更新,这份旧草稿不能直接覆盖。可以先导出草稿,再使用服务端版本继续编辑。"}</p>
+10 -1
View File
@@ -1,4 +1,13 @@
/**
* 连线编辑的React动作上下文。让连线组件调用begin/patch/finish/cancel,并读取当前活动集合。
* 真正的命令、事务和history由Provider注入,默认空实现仅支持未接入编辑器的渲染环境。
*/
"use client";
import { createContext } from "react";
import type { EditingLease } from "./editing-lease";
import type { EdgeData } from "@/lib/canvas-v2";
export const EdgeEditing = createContext({ begin: (_id?: string) => {}, patch: (_id: string, _data: EdgeData, _transient = false) => {}, active: new Set<string>() });
/** begin可拒绝、使用旧版无返回值模式,或返回绑定手势的EditingLease;patch的transient区分临时预览。
* finish/cancel可选,active为当前活动连线ID集合;默认Set不代表全项目状态仓库。 */
const defaultEditing = { finish: undefined as undefined | (() => void), cancel: undefined as undefined | (() => void), begin: (_id?: string): boolean | void | EditingLease<NonNullable<EdgeData['path']>> => {}, patch: (_id: string, _data: EdgeData, _transient = false) => {}, active: new Set<string>() };
/** Context的类型保留可选结束钩子,兼容旧画布与新版租约式交互,不在此创建编辑任务队列。 */
export const EdgeEditing = createContext<Omit<typeof defaultEditing, 'finish' | 'cancel'> & Partial<Pick<typeof defaultEditing, 'finish' | 'cancel'>>>(defaultEditing);
+13 -1
View File
@@ -1,15 +1,27 @@
/**
* 连线的受控样式与曲线工具栏。接收当前data及回调,编辑外观、动画、传播开关和曲线点,不直接持有文档状态。
*/
"use client";
import { EdgeToolbar } from "@xyflow/react";
import { Plus, Trash2 } from "lucide-react";
import type { EdgeData, EdgeMarker, MarkerShape } from "@/lib/canvas-v2";
import styles from "./canvas.module.css";
/** x/y是工具栏锚点;count与knotMode来自外层曲线编辑器。add/remove/changeKnotMode委托外层选择具体曲线点。
* EdgeToolbar只负责屏幕定位,外层仅在选中连线时挂载本组件。 */
export function EdgeToolbarControls({ id, x, y, data, change, add, remove, count, knotMode, changeKnotMode }: { id: string; x: number; y: number; data: EdgeData; change: (data: EdgeData) => void; add: () => void; remove: () => void; count: number; knotMode?: string; changeKnotMode: (mode: "corner" | "smooth" | "symmetric") => void }) {
const a = data.appearance || {}, animation = data.animation || {};
/** 合并appearance子对象并保留其它连线数据;箭头/箭尾各自更新时仍需在事件内保留其嵌套字段。 */
const appearance = (patch: NonNullable<EdgeData["appearance"]>) => change({ ...data, appearance: { ...a, ...patch } });
/** 动画属性单独合并,调整一个设置不覆盖已有方向、效果等其它字段。 */
const animate = (patch: NonNullable<EdgeData["animation"]>) => change({ ...data, animation: { ...animation, ...patch } });
/** 箭头与箭尾共用的六种形状;实际SVG绘制由CanvasEdge中的Marker实现。 */
const markers: [MarkerShape, string][] = [["none", "无"], ["open-arrow", "开放箭头"], ["triangle", "三角箭头"], ["circle", "圆形"], ["diamond", "菱形"], ["square", "方形"]];
return <EdgeToolbar edgeId={id} x={x} y={y} isVisible style={{ zIndex: 115, pointerEvents: "auto" }}><div role="toolbar" aria-label="连线编辑" className={`${styles.edgeToolbar} nodrag nopan nowheel`}
// 宽度校验0.5—20,端部尺寸4—32,动画时长250—30000ms;透明度范围由range控件给出。
// head/tail分别合并type/size/filled;传播开关将execution替换为当前enabled对象。
// 无曲线点时禁用模式和删除,达到64点时禁用添加;外层仍负责相应操作的实际合法性。
// 所有指针/键盘冒泡止于工具栏,避免颜色或输入操作同时触发画布编辑。
return <EdgeToolbar alignY="bottom" edgeId={id} x={x} y={y} isVisible style={{ zIndex: 115, pointerEvents: "auto" }}><div role="toolbar" aria-label="连线编辑" className={`${styles.edgeToolbar} nodrag nopan nowheel`}
onPointerDown={(event) => event.stopPropagation()} onClick={(event) => event.stopPropagation()} onDoubleClick={(event) => event.stopPropagation()} onKeyDown={(event) => event.stopPropagation()}>
<label>颜色<input aria-label="连线颜色" type="color" value={a.color || "#aa968b"} onChange={(event) => appearance({ color: event.target.value })} /></label>
<label>粗细<input aria-label="连线粗细" type="number" min={.5} max={20} step={.5} value={a.width ?? 1.5} onChange={(event) => { const value = +event.target.value; if (value >= .5 && value <= 20) appearance({ width: value }); }} /></label>
+18
View File
@@ -0,0 +1,18 @@
/**
* 编辑手势的能力对象:一次交互取得preview/commit/cancel,再由有效性护栏限制其后续调用。
* 这里只校验调用当刻是否仍有效,不维护手势状态机,也不自行保证commit只能调用一次。
*/
/** A captured pointer may only finish the exact interaction that acquired it. */
/** preview产生临时显示,commit结束并提交,cancel撤销本次预览;具体语义由创建租约的编辑器实现。 */
export type EditingLease<T> = { preview(value: T): void; commit(): void; cancel(): void };
/** 冻结包装后的方法表,每次调用重新检查isCurrent;过期事件静默忽略,不能结束另一场交互。 */
export function guardEditingLease<T>(isCurrent: () => boolean, actions: EditingLease<T>): EditingLease<T> {
return Object.freeze({
// 指针移动可能持续产生事件,必须逐次检查,不能只在创建租约时检查一次。
preview(value: T) { if (isCurrent()) actions.preview(value); },
// 提交与取消共用同一有效性判断;原actions负责释放手势与处理重复结束。
commit() { if (isCurrent()) actions.commit(); },
cancel() { if (isCurrent()) actions.cancel(); },
});
}
+7
View File
@@ -1,3 +1,6 @@
/**
* 分区成员拖放的展示上下文。提供名称、成员计数与当前候选归属变化,供节点显示提示;不负责执行成员迁移。
*/
"use client";
import { createContext, useContext } from "react";
@@ -5,14 +8,18 @@ import { Frame } from "lucide-react";
import type { frameDropFeedback } from "@/lib/canvas-frames";
import styles from "./canvas.module.css";
/** 缺少Provider时使用空展示数据;drops是frameDropFeedback产生的当前预览,不能代替持久归属。 */
export const FrameFeedback = createContext({ names: new Map<string, string>(), counts: new Map<string, number>(), drops: [] as ReturnType<typeof frameDropFeedback> });
/** 优先展示拖动候选to,否则使用既有frameId;只有被选中或有拖放变化时才考虑显示徽标。 */
export function MembershipBadge({ id, frameId, selected }: { id: string; frameId?: string; selected: boolean }) {
const { names, drops } = useContext(FrameFeedback);
const drop = drops.find((item) => item.id === id);
// drop.to可能为空,表示准备移出;不能用“drop.to || frameId”退回原分区,否则会隐藏移出意图。
const target = drop ? drop.to : frameId;
const name = target ? names.get(target) : undefined;
if (!drop && !selected) return null;
// 目标有名称时区分“松开加入”与“属于”;没有目标且原来有归属则提示移出,否则不绘制。
const label = name ? `${drop && drop.to !== drop.from ? "松开加入" : "属于"}:${name}` : drop?.from ? `松开移出:${names.get(drop.from) || "分区"}` : null;
return label && <div className={styles.membershipBadge} title={label}><Frame size={11} />{label}</div>;
}
@@ -1,16 +1,25 @@
/**
* 分区名称显示模式的受控单选控件。显示当前值并通过change请求修改,组件本身不拥有分区模型。
*/
"use client";
import { useRef } from "react";
import { EyeOff, Scaling, Type } from "lucide-react";
import type { FrameLabelMode } from "@/lib/canvas-elements";
import styles from "./canvas.module.css";
/** 固定三种模式,数组顺序同时决定视觉滑块位置与键盘导航顺序。 */
const options = [
{ value: "hidden", label: "隐藏名称", hint: "在画布上隐藏分区名称", Icon: EyeOff },
{ value: "fixed", label: "固定字号", hint: "缩放画布时保持名称大小", Icon: Type },
{ value: "scaled", label: "随画布缩放", hint: "名称随画布一起放大缩小", Icon: Scaling },
] as const;
/** 鼠标点击切换模式;方向键循环移动,Home/End跳到首尾,并把焦点移至目标按钮。
* radio角色、aria-checked和单一Tab入口表达单选语义;SVG仅提供视觉提示。 */
export function FrameLabelMenu({ value, change }: { value: FrameLabelMode; change: (mode: FrameLabelMode) => void }) {
// 以数组保存三个真实按钮引用,键盘选择后直接聚焦;滑块索引在未知value时回退0。
const items = useRef<(HTMLButtonElement | null)[]>([]);
const current = Math.max(0, options.findIndex((option) => option.value === value));
// 左/上为-1,右/下为+1,通过加3再取模实现循环;Home/End直接使用0/2。
// 阻止默认滚动并停止冒泡,避免选择模式的按键同时移动画布;滑块每项28px。
return <div className={styles.frameLabelSegments} role="radiogroup" aria-label="分区名称显示模式" onKeyDown={(event) => {
const delta = { ArrowRight: 1, ArrowDown: 1, ArrowLeft: -1, ArrowUp: -1 }[event.key];
if (delta !== undefined || event.key === "Home" || event.key === "End") {
+33 -10
View File
@@ -1,3 +1,7 @@
/**
* 分区节点:展示名称、边框、成员拖入/移出反馈,以及子画布折叠与概览设置。
* 局部状态只存名称输入草稿;保存与分区属性修改都交给作用域编辑接口,成员归属不由本组件直接变更。
*/
"use client";
import { useContext, useEffect, useRef, useState } from "react";
@@ -5,37 +9,54 @@ import { FoldVertical, UnfoldVertical, ExternalLink } from "lucide-react";
import { Handle, NodeToolbar, Position, useViewport, type NodeProps } from "@xyflow/react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import type { CanvasNode } from "@/lib/canvas";
import { NodeEditing, TransformableNode, ResizeToggle } from "./transformable-node";
import { TransformableNode, ResizeToggle } from "./transformable-node";
import { FrameFeedback } from "./frame-feedback";
import { FrameLabelMenu } from "./frame-label-menu";
import { ContentHandles } from "./content-handles";
import {useCanvasOverlay} from "./canvas-overlays";
import {useScopedNodeEditing} from "./node-editing";
import styles from "./canvas.module.css";
export function FrameNode(props: NodeProps<CanvasNode>) {
const { id, data, selected } = props;
const editing = useContext(NodeEditing);
/** directAnchors用于新版分区四向连接点;子画布ports则按输入/输出方向单独布局。
* 分区使用自己的设置浮层,因此隐藏TransformableNode的通用样式栏,仍保留公共变换行为。 */
export function FrameNode(props: NodeProps<CanvasNode> & { directAnchors?: boolean }) {
const { directAnchors = false, ...nodeProps } = props;
const { id, data, selected } = nodeProps;
// 设置浮层注册到当前视图的overlay索引,让Portal中的控件也能正确参与焦点与输入路由。
const overlay=useCanvasOverlay(id,"frame-settings");
const editing = useScopedNodeEditing(id,data.__headlessTarget);
const feedback = useContext(FrameFeedback);
// 反馈来自当前拖动预计算,只统计跨分区的进入/离开;这两个计数不是持久成员列表。
const incoming = feedback.drops.filter((drop) => drop.to === id && drop.from !== id).length;
const outgoing = feedback.drops.filter((drop) => drop.from === id && drop.to !== id).length;
const { zoom } = useViewport();
const reduce = useReducedMotion();
// 名称是受控输入草稿;外部data.title变化时同步覆盖,实际提交发生在blur。
const [title, setTitle] = useState(data.title);
useEffect(() => setTitle(data.title), [data.title]);
// Escape恢复模型标题并标记跳过随后的blur保存;Enter排除输入法组合态后通过blur统一提交。
const cancelBlur = useRef(false);
const mode = data.labelMode || "fixed";
/** 去除首尾空白,空名称回退“未命名分区”;只有与模型不同才派发修改,再归一化输入显示。 */
function saveTitle() {
const name = title.trim() || "未命名分区";
if (name !== data.title) editing.frame(id, { title: name });
setTitle(name);
}
// 选中时显示设置栏,动画尊重减少动态效果偏好;栏内事件停止冒泡,避免触发画布快捷键或拖动。
// 折叠切换前按viewScale还原当前尺寸,存入原状态对应的expandedSize/collapsedSize供后续恢复。
// 概览比例输入限定5%—400%,只更新presentation.contentScale;链接单独打开子画布。
// fixed名称与拖放提示用1/(zoom*viewScale)抵消缩放,scaled跟随画布,hidden不绘制名称。
// 四侧边框提供拖动命中区域;ports按同方向均匀分布,输出在右侧可起连线,输入在左侧可收连线。
return <>
<NodeToolbar style={{ zIndex: 100, pointerEvents: "auto" }} isVisible={true} position={Position.Top} offset={45}>
<AnimatePresence>{selected && <motion.div className={`${styles.styleToolbar} nodrag nopan nowheel`} role="toolbar" aria-label="分区设置"
<AnimatePresence>{selected && <NodeToolbar style={{ zIndex: 100, pointerEvents: "auto" }} isVisible={true} position={Position.Top} offset={45}>
<motion.div ref={overlay} className={`${styles.styleToolbar} nodrag nopan nowheel`} role="toolbar" aria-label="分区设置"
initial={{ opacity: 0, y: reduce ? 0 : 5 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: reduce ? 0 : 5 }} transition={{ duration: reduce ? 0 : 0.14 }}
onPointerDown={(event) => event.stopPropagation()} onClick={(event) => event.stopPropagation()} onDoubleClick={(event) => event.stopPropagation()} onKeyDown={(event) => event.stopPropagation()}>
<input className={styles.frameNameInput} aria-label="分区名称" value={title} maxLength={300} onChange={(event) => setTitle(event.target.value)} onBlur={() => { if (cancelBlur.current) cancelBlur.current = false; else saveTitle(); }}
onKeyDown={(event) => { if (event.key === "Enter" && !event.nativeEvent.isComposing) { event.currentTarget.blur(); } if (event.key === "Escape") { cancelBlur.current = true; setTitle(data.title); event.currentTarget.blur(); } }} />
<FrameLabelMenu value={mode} change={(labelMode) => editing.frame(id, { labelMode })} />
<ResizeToggle id={id} />
<ResizeToggle id={id} renderedTarget={data.__headlessTarget} />
{data.subgraph && <><button type="button" aria-label={data.presentation?.collapsed ? "展开子画布" : "收起子画布"} title={data.presentation?.collapsed ? "展开子画布" : "收起子画布"} onClick={() => {
const collapsed = !data.presentation?.collapsed;
const currentSize = { width: (props.width || 640) / (data.viewScale || 1), height: (props.height || 400) / (data.viewScale || 1) };
@@ -45,14 +66,16 @@ export function FrameNode(props: NodeProps<CanvasNode>) {
<label className={styles.subgraphScale} title="子画布概览比例,不修改内容尺寸"><input aria-label="子画布概览比例" type="number" min={5} max={400} step={5} value={Math.round((data.presentation?.contentScale || 1) * 100)} onChange={(event) => { const scale = +event.target.value / 100; if (scale >= .05 && scale <= 4) editing.frame(id, { presentation: { ...data.presentation, contentScale: scale } }); }} />%</label>
<a href={`/dashboard/${encodeURIComponent(editing.workspace)}/${data.subgraph.boardId}`} aria-label="打开子画布" title="打开子画布"><ExternalLink size={15} /></a></>}
</motion.div>}</AnimatePresence>
</NodeToolbar>
<TransformableNode {...props} hideStyleToolbar className={`${styles.frameNode} ${incoming ? styles.frameDropTarget : ""} ${outgoing ? styles.frameDropExit : ""}`}>
</motion.div>
</NodeToolbar>}</AnimatePresence>
<TransformableNode {...nodeProps} hideStyleToolbar className={`${styles.frameNode} ${incoming ? styles.frameDropTarget : ""} ${outgoing ? styles.frameDropExit : ""}`}>
{data.subgraph && data.presentation?.collapsed && <a className={styles.collapsedSubgraph} href={`/dashboard/${encodeURIComponent(editing.workspace)}/${data.subgraph.boardId}`} onPointerDown={(event) => event.stopPropagation()}><FoldVertical size={22} /><span>{data.title}</span><small>打开子画布</small></a>}
{mode !== "hidden" && <div className={styles.frameLabel} style={{ scale: mode === "fixed" ? 1 / (zoom * (data.viewScale || 1)) : 1 }} title={data.title}>{data.title}</div>}
{(incoming > 0 || outgoing > 0) && <div className={styles.frameDropHint} style={{ scale: 1 / (zoom * (data.viewScale || 1)) }}>{incoming > 0 ? `松开加入 · ${incoming} 项` : `松开移出 · ${outgoing} 项`}</div>}
{(["top", "right", "bottom", "left"] as const).map((side) => <div key={side} className={`${styles.frameBorderHit} ${styles["frameBorder_" + side]}`} title="拖动分区,已有成员一起移动" />)}
{directAnchors && <ContentHandles selected={!!selected} />}
{data.ports?.map((port) => {
// 只在同方向端口之间均分高度,index+1与length+1让首尾都留出边距。
const peers = data.ports!.filter((item) => item.direction === port.direction);
return <Handle key={port.id} id={`port:${port.id}`} type="source" position={port.direction === "input" ? Position.Left : Position.Right} className={styles.framePort} style={{ top: `${(peers.indexOf(port) + 1) / (peers.length + 1) * 100}%` }} isConnectableStart={selected && port.direction === "output"} isConnectableEnd={port.direction === "input"} title={port.direction === "input" ? "子画布输入" : "子画布输出"} />;
})}
+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,79 @@
/**
* 新版元素AI工具面板。配置、生成开始、取消和结果采用均通过engine命令进入既有状态与同步链路。
* 任务事实读取runtime.runs/ledger,局部state只保存输入、模型目录和界面错误;关闭面板不等于取消生成。
*/
"use client";
import { useEffect, useState } from "react";
import { ArrowUp, Sparkles, Square, X } from "lucide-react";
import type { ElementRecord, Json, Result } from "@/lib/canvas-core/types";
import { runContainsTarget } from "@/lib/canvas-runtime/runs";
import type { RunModel } from "@/lib/canvas-runtime/runs";
import type { ProjectEngine } from "@/lib/canvas-runtime/workspace";
import { errorText } from "@/lib/api";
import { managedImageSource } from "@/lib/canvas-v2";
import styles from "./canvas.module.css";
/** 只接受非数组JSON对象作为配置/输出包络,其它值退为空对象,避免直接按对象读取标量。 */
const object = (value: Json | undefined) => value && typeof value === 'object' && !Array.isArray(value) ? value : {};
/** 配置输入从元素当前ai字段初始化;模型输出模态按image/plain-image与其它组件区分。
* 元素数据来自父层投影,本组件直接读取engine根用于显示任务,不再创建额外轮询器。 */
export function HeadlessAIPanel({ engine, element, close, viewId }: { engine: ProjectEngine; element: ElementRecord; close(): void; viewId:string }) {
const saved = object(element.data.ai), root = engine.getSnapshot();
const [models, setModels] = useState<readonly RunModel[]>([]), [loading, setLoading] = useState(true), [error, setError] = useState('');
const [model, setModel] = useState(typeof saved.modelId === 'string' ? saved.modelId : '');
const [prompt, setPrompt] = useState(typeof saved.prompt === 'string' ? saved.prompt : '');
const [propagate, setPropagate] = useState(saved.propagate === true);
const image = ['image','plain-image'].includes(element.componentId);
// 用runContainsTarget核对目标身份,选择相关任务列表的最后一项;不是按时间戳重新排序。
const relevant = [...root.runtime.runs.values()].filter(run => runContainsTarget(run,element));
const run = relevant.at(-1);
// ledger里的run.start可尚未得到服务端run,必须单独显示等待/阻断,不能仅看runs来判定空闲。
const starts = [...root.runtime.ledger.operations.values()].filter(record => record.intent.operationType === 'run.start' && record.intent.run.target.id === element.id && record.intent.run.target.incarnation === element.incarnation);
const pending=starts.filter(record=>record.phase!=='resolved'), rejectedStart=starts.at(-1)?.resolution==='not-applied'?starts.at(-1):undefined;
// 同一run未解决的cancel/apply也占忙碌状态;hasMore/pendingPage表示任务事件尚未完整读取。
const actions=[...root.runtime.ledger.operations.values()].filter(record=>(record.intent.operationType==='run.cancel'||record.intent.operationType==='run.apply')&&record.intent.action.runId===run?.id&&record.phase!=='resolved');
const cancelling=actions.some(record=>record.intent.operationType==='run.cancel'),applying=actions.some(record=>record.intent.operationType==='run.apply');
const busy = pending.length > 0 || actions.length > 0 || !!run && (run.hasMore || !!run.pendingPage || ['queued','running'].includes(run.status));
// 展示当前元素最后一个输出事件;是否可采用还要检查output_id、任务冲突状态及已应用集合。
const outputEvent = run?.outputEvents.filter(event => event.node_id === element.id).at(-1), output = object(outputEvent?.output);
// 按当前ownerCanvasId读取可用模型,保留仍兼容的选择,否则选首个available;目录不可用原因单独提示。
// 切作用域/卸载时abort;所有异步结果先检查取消状态,不把旧目录写回新面板。
useEffect(() => {
const controller = new AbortController();setLoading(true);setError('');
engine.models(element.ownerCanvasId,controller.signal).then(catalog => {
if(controller.signal.aborted)return;
const compatible = catalog.models.filter(model => model.output_modalities.includes(image ? 'image' : 'text'));
setModels(compatible);setModel(current => compatible.some(model => model.id === current) ? current : compatible.find(model => model.available)?.id ?? '');
if(catalog.unavailableReason)setError(errorText(new Error(catalog.unavailableReason)));
}).catch(error => { if(!controller.signal.aborted)setError(errorText(error)); }).finally(() => { if(!controller.signal.aborted)setLoading(false); });
return () => controller.abort();
},[engine,element.ownerCanvasId,image]);
/** 将同步命令的rejected结果显示为错误;返回布尔值供“先保存配置再启动”短路。 */
function check(result: Result) { if(result.status === 'rejected') { setError(errorText(new Error(result.code)));return false; }return true; }
/** 有模型且不忙时提交ai配置;否则不发修改并返回true。该函数只配置,不发起生成。 */
function save() {
if(!model || busy)return true;
return check(engine.editor.dispatch({type:'element.data',target:{id:element.id,incarnation:element.incarnation},patch:{ai:{enabled:true,modelId:model,prompt,propagate,parameters:{}}}},{label:'Configure generation'}));
}
// 用服务端输出身份判断是否已采用,不凭当前正文/图片内容相同推测结果是否应用。
const appliedOutput=typeof output.output_id==='string'&&run?.appliedOutputIds?.includes(output.output_id);
// 状态文案先展示未解决取消/采用,再展示拒绝、已采用、等待受理与任务进度,防止把请求排队说成已执行。
const message = cancelling ? '取消请求已保存,等待确认…' : applying ? '正在应用生成结果…' : rejectedStart ? `生成未受理:${rejectedStart.blockers.join('、')}` : appliedOutput ? '此生成结果已应用' : pending.length ? pending.some(record => record.phase === 'blocked') ? '生成受理需要处理' : '正在等待同步并受理生成…' : run?.blocked ? '生成结果已保留,当前编辑需要协调' : run?.error ? '任务跟踪需要处理,可重试' : run?.hasMore ? '正在同步任务状态…' : run ? ({queued:'已受理,等待生成',running:'正在生成…',succeeded:'已更新当前元素',failed:'生成未完成,原内容已保留',conflict:'内容已改变,生成结果已保留',cancelled:'已取消生成',partially_succeeded:'部分步骤已完成'}[run.status]) : '结果将显示在当前元素';
// 输入blur保存配置,执行按钮再保存一次且成功后startRun;忙碌时锁定配置和启动,关闭按钮仍可用。
// queued/running提供取消请求入口;冲突输出保留展示,只有事件读完且未采用时才允许显式替换。
// 跟踪重试调用engine.retry,不新建run.start;pointer/keydown止于面板,避免触发画布手势或快捷键。
return <section data-canvas-ui-view={viewId} className={`${styles.aiPanel} nodrag nopan nowheel`} aria-label="元素 AI 工具" style={{position:'absolute',right:24,bottom:90,zIndex:110,maxWidth:'calc(100% - 48px)',maxHeight:'70%',overflow:'auto'}} onPointerDown={event=>event.stopPropagation()} onKeyDown={event=>event.stopPropagation()}>
<header><Sparkles size={14}/><strong>{image ? '图像创作' : '文本创作'}</strong><span>{element.data.title as string}</span><button aria-label="关闭 AI 工具" onClick={close}><X size={14}/></button></header>
<textarea aria-label="AI 指令" placeholder="希望如何处理当前内容?" maxLength={16000} disabled={busy} value={prompt} onChange={event=>setPrompt(event.target.value)} onBlur={save}/>
<footer><select aria-label="AI 模型" value={model} disabled={loading || busy} onChange={event=>setModel(event.target.value)}>{!model && <option value="">{loading ? '正在读取模型…' : '暂无可用模型'}</option>}{models.map(model=><option key={model.id} value={model.id} disabled={!model.available}>{model.label || model.display_name || model.id}</option>)}</select>
<label><input type="checkbox" aria-label="继续向下传播" checked={propagate} disabled={busy} onChange={event=>setPropagate(event.target.checked)}/>继续向下传播</label>
<button aria-label="执行 AI" title="在当前元素生成结果" disabled={busy || loading || !prompt.trim() || !models.some(item=>item.id===model && item.available)} onClick={()=>{setError('');if(save())check(engine.startRun({target:{id:element.id,incarnation:element.incarnation},boardId:element.ownerCanvasId,modelId:model,prompt,parameters:{},propagate}));}}><ArrowUp size={18}/></button>
{run && ['queued','running'].includes(run.status) && <button aria-label="取消生成" disabled={cancelling} onClick={()=>check(engine.cancelRun(run.id))}><Square size={15}/></button>}
</footer>
<p role="status">{message}</p>
{(run?.blocked || run?.status==='conflict') && <div className={styles.aiConflict}>{typeof output.text==='string' && <pre>{output.text}</pre>}{typeof output.src==='string' && <img src={managedImageSource(output.src)} alt="保留的生成结果"/>}{run?.status==='conflict'&&!run.blocked&&!run.hasMore&&!appliedOutput&&typeof output.output_id==='string'&&<button disabled={actions.length>0} onClick={()=>check(engine.applyRunOutput(run.id,{id:element.id,incarnation:element.incarnation},output.output_id as string))}>使用此结果替换当前内容</button>}</div>}
{(run?.blocked || run?.error || pending.some(record=>record.phase==='blocked')) && <button onClick={()=>void engine.retry().catch(error=>setError(errorText(error)))}>重试任务跟踪</button>}
{error && <p role="alert">{error}</p>}
</section>;
}
@@ -0,0 +1,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,58 @@
/**
* 新版画布的导入/复制任务显示入口。订阅engine已发布状态,选择文件后启动runtime任务,并展示已加入前缀、等待项与失败原因。
* 面板不保存独立任务事实;暂停/继续/停止由imports实现,停止后已加入内容仍保留,不能把它当成整批回滚。
*/
"use client";
import { useCallback, useRef, useState, useSyncExternalStore } from "react";
import { FolderInput } from "lucide-react";
import type { ProjectEngine } from "@/lib/canvas-runtime/workspace";
import { IconButton } from "@/components/ui/icon-button";
import { errorText } from "@/lib/api";
import styles from "./canvas.module.css";
/** 将记录级错误码转成可读说明,未识别的码使用通用提示;原始错误仍保存在任务记录中。 */
const reason = (code: string) => ({ import_asset_authorization_failed: "资源访问授权失败,此记录未加入", import_asset_capacity: "本组资源过大,无法一次准备", asset_depot_unavailable: "资源仓库暂不可用", import_resource_authorization_required: "资源需要重新授权,此记录尚未加入", invalid_document_import: "导出文档结构不合法", invalid_json_array: "JSON 记录数组格式错误", incomplete_json_array: "JSON 数组尾部不完整", json_nesting_limit: "JSON 嵌套层数过多", invalid_json: "记录格式错误", invalid_json_value: "记录包含不支持的数据", invalid_utf8: "文件编码不是 UTF-8", record_too_large: "单条记录过大", unresolved_dependency: "引用的记录未加入", invalid_native_import_record: "不是画布记录", import_component_unavailable: "元素类型暂不支持", invalid_geometry: "元素尺寸不合法", invalid_endpoint: "连线端点已不存在", duplicate_import_source_key: "记录标识重复", import_source_read_failed: "暂时无法读取源文件,可重试;已加入内容保留", import_source_verification_failed: "源文件校验失败,可重试;已加入内容保留", invalid_import_read_proof: "源文件读取证据不一致,任务已暂停", import_source_unavailable: "导入源文件不可用", import_source_changed: "源文件内容与保存时不一致,任务已暂停", import_source_integrity_unavailable: "旧任务缺少源内容校验,无法安全继续;已加入内容保留", import_source_integrity_missing: "源校验记录缺失,任务已暂停", import_source_integrity_interpreter_unavailable: "源校验版本暂不支持", import_target_missing: "目标分区已不存在", import_target_changed: "目标分区已改变", import_dependency_changed: "引用对象已删除或恢复为新版本", dependency_capacity: "等待引用过多,请调整文件顺序", copy_external_changed: "原图引用已删除、移动或恢复为新版本", invalid_duplicate_source: "复制源数据不可用", invalid_copy_snapshot: "复制快照不可用", invalid_copy_preparation: "复制准备记录不可用", copy_interpreter_changed: "复制规则已变化,需要重新复制", invalid_import_source: "文件过大或格式不支持" } as Record<string,string>)[code] ?? "这条记录暂时无法加入";
/** 只显示目标canvas最近三个任务,倒序展示;任务数量与汇总仅覆盖这个显示窗口,不代表项目全部导入。 */
export function HeadlessImports({ engine, canvasId, viewId }: { engine: ProjectEngine; canvasId: string; viewId:string }) {
// React订阅唯一发布根,不在组件里复制任务状态;同一个getSnapshot也作为服务端快照入口。
const subscribe = useCallback((notify: () => void) => engine.editor.subscribe(notify), [engine]);
const snapshot = useSyncExternalStore(subscribe, engine.getSnapshot, engine.getSnapshot);
const input = useRef<HTMLInputElement>(null), [open, setOpen] = useState(false), [error, setError] = useState("");
// 每次渲染更新engine/canvas身份,用于异步文件探测和控制回调完成后避免更新另一画布的错误显示。
const scope = useRef({ engine, canvasId }); scope.current = { engine, canvasId };
const jobs = [...(snapshot.runtime.imports?.values() ?? [])].filter(job => job.source?.targetCanvasId === canvasId).slice(-3).reverse();
/** 统一承接runtime控制Promise的成功/失败;这里只更新面板错误,不自行重排或重启任务。 */
const control = (work: Promise<void>) => { void work.then(() => { if (scope.current.engine === engine && scope.current.canvasId === canvasId) setError(""); }).catch(error => { if (scope.current.engine === engine && scope.current.canvasId === canvasId) setError(errorText(error)); }); };
// canStart决定文件入口可用性;input每次先清value,允许再次选择同一个文件。
// .json读取前64KiB探测首个非空白字符:对象走文档导入,数组走记录数组;其它后缀走NDJSON。
// 探测前捕获canvas父关系及入口生命周期,await后重新核对,防止向已被替换/移走的目标启动任务。
// integrityVersion=1源校验未完成显示校验中;=2允许内容继续编辑,但提示校验完成后同步。
// 任务按钮按runtime状态显示;失败详情最多列前10项,停止提示明确已加入内容继续同步。
return <aside data-canvas-ui-view={viewId} className={styles.importPanel} aria-label="画布导入">
<div className={styles.importActions}><IconButton icon={FolderInput} label="导入画布记录" disabled={!engine.imports.canStart(canvasId).enabled} onClick={() => input.current?.click()} /><span>NDJSON / JSON 记录数组</span></div>
<input data-canvas-import ref={input} type="file" accept=".ndjson,.jsonl,.json,application/json,application/x-ndjson" hidden onChange={async event => {
const file = event.target.files?.[0]; event.target.value = ""; if (!file) return;
try { if(file.name.toLowerCase().endsWith('.json')){const before=engine.getSnapshot().document,target=before.canvases.get(canvasId),parent=JSON.stringify(target?.parent),entry=target?.parent?before.elements.get(target.parent.entryElementId)?.incarnation:undefined;const prefix=(await file.slice(0,65536).text()).trimStart()[0];if(scope.current.engine!==engine||scope.current.canvasId!==canvasId)return;const current=engine.getSnapshot().document,now=current.canvases.get(canvasId);if(!target||!now||JSON.stringify(now.parent)!==parent||(now.parent?current.elements.get(now.parent.entryElementId)?.incarnation:undefined)!==entry)throw Error('import_target_changed');if(prefix==='{')engine.imports.startDocument(file,canvasId);else if(prefix==='[')engine.imports.startJsonArray(file,canvasId);else throw Error('invalid_import_source');}else engine.imports.startNdjson(file,canvasId); setOpen(true); setError(""); } catch (error) { if(scope.current.engine===engine&&scope.current.canvasId===canvasId)setError(errorText(error)); }
}} />
{jobs.length > 0 && <details open={open} onToggle={event => setOpen(event.currentTarget.open)}><summary>导入与复制任务 · {jobs.some(job => job.status === "preparing") && "正在准备复制 · "}{jobs.reduce((count, job) => count + job.committedSourceKeys.length, 0)} 项已加入</summary>
{jobs.map((job, index) => <section className={styles.importJob} key={job.id} aria-label={`导入任务 ${index + 1}`}>
<strong>{job.source?.name || `导入任务 ${index + 1}`}</strong>
<p role="status">{["running","preparing"].includes(job.status) && job.source?.integrityVersion === 1 && !job.source.sha256 ? "正在校验源文件" : job.status === "preparing" ? "正在准备复制" : job.status === "running" ? "正在加入" : job.status === "paused" ? "已暂停" : job.status === "cancelled" ? "已停止" : job.status === "partial" ? "部分加入" : "已加入画布"} · {job.committedSourceKeys.length} 项{job.pending.length > 0 && `,${job.pending.length} 项等待`}</p>
{!(job.status==='cancelled'&&!job.committedSourceKeys.length)&&job.source?.integrityVersion===2&&!job.source.sha256&&!job.source.integrityError&&<small>内容可继续编辑,源文件校验完成后同步。</small>}
{job.source?.retirement?.purgedAt !== undefined && <small>本地源文件已清理;画布内容和撤销记录保留。</small>}
{job.error && <p>{reason(job.error)}</p>}
<div className={styles.importActions}>
{job.source?.integrityError&&<button onClick={()=>control(engine.retry())}>重试源文件校验</button>}
{["running","preparing"].includes(job.status) && <button onClick={() => control(engine.imports.pause(job.id))}>暂停导入</button>}
{job.status === "paused" && <button onClick={() => control(engine.imports.resume(job.id))}>继续导入</button>}
{["running","preparing","paused"].includes(job.status) && <button onClick={() => control(engine.imports.cancel(job.id))}>停止导入</button>}
</div>
{job.status === "cancelled" && <small>已加入的内容保留并继续同步。</small>}
{job.failures.length > 0 && <details><summary>{job.failures.length} 条未加入记录</summary><ul>{job.failures.slice(0, 10).map((failure, item) => <li key={`${failure.key}:${item}`}>{failure.key.startsWith("byte:") ? `文件位置 ${failure.cursor}` : failure.key}:{reason(failure.code)}</li>)}</ul></details>}
</section>)}
</details>}
{error && <p role="alert">{error}{jobs.length > 0 && <button onClick={() => control(engine.retry())}>重试保存</button>}</p>}
</aside>;
}
@@ -0,0 +1,109 @@
/**
* 新版画布的同步冲突处理界面。取得engine提供的恢复会话,按操作逐项选择保留/放弃,先预览再应用。
* 真正的冻结、回执核对、重放及备份由runtime完成;面板只保存选择与方案展示,不自行覆盖当前文档。
*/
"use client";
import { useEffect, useRef, useState } from 'react';
import type { Change, Document, Id, Json, OperationIntent } from '@/lib/canvas-core/types';
import type { ProjectEngine } from '@/lib/canvas-runtime/workspace';
import type { RecoverySession } from '@/lib/canvas-runtime/engine';
import type { ConflictPlan, RecoveryChoice } from '@/lib/canvas-runtime/conflicts';
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogTitle } from '@/components/ui/dialog';
/** 按恢复错误分类说明需要等待、重新选择或更新解释器;未知错误保留通用重试提示。 */
function message(error:unknown){
const code=(error as {code?:string;message?:string})?.code??(error as Error)?.message;
if(code==='recovery_waiting_for_receipts')return '还有请求正在确认。请关闭此窗口,重试同步后再处理。';
if(code==='recovery_read_behind_receipt')return '服务端读取尚未包含已确认的修改,请稍后重新检查。';
if(code?.endsWith('receipt_interpreter_unavailable')||code==='recovery_receipt_missing')return '暂时无法验证原成功回执,修改和原请求仍已保留。请稍后重试。';
if(code==='recovery_in_progress')return '上一次处理尚未结束。可先恢复编辑,再重新检查。';
if(code==='recovery_choices_incomplete')return '请为每一项修改选择处理方式。';
if(code==='stale_incarnation')return '服务端已删除或替换了这个元素。可以放弃这项本地修改。';
if(code==='operation_context_changed')return '元素所属分区已改变,请重新预览这项修改。';
if(code==='ownership_conflict')return '元素已移入其它分区,当前坐标需要重新确认,暂时无法直接保留。';
if(code==='recovery_identity_collision')return '这个身份已在服务端使用,不能覆盖另一份内容。可以放弃本地创建,或重新创建一个元素。';
if(code==='stale_endpoint')return '连线所指元素已删除或进入新的生命周期,请重新选择端点。';
if(code==='unsupported_presentation_units'||code==='invalid_presentation_units')return '这个元素的展示单位暂时无法转换,原内容已保留,请确认展示格式后重试。';
if(code==='unsupported_coordinate_path')return '这条曲线使用未知坐标格式,当前无法安全转换归属,原路径仍已保留。';
if(code==='history_replay_interpreter_unavailable'||code==='history_transition_interpreter_unavailable'||code==='operation_interpreter_unavailable')return '这份修改或恢复记录来自当前无法解析的版本,原内容和请求已保留。请更新应用后重试。';
if(code==='recovery_history_adoption_evidence_missing')return '这次恢复缺少完整历史步骤,暂时无法安全继续处理;原内容和请求已保留。';
if(code==='recovery_history_traversal_order')return '这些撤销或重做有先后顺序,不能跳过前一项继续处理后一项。请重新选择。';
if(code==='recovery_history_traversal_conflict')return '服务端内容与这项历史记录已有冲突,暂时无法安全调整撤销或重做;原内容和历史均已保留。';
if(code?.startsWith('recovery_history')||code==='recovery_record_replacement_not_supported')return '这批修改含结构或撤销操作,暂时无法自动处理。原内容和历史均已保留。';
if(code==='recovery_action_requires_new_command')return '生成任务需要重新手动发起。可以在这里放弃尚未执行的请求。';
if(code==='forbidden'||code==='unauthorized'||code==='identity_changed')return '当前账号无法读取或修改此画布,请确认权限后重试。';
return '处理未完成,当前修改仍已保留。请重试,或恢复编辑后稍后处理。';
}
/** undefined明确显示未设置,字符串直接显示,其它JSON序列化;超过240字符可展开全文。
* React文本节点负责转义内容,不将导入内容或服务端值作为HTML执行。 */
function PreviewValue({value}:{value:Json|undefined}){
if(value===undefined)return <span>未设置</span>;
const text=typeof value==='string'?value:JSON.stringify(value);
return text.length>240?<details><summary>{text.slice(0,240)}…(展开全文)</summary><pre style={{whiteSpace:'pre-wrap',overflowWrap:'anywhere'}}>{text}</pre></details>:<span>{text}</span>;
}
/** 仅对常见字段提供具体中文名,其它data字段与结构关系使用类别名,不改变字段寻址规则。 */
const fieldLabel=(field:string)=>({'data.title':'标题','data.text':'正文',geometry:'位置与尺寸',path:'连线路径'}[field]??(field.startsWith('data.')?'内容字段':'元素关系'));
/** 创建/删除/替换类变更并排显示服务端当前记录与本地after;字段变更由主列表另行渲染。 */
function StructureReview({change,document}:{change:Exclude<Change,{kind:'field'}>;document:Document}){
const current=change.kind==='element'?document.elements.get(change.id):document.canvases.get(change.id),kind=change.kind==='element'?'元素':'分区';
const title=change.kind==='element'?(change.after?.data.title??change.before?.data.title??change.id):change.id;
return <div style={{fontSize:13,overflowWrap:'anywhere',marginTop:8}}><strong>{change.after===null?'删除':change.before===null?'创建':'调整'}{kind} · {String(title)}</strong>
<div>服务端:{current?<PreviewValue value={current as unknown as Json}/>:<span>不存在</span>}</div><div>本地请求:{change.after?<PreviewValue value={change.after as unknown as Json}/>:<span>删除此{kind}</span>}</div></div>;
}
/** 展示方案内结构影响:记录创建删除,以及owner/端点/路径字段;统计删除数量便于核对关联范围。 */
function PlanStructureReview({plan}:{plan:ConflictPlan}){
const changes=plan.operationIds.flatMap(id=>plan.operations.get(id)!.changes),structural=changes.filter(change=>change.kind!=='field'||['ownerCanvasId','source','target','path'].includes(change.field));
if(!structural.length)return null;
const elements=structural.filter(change=>change.kind==='element'&&change.after===null).length,canvases=structural.filter(change=>change.kind==='canvas'&&change.after===null).length;
return <details><summary>关联结构变更 {structural.length} 项 · 删除 {elements} 个元素、{canvases} 个分区(展开查看)</summary>
{structural.map((change,index)=><div key={index} style={{borderBottom:'1px solid #ddd5ca',fontSize:12,overflowWrap:'anywhere',padding:8}}><strong>{change.kind==='field'?fieldLabel(change.field):change.kind==='element'?'元素':'分区'} · {change.kind==='field'?change.target.id:change.id}</strong><div>修改前:<PreviewValue value={change.before as unknown as Json}/></div><div>修改后:<PreviewValue value={change.after as unknown as Json}/></div></div>)}
</details>;
}
/** owner保存清理时要取消的会话,session用于显示;records按操作序列展示并排除上传任务。
* choices每个operationId一个决定;plan是会话生成的预览,任何选择修改都使旧plan失效。 */
export function HeadlessRecoveryDialog({engine,close,viewId}:{engine:ProjectEngine;close():void;viewId:string}){
const owner=useRef<RecoverySession|undefined>(undefined),[session,setSession]=useState<RecoverySession>(),[records,setRecords]=useState<readonly OperationIntent[]>([]);
// busy防重复动作;frozen在开始应用后锁住选择,保存异常时允许原方案重试,明确拒绝才解除冻结。
const [choices,setChoices]=useState(new Map<Id,RecoveryChoice>()),[plan,setPlan]=useState<ConflictPlan>(),[error,setError]=useState(''),[busy,setBusy]=useState(true),[frozen,setFrozen]=useState(false);
// 微任务中准备会话,组件已清理时跳过;会话晚到则立即cancel,不再认领为当前面板。
useEffect(()=>{
let live=true;
Promise.resolve().then(()=>live?engine.prepareRecovery():undefined).then(async session=>{if(!session)return;if(!live){await session.cancel();return;}owner.current=session;setSession(session);setRecords([...engine.getSnapshot().runtime.ledger.operations.values()].filter(record=>session.pendingOperationIds.includes(record.intent.id)&&record.intent.operationType!=='asset.upload').sort((a,b)=>a.intent.sequence-b.intent.sequence).map(record=>record.intent));}).catch(error=>{if(live)setError(message(error));}).finally(()=>{if(live)setBusy(false);});
// 卸载尝试取消自己取得的会话;失败时engine仍保留持久屏障,后续retry/cancelRecovery继续收尾。
return()=>{live=false;void owner.current?.cancel().catch(()=>{/* Engine retains the failed durable barrier; retry/cancelRecovery can finish it. */});};
},[engine]);
/** 已有session且正在操作时不允许取消;否则等待engine解除恢复屏障,成功才关闭,失败留窗提示。 */
async function cancel(){if(busy&&session)return;setBusy(true);try{await engine.cancelRecovery();close();}catch(error){setError(message(error));}finally{setBusy(false);}}
/** 有plan则resolve逐项方案,无待处理操作则apply采用最新根;拒绝结果展示原因,异常保留冻结方案供重试。 */
async function apply(){if(!session||busy)return;setBusy(true);setError('');setFrozen(true);try{const result=plan?await session.resolve(plan):await session.apply();if(result.status==='rejected'){setFrozen(false);setError(message({code:result.code}));return;}close();}catch(error){setError(message(error));}finally{setBusy(false);}}
// canonical是恢复会话捕获的服务端基准,不直接读working当成服务端值。
const canonical=session?.canonical.document;
// Dialog关闭统一走cancel,session忙碌时抑制Escape;初次准备期间仍可请求恢复编辑。
// 逐项显示服务端、本地与方案值:afterPresent=false明确表示移除字段,不等同undefined值。
// 仅document.change/run.apply提供保留选项,生成开始/取消等动作可放弃但不能在此自动重新执行。
// 选择齐全才允许plan,存在本地记录则必须先有plan才允许应用;方案汇总同时显示undo/redo数量变化。
return <Dialog open onOpenChange={open=>{if(!open)void cancel();}}><DialogContent data-canvas-ui-view={viewId} aria-label="处理同步修改" style={{zIndex:300,maxHeight:'80vh',overflowY:'auto',background:'var(--background, #fffdf9)',color:'var(--foreground, #302d29)'}} onEscapeKeyDown={event=>{if(busy&&session)event.preventDefault();}}>
<DialogTitle>处理同步修改</DialogTitle>
<DialogDescription>检查期间暂停编辑。逐项选择要保留的修改,预览后再应用;原内容和撤销记录会保留备份。</DialogDescription>
{busy&&!session&&<p role="status">正在等待同步并读取最新内容…</p>}
{!!session?.acknowledgedRequestIds.length&&<p>已找回 {session.acknowledgedRequestIds.length} 个成功回执,仅处理后续尚未同步的修改。</p>}
{session&&records.length===0&&<p>没有待处理的本地修改,可以采用服务端最新内容。</p>}
{session&&records.map((intent,index)=><article key={intent.id} style={{borderBottom:'1px solid #ddd5ca',paddingBottom:12}}>
<strong>修改 {index+1}{intent.origin==='undo'?' · 未同步的撤销':intent.origin==='redo'?' · 未同步的重做':''}{intent.operationType==='run.start'?' · 尚未受理的生成':intent.operationType==='run.cancel'?' · 尚未确认的取消':''}</strong>
{intent.changes.map((change,changeIndex)=>{if(change.kind!=='field')return <StructureReview key={changeIndex} change={change} document={canonical!}/>;const element=canonical?.elements.get(change.target.id),key=change.field.startsWith('data.')?change.field.slice(5):undefined,value=key===undefined?(element as unknown as Record<string,Json>|undefined)?.[change.field]:element?.data[key],proposed=plan?.document.elements.get(change.target.id),proposedValue=key===undefined?(proposed as unknown as Record<string,Json>|undefined)?.[change.field]:proposed?.data[key];return <div key={changeIndex} style={{fontSize:13,whiteSpace:'pre-wrap',overflowWrap:'anywhere',marginTop:8}}><span>{fieldLabel(change.field)}</span><div>服务端:{element?<PreviewValue value={value}/>:'元素已不存在'}</div><div>本地:{change.afterPresent===false?'移除此字段':<PreviewValue value={change.after}/>}</div>{plan&&<div>处理后:{proposed?<PreviewValue value={proposedValue}/>:'元素已不存在'}</div>}</div>;})}
<select aria-label={`处理第${index+1}项修改`} value={choices.get(intent.id)??''} disabled={busy||frozen} onChange={event=>{const next=new Map(choices);if(event.target.value)next.set(intent.id,event.target.value as RecoveryChoice);else next.delete(intent.id);setChoices(next);setPlan(undefined);setError('');}}>
<option value="">选择处理方式</option>{['document.change','run.apply'].includes(intent.operationType)&&<option value="keep-local">{intent.origin==='undo'?'保留这次撤销':intent.origin==='redo'?'保留这次重做':'保留这项本地修改'}</option>}<option value="discard-local">{intent.origin==='undo'?'放弃这次撤销':intent.origin==='redo'?'放弃这次重做':'放弃这项本地修改'}</option>
</select>
</article>)}
{plan&&<p role="status">方案已生成:保留 {plan.audit.decisions.filter(item=>item.choice==='keep-local').length} 项,放弃 {plan.audit.decisions.filter(item=>item.choice==='discard-local').length} 项。撤销记录由 {engine.getSnapshot().history.undo.length} 项调整为 {plan.history.undo.length} 项,重做记录由 {engine.getSnapshot().history.redo.length} 项调整为 {plan.history.redo.length} 项。</p>}
{plan&&<PlanStructureReview plan={plan}/>}
{error&&<p role="alert">{error}</p>}
<DialogFooter><button disabled={busy&&!!session} onClick={()=>void cancel()}>{session?'取消并恢复编辑':'恢复编辑'}</button>
{session&&records.length>0&&<button aria-label="预览处理方案" disabled={busy||frozen||choices.size!==records.length} onClick={()=>{try{setPlan(session.plan(choices));setError('');}catch(error){setPlan(undefined);setError(message(error));}}}>预览处理方案</button>}
{session&&<button aria-label="应用处理方案" disabled={busy||records.length>0&&!plan} onClick={()=>void apply()}>{busy?'正在保存…':frozen?'重试保存处理结果':'应用处理方案'}</button>}
</DialogFooter>
</DialogContent></Dialog>;
}
@@ -0,0 +1,84 @@
/**
* 从同工作区已打开的其它项目中发现可复用图片,授权接纳资源后在目标画布创建新图片元素。
* 面板不重新上传原文件;资源接纳与元素创建的事务、持久化和授权由engine.adoptResource负责。
*/
"use client";
import { useEffect, useId, useRef, useState } from "react";
import { ImageIcon } from "lucide-react";
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { useCanvasRuntimeHost } from "@/components/workspace/canvas-runtime-host";
import type { ProjectEngine } from "@/lib/canvas-runtime/workspace";
import type { Id, Point } from "@/lib/canvas-core/types";
import { fault } from "@/lib/canvas-core/values";
import { errorText } from "@/lib/api";
/** 懒加载候选缩略图,失败后以通用图标代替;failed是该预览组件的局部显示状态,不修改资源记录。 */
function Preview({src}:{src:string}){const [failed,setFailed]=useState(false);return failed?<ImageIcon aria-hidden className="h-8 w-8 opacity-50"/>:<img src={src} alt="" loading="lazy" className="h-full w-full object-contain" onError={()=>setFailed(true)}/>;}
/** 列表只保存资源ID和展示标题;插入时重新由engine取得资源元数据,不能信任缩略图即代表可访问。 */
type Candidate = { id: Id; title: string };
/** position是打开面板时捕获的画布坐标,插入图片以此为中心;onAdded让调用方选择新元素。 */
export function HeadlessResourcePicker({engine,workspaceId,canvasId,position,close,onAdded,viewId}:{engine:ProjectEngine;workspaceId:Id;canvasId:Id;position:Point;close():void;onAdded(id:Id):void;viewId:string}) {
const host=useCanvasRuntimeHost(),[items,setItems]=useState<Candidate[]>([]),[loading,setLoading]=useState(true),[busy,setBusy]=useState<Id>(),[error,setError]=useState("");
// controller取消本次面板操作,requesting同步防重复点击,busy用于禁用按钮和展示当前候选进度。
const lifecycle=useRef<AbortController|null>(null),requesting=useRef(false),descriptionId=useId();
/** 关闭前发取消信号;已经提交的事务不会由这个UI回调自动撤销。 */
const dismiss=()=>{lifecycle.current?.abort();close();};
// 初次挂载捕获目标canvas及父入口记录,异步接纳资源后仍须核对入口身份与生命周期。
const [target]=useState(()=>{const document=engine.getSnapshot().document,canvas=document.canvases.get(canvasId),entry=canvas?.parent?document.elements.get(canvas.parent.entryElementId):undefined;return {canvas,entry};});
// 记录当前账户代次与工作区,收集已打开/正在打开的其它项目;不扫描服务端全部画布。
useEffect(()=>{
const controller=new AbortController();lifecycle.current=controller;
const identity=host.identity(),workspace=host.getWorkspaces().get(workspaceId);
void (async()=>{
if(!workspace?.isCurrentIdentity()||workspace.accountId!==identity.accountId)throw fault('identity_changed');
// 单个项目打开失败不阻断其它候选,allSettled保留每项结果;排除本项目避免重复从自身取资源。
const results=await Promise.allSettled([...workspace.getProjects()].filter(([id])=>id!==engine.getSnapshot().document.projectId).map(([,project])=>project));
if(controller.signal.aborted)return;
if(!workspace.isCurrentIdentity()||host.identity().authEpoch!==identity.authEpoch)throw fault('identity_changed');
// 按资源ID去重,排除本项目已有资源;其它项目若有本地未stored资源也跳过,避免复用未可靠保存的文件。
const candidates=new Map<Id,Candidate>(),owned=engine.getSnapshot().runtime.resources;
for(const result of results)if(result.status==='fulfilled'){
const snapshot=result.value.getSnapshot();
for(const element of snapshot.document.elements.values()){
const id=element.data.assetId;
if(element.structure!=='node'||typeof id!=='string'||!id||owned.has(id)||candidates.has(id)||snapshot.runtime.resources.has(id)&&!snapshot.runtime.resources.get(id)!.stored)continue;
// 只从明确声明assetId资源字段的组件提取,不能把任意同名业务字段当图片资源。
if(!result.value.editor.registry.components.get(element.componentId)?.resourceFields?.includes('assetId'))continue;
candidates.set(id,{id,title:typeof element.data.title==='string'&&element.data.title.trim()?element.data.title:'未命名图片'});
}
}
setItems([...candidates.values()]);setLoading(false);
})().catch(error=>{if(!controller.signal.aborted){setLoading(false);setError(errorText(error));}});
return()=>{controller.abort();if(lifecycle.current===controller)lifecycle.current=null;};
},[engine,host,workspaceId]);
/** 一次只接纳一个候选。生成新元素ID,在adoptResource提供的事务内校验目标并创建图片。
* 失败保持面板以便重试;关闭或卸载后不再更新错误/选中状态,取消的实际边界由engine处理。 */
async function insert(item:Candidate){
const signal=lifecycle.current?.signal;if(!signal||signal.aborted||requesting.current)return;
requesting.current=true;
setBusy(item.id);setError("");
try{
const id=crypto.randomUUID(),result=await engine.adoptResource(item.id,(resource,tx)=>{
const document=tx.getDocument(),canvas=document.canvases.get(canvasId),entry=canvas?.parent?document.elements.get(canvas.parent.entryElementId):undefined;
// 父入口ID变化或原入口incarnation变化都拒绝,避免资源授权等待期间目标已经重建。
if(!canvas||canvas.parent?.entryElementId!==target.canvas?.parent?.entryElementId||target.entry&&(entry?.id!==target.entry.id||entry.incarnation!==target.entry.incarnation))throw fault('asset_target_changed');
// 按原始宽高等比装入260×200边界,再以position为中心计算左上角;小图也可能被放大。
const scale=Math.min(260/resource.asset.width,200/resource.asset.height),width=resource.asset.width*scale,height=resource.asset.height*scale;
tx.dispatch({type:'element.create',element:{id,incarnation:1,structure:'node',componentId:'image',dataVersion:1,ownerCanvasId:canvasId,geometry:{x:position.x-width/2,y:position.y-height/2,width,height,rotation:0},data:{title:item.title,text:'',assetId:resource.id,src:resource.src}}});
},{signal});
// engine拒绝与异常统一进入错误展示;仅在面板仍活跃时通知onAdded并关闭。
if(result.status==='rejected')throw fault(result.code,result.targets);
if(!signal.aborted){onAdded(id);close();}
}catch(error){if(!signal.aborted)setError(error instanceof Error&&error.message==='not_found'?'无法读取这张图片,请确认仍有原画布的访问权限。':errorText(error));}
finally{requesting.current=false;if(!signal.aborted)setBusy(undefined);}
}
// Dialog关闭入口都走dismiss;缩略图使用工作区资源预览接口,标题截断仅影响显示。
return <Dialog open onOpenChange={open=>{if(!open)dismiss();}}><DialogContent data-canvas-ui-view={viewId} aria-describedby={descriptionId}>
<DialogHeader><DialogTitle>从其它画布添加图片</DialogTitle><DialogDescription id={descriptionId}>选择同一工作区已打开画布中的图片,无需再次上传。</DialogDescription></DialogHeader>
{loading?<p role="status">正在查找图片…</p>:items.length===0?<p>暂无可复用的图片。先打开有已上传图片的画布,再回到这里。</p>:<div className="grid max-h-80 grid-cols-2 gap-3 overflow-y-auto">{items.map(item=><button key={item.id} type="button" aria-label={`添加图片:${item.title}`} disabled={!!busy} onClick={()=>void insert(item)} className="flex min-w-0 flex-col items-center gap-2 rounded-lg border border-border p-3 text-sm hover:bg-muted disabled:opacity-50">
<span className="flex h-20 w-full items-center justify-center overflow-hidden rounded bg-muted"><Preview src={`/api/foundry/workspaces/${encodeURIComponent(workspaceId)}/assets/${encodeURIComponent(item.id)}?variant=preview`}/></span><span className="w-full truncate">{item.title}</span>{busy===item.id&&<span role="status">正在添加…</span>}
</button>)}</div>}
{error&&<p role="alert">{error}</p>}
</DialogContent></Dialog>;
}
+10
View File
@@ -1,11 +1,21 @@
/**
* 图标目录数据与搜索入口。JSON保存名称、中文标签、关键词及分类等检索元数据;SVG路径另由图标加载器取得。
*/
import records from "./icon-catalog-data.json" with { type: "json" };
/** 分类展示顺序;common/all/other是搜索时有特殊含义的选项,其余按categories成员匹配。 */
export const iconCategories = [
["common", "常用"], ["all", "全部"], ["arrows", "箭头与导航"], ["people", "人物与表情"], ["office", "文件与办公"], ["media", "媒体与设计"], ["tech", "科技与设备"], ["business", "图表与商业"], ["nature", "自然与天气"], ["travel", "出行与地点"], ["shapes", "形状与符号"], ["other", "其他"],
] as const;
/** 导出静态记录数组供选择器统计与筛选,不在运行时下载整套图标路径。 */
export const iconCatalog = records;
/** 按图标name建Map供调用方直接定位记录;与目录共享记录对象,不复制或冻结内容。 */
export const iconsById = new Map(iconCatalog.map((icon) => [icon.name, icon]));
/** 来源过滤支持全部、Lucide和Tabler;分类与来源是独立条件。 */
export type IconSource = "all" | "lucide" | "tabler";
/** 去掉首尾空白、连字符、下划线及空白并转小写,再在名称+标签+关键词中做子串匹配。
* 筛选同时满足来源和分类;common看common标记,other指没有分类,all跳过对应限制。
* 保留目录原顺序,不计算相关性分数,也不限制结果数量;虚拟列表由调用方实现。 */
export function searchIcons(query: string, category = "all", source: IconSource = "all") {
const needle = query.trim().toLowerCase().replace(/[-_\s]/g, "");
return iconCatalog.filter((icon) => (source === "all" || icon.source === source)
+5
View File
@@ -1,9 +1,14 @@
/**
* 图标节点的轻量显示装配。图标名称与颜色来自节点data,交互与几何变换委托公共节点容器。
*/
"use client";
import type { NodeProps } from "@xyflow/react";
import type { CanvasNode } from "@/lib/canvas";
import { TransformableNode } from "./transformable-node";
import { CanvasIcon } from "./canvas-icon";
import styles from "./canvas.module.css";
/** 前景色默认棕色、背景默认透明;SVG填满容器并用节点title命名,实际路径由CanvasIcon异步加载。
* 此处的100%宽高跟随外层尺寸;固定strokeWidth传给图标层,不在本组件修改节点几何。 */
export function IconNode(props: NodeProps<CanvasNode>) {
return <TransformableNode {...props}><div className={styles.iconContent} style={{ color: props.data.textStyle?.color || "#78523f", background: props.data.textStyle?.background || "transparent" }}><CanvasIcon name={props.data.icon} aria-label={props.data.title} width="100%" height="100%" strokeWidth={1.5} /></div></TransformableNode>;
}
+16
View File
@@ -1,3 +1,7 @@
/**
* 图标选择面板。按来源、分类与文本查询筛选目录,使用固定五列虚拟列表只挂载视窗附近的图标按钮。
* 仅保存搜索/滚动/焦点状态;choose把名称和标签交给画布创建节点,实际SVG按需加载。
*/
"use client";
import { useEffect, useMemo, useRef, useState } from "react";
import { motion, useReducedMotion } from "motion/react";
@@ -7,7 +11,9 @@ import { CanvasIcon } from "./canvas-icon";
import { iconCatalog, iconCategories, searchIcons, type IconSource } from "./icon-catalog";
import styles from "./canvas.module.css";
/** 固定网格参数:每行五项、每行48px;占位总高与绝对定位必须使用同一组参数。 */
const columns = 5, rowHeight = 48;
/** 通过Portal挂载到body,调用方在浏览器中打开;Escape或关闭按钮只触发close。 */
export function IconPicker({ close, choose }: { close: () => void; choose: (name: string, label: string) => void }) {
const [query, setQuery] = useState("");
const [category, setCategory] = useState("common");
@@ -18,7 +24,9 @@ export function IconPicker({ close, choose }: { close: () => void; choose: (name
const search = useRef<HTMLInputElement>(null);
const grid = useRef<HTMLDivElement>(null);
const reduce = useReducedMotion();
// 有非空查询时搜索当前来源的全部分类;无查询才使用分类按钮值,useMemo避免无关渲染重复过滤。
const matches = useMemo(() => searchIcons(query, query.trim() ? "all" : category, source), [query, category, source]);
// 挂载时聚焦搜索框并观察列表实际高度;卸载断开观察器,尝试把焦点还给打开前的元素。
useEffect(() => {
const previous = document.activeElement as HTMLElement | null;
search.current?.focus();
@@ -26,11 +34,15 @@ export function IconPicker({ close, choose }: { close: () => void; choose: (name
if (grid.current) observer.observe(grid.current);
return () => { observer.disconnect(); previous?.focus(); };
}, []);
// 任一筛选条件变化都回到列表顶部与首项焦点索引,避免沿用上次结果中的越界位置。
useEffect(() => { if (grid.current) grid.current.scrollTop = 0; setScrollTop(0); setFocused(0); }, [query, category, source]);
// 根据总行数撑起滚动区域,上下各多挂载一行缓冲;slice只取可见范围的目录项。
const rows = Math.ceil(matches.length / columns);
const firstRow = Math.max(0, Math.floor(scrollTop / rowHeight) - 1);
const lastRow = Math.min(rows, Math.ceil((scrollTop + height) / rowHeight) + 1);
const visible = matches.slice(firstRow * columns, lastRow * columns);
/** 先钳制目标索引,再按行把目标滚入视口;等下一帧虚拟按钮挂载后调用focus。
* 空结果没有可聚焦按钮,最终可选链自然不执行focus;不凭空创建占位项目。 */
function focusIcon(index: number) {
const next = Math.max(0, Math.min(matches.length - 1, index));
setFocused(next);
@@ -42,6 +54,9 @@ export function IconPicker({ close, choose }: { close: () => void; choose: (name
}
requestAnimationFrame(() => grid.current?.querySelector<HTMLButtonElement>(`[data-index="${next}"]`)?.focus());
}
// 分类点击同时清空搜索,来源选择保留查询;网格按钮按全局结果索引绝对定位,不按切片局部索引。
// 左右移动一项、上下移动一行,Home/End跳至结果首尾;只有当前focused项进入Tab顺序。
// 每个按钮由CanvasIcon加载路径,页脚结果数来自筛选数组;choose后是否关闭由调用方决定。
return createPortal(<motion.aside initial={{ opacity: 0, x: reduce ? 0 : -12 }} animate={{ opacity: 1, x: 0 }} transition={{ duration: reduce ? 0 : 0.16 }} className={styles.iconPicker} role="dialog" aria-label="添加图标" onKeyDown={(event) => { event.stopPropagation(); if (event.key === "Escape") close(); }}>
<header><strong>图标</strong><button aria-label="关闭图标面板" onClick={close}><X size={16} /></button></header>
<label className={styles.iconSource}>图标库<select aria-label="图标库" value={source} onChange={(event) => setSource(event.target.value as IconSource)}><option value="all">全部图标库</option><option value="lucide">Lucide · 2313</option><option value="tabler">Tabler · 24 个试用</option></select></label>
@@ -49,6 +64,7 @@ export function IconPicker({ close, choose }: { close: () => void; choose: (name
<div className={styles.iconCategories} aria-label="图标分类">{iconCategories.map(([id, label]) => <button key={id} aria-pressed={!query.trim() && category === id} onClick={() => { setCategory(id); setQuery(""); }}>{label}</button>)}</div>
<div ref={grid} className={styles.virtualIconGrid} aria-label="图标列表" onScroll={(event) => setScrollTop(event.currentTarget.scrollTop)}>
<div style={{ height: rows * rowHeight, position: "relative" }}>{visible.map(({ name, label, source }, offset) => {
// 加回首行偏移取得完整结果索引,保证滚动后的定位、Tab状态与键盘导航仍对应同一条记录。
const index = firstRow * columns + offset;
return <button key={name} type="button" data-index={index} tabIndex={focused === index ? 0 : -1} title={`${label} · ${source === "tabler" ? "Tabler" : "Lucide"}`} aria-label={`添加${label}图标`} style={{ position: "absolute", width: "20%", height: rowHeight, left: `${index % columns * 20}%`, top: Math.floor(index / columns) * rowHeight }} onFocus={() => setFocused(index)} onClick={() => choose(name, label)}
onKeyDown={(event) => { const delta = { ArrowRight: 1, ArrowLeft: -1, ArrowDown: columns, ArrowUp: -columns, Home: -index, End: matches.length - 1 - index }[event.key]; if (delta !== undefined) { event.preventDefault(); focusIcon(index + delta); } }}><CanvasIcon name={name} size={24} /></button>;
@@ -0,0 +1,136 @@
/**
* 旧画布迁往canonical模型的界面协调层。持有本页旧编辑租约,迁移前停止新编辑并排空旧writer,结果确认后重开新版。
* “升级”指数据与协议迁移;本地记录和浏览器锁用于跨页协调,服务端版本校验与幂等仍由迁移服务负责。
*/
"use client";
import {useEffect,useRef,useState} from 'react';
import type {Registry} from '@/lib/canvas-core/types';
import {claimLegacyCanvasEditing,createBrowserCanvasMigration,type LegacyMigrationContext} from '@/lib/browser-canvas-migration';
import {canvasMigrationStorageKey,migrationBlocksCanvas,readCanvasMigration,type CanvasMigrationJob} from '@/lib/canvas-migration';
import {errorText} from '@/lib/api';
import styles from './canvas.module.css';
/** 将迁移错误码翻成可操作提示,尤其区分确定拒绝与结果未知;未知结果不能当成没写入而直接开放旧编辑。 */
function message(error:unknown){
const code=error instanceof Error?error.message:String(error);
return ({migration_busy:'其他旧画布页面仍在编辑或升级,请先完成那里的保存和上传,再关闭页面重试。',migration_lock_unavailable:'此浏览器不支持安全升级,请使用支持 Web Locks 的浏览器。',migration_response_unknown:'升级结果尚未确认,编辑已暂停。可继续查询原请求。',invalid_migration_receipt:'升级回执无法确认,已保留原请求并暂停编辑。请继续查询或联系支持。',migration_pending_uploads:'请先完成或取消图片上传,再升级。',draft_recovery_required:'请先处理本地草稿恢复,再升级。',canvas_busy:'请先完成当前操作与同步,再升级。',migration_waiting_for_runs:'请等待生成任务结束,再重新准备升级。',migration_version_conflict:'画布已发生变化,请重新准备升级。',workspace_migration_pending:'本工作区还有另一画布的升级待确认,请先处理原画布。',invalid_migration_record:'无法读取本地升级进度,已保留记录并暂停编辑。',migration_interpreter_unavailable:'本地升级进度需要其他版本解释,已保留记录并暂停编辑。',migration_not_durable:'无法可靠保存升级进度,请保留页面并重试。'} as Record<string,string>)[code]??errorText(error instanceof Error?error:new Error(code));
}
/** hold控制旧编辑入口;prepare排空指定根的保存工作,check检查当前是否允许迁移。
* canLeave保护重开页面前的未同步内容,exportDraft提供本页备份,context提供账户与迁移根身份。 */
type Props={context:LegacyMigrationContext;workspaceId:string;canvasId:string;registry:Registry;held:boolean;hold(value:boolean):void;prepare(rootId:string):Promise<void>;check():void;canLeave():boolean;exportDraft():void};
/** 只保存迁移展示状态,实际请求/本地记录由createBrowserCanvasMigration管理。
* attempt是同步防重入标记,running只用于渲染,finished表示本页已进入成功或外部变更后的交接阶段。 */
export function LegacyCanvasMigration(props:Props){
// 回调经ref读取最新props,避免每次父组件渲染都重新申请共享旧编辑租约。
const port=useRef(props);port.current=props;
const [job,setJob]=useState<CanvasMigrationJob>(),[error,setError]=useState(''),[checking,setChecking]=useState(true),[running,setRunning]=useState(false),[changedElsewhere,setChangedElsewhere]=useState(false);
// lease是本页共享旧编辑锁;life取消本次挂载工作;previous用来识别其它页面删除待确认记录的变化。
const lease=useRef<{release():Promise<void>}|undefined>(undefined),life=useRef<AbortController|undefined>(undefined),attempt=useRef(false),finished=useRef(false),previous=useRef<CanvasMigrationJob|undefined>(undefined);
// localStorage迁移记录按账户+工作区隔离,canvasId用于判断该任务是否阻断当前画布。
const scope={accountId:props.context.accountId,workspaceId:props.workspaceId},key=canvasMigrationStorageKey(scope);
const refresh=useRef<()=>Promise<void>>(async()=>{});
// 挂载或作用域变化时检查记录并申请旧编辑租约;身份失效或卸载后不再采用异步结果。
useEffect(()=>{
const controller=new AbortController();life.current=controller;
/** 同时检查本页取消与账户身份,不能只因旧请求返回就更新当前页面或解锁编辑。 */
const current=()=>!controller.signal.aborted&&port.current.context.isCurrentIdentity();
/** 读取并校验本作用域持久迁移记录;格式/解释版本错误也要走保留记录和暂停路径。 */
const read=()=>readCanvasMigration(localStorage,scope);
/** 迁移尝试进行中不抢租约;已有pending或finished就保持hold,正常情形取得共享锁后开放旧编辑。 */
async function inspect(){
if(attempt.current||!current())return;
setChecking(true);
try{
const next=read();previous.current=next;setJob(next);
if(migrationBlocksCanvas(next,props.canvasId)||finished.current){
port.current.hold(true);const held=lease.current;lease.current=undefined;await held?.release();setError('');return;
}
// 已持有共享锁时复用;申请新锁前先暂停编辑,避免没有锁的空档接收修改。
if(lease.current){port.current.hold(false);setError('');return;}
port.current.hold(true);
const acquired=await claimLegacyCanvasEditing({scope,canvasId:props.canvasId,signal:controller.signal,isCurrentIdentity:current});
// 等锁期间页面可能卸载或换账户,拿到过期锁立即释放,不再发布可编辑状态。
if(!current()){await acquired.release();return;}
lease.current=acquired;port.current.hold(false);setError('');
}catch(error){
if(!current())return;
port.current.hold(true);const held=lease.current;lease.current=undefined;await held?.release();
// Without Web Locks this browser cannot upgrade, but existing legacy
// editing remains supported unless there is an unresolved identity job.
// 不支持Web Locks仅禁止安全迁移;没有阻断记录时仍允许旧编辑,不能把这类环境错误当数据已迁移。
if(error instanceof Error&&error.message==='migration_lock_unavailable'){
try{if(!migrationBlocksCanvas(read(),props.canvasId))port.current.hold(false);}catch{}
}
setError(message(error));
}finally{if(current())setChecking(false);}
}
refresh.current=inspect;void inspect();
/** 其它页改变同一记录时重新检查;本页迁移中忽略事件,由当前尝试负责收尾。
* 待确认记录消失只说明外部变化,本页不能丢掉内存内容,先冻结并提示导出/重新打开。 */
const storage=(event:StorageEvent)=>{
if(event.key!==key&&event.key!==null||!current()||attempt.current)return;
// Never discard another view's in-memory content because a record vanished.
if(previous.current?.phase==='pending'&&event.newValue===null){
finished.current=true;port.current.hold(true);setChangedElsewhere(true);setError('升级进度已在其他页面改变。本页内容保留,可导出后重新打开。');
const held=lease.current;lease.current=undefined;void held?.release();return;
}
void inspect();
};
window.addEventListener('storage',storage);
// 卸载取消检查、拆storage监听并释放本页共享锁,不删除尚待确认的迁移记录。
return()=>{controller.abort();window.removeEventListener('storage',storage);const held=lease.current;lease.current=undefined;void held?.release();};
// The port ref follows renders without re-acquiring a shared editing lease.
// eslint-disable-next-line react-hooks/exhaustive-deps
},[key,props.canvasId]);
/** 新尝试先check并hold,在共享锁仍持有时prepare;释放共享锁后由迁移器申请独占锁。
* pending复用原记录resume,其它情况按rootId重新准备,不能把每次点击都变成新的迁移请求。 */
async function upgrade(){
const controller=life.current;if(!controller||controller.signal.aborted||attempt.current)return;
attempt.current=true;setRunning(true);setError('');
const current=()=>!controller.signal.aborted&&port.current.context.isCurrentIdentity();
let failure='';
try{
const pending=readCanvasMigration(localStorage,scope);
// 工作区已有另一根pending时禁止挤占;同一根结果未知则继续原请求查询,不重复做初始准备。
if(pending?.phase==='pending'&&!migrationBlocksCanvas(pending,props.canvasId))throw new Error('workspace_migration_pending');
if(pending?.phase!=='pending')port.current.check();
port.current.hold(true);
// Drain while still holding our shared lease, so losing the subsequent
// exclusive-lock race never abandons a dirty old writer between locks.
if(pending?.phase!=='pending'&&props.context.rootId){await port.current.prepare(props.context.rootId);if(!current())return;}
const held=lease.current;lease.current=undefined;await held?.release();if(!current())return;
const migration=createBrowserCanvasMigration({scope,registry:props.registry,signal:controller.signal,isCurrentIdentity:current});
// start独占阶段再次check/prepare/check,覆盖共享锁释放到独占锁取得之间可能出现的旧写入。
const result=pending?.phase==='pending'?await migration.resume():props.context.rootId?await migration.start(props.context.rootId,async()=>{port.current.check();await port.current.prepare(props.context.rootId!);port.current.check();}):undefined;
if(!current())return;
// 只有确认applied或已迁移才通知外层重开;拒绝/未知保留记录,交finally重新决定是否能恢复旧编辑。
if(result?.status==='applied'||result?.status==='already-migrated'){
finished.current=true;port.current.context.onApplied();return;
}
if(result&&'error'in result){failure=message(result.error);setError(failure);}
const next=migration.read();previous.current=next;setJob(next);
}catch(error){if(current()){failure=message(error);setError(failure);}}
// 不论尝试结果都清重入标记;仅当前身份有效才更新UI,并从持久记录重新判断是否仍需hold。
finally{
attempt.current=false;
if(current()){
setRunning(false);
try{const next=readCanvasMigration(localStorage,scope);previous.current=next;setJob(next);if(!finished.current&&!migrationBlocksCanvas(next,props.canvasId))await refresh.current();}catch(error){port.current.hold(true);failure=message(error);}
if(failure)setError(failure);
}
}
}
// pending表示本页在迁移范围内;other表示工作区另一画布待确认,按钮禁用但不篡改其任务。
const pending=migrationBlocksCanvas(job,props.canvasId),other=job?.phase==='pending'&&!pending;
if(!props.context.rootId&&!pending&&!error&&!props.held)return null;
// 入口依次呈现检查中/执行中/外部变更重开/开始或继续迁移;旧撤销记录不迁移,提示写在按钮title。
// 本页被hold时提供备份;外部变化后的重开仍须check及canLeave,不能直接丢弃尚未同步的内存内容。
return <div className={styles.migrationPanel} aria-label="画布升级">
{checking?<span role="status">正在检查画布编辑权限…</span>:<>
{running?<span role="status">正在保存并升级画布…</span>:changedElsewhere?<button onClick={()=>{try{port.current.check();if(!port.current.canLeave())throw new Error('本页有未同步内容,请先导出备份并手动重新打开画布。');port.current.context.onApplied();}catch(error){setError(message(error));}}}>重新打开画布</button>:props.context.rootId||pending?<button disabled={other} title="保存内容并使用新版编辑器;旧撤销记录不会带入" onClick={()=>void upgrade()}>{pending?'继续查询升级':job?.phase==='rejected'?'重新准备升级':'升级画布'}</button>:props.held?<button onClick={()=>void refresh.current()}>重新检查</button>:null}
{pending&&!running&&<span role="status">结果待确认,旧编辑已暂停。</span>}
{error&&<span role="alert">{error}</span>}
{props.held&&!running&&<button onClick={props.exportDraft}>导出本页备份</button>}
</>}
</div>;
}
@@ -0,0 +1,57 @@
/**
* 模型读取前缀的只读预览。直接把已校验document投影给ReactFlow,允许浏览视口,不创建可写engine或临时history/ledger。
* 这个组件不负责持久化前缀;后续可编辑partial engine的建立与交接由路由及runtime处理。
*/
"use client";
import {useEffect,useMemo,useRef} from 'react';
import {Background,BackgroundVariant,ReactFlow,useReactFlow,type Viewport,type NodeTypes, type NodeProps,Handle,Position} from '@xyflow/react';
import type {Registry} from '@/lib/canvas-core/types';
import type {ModelPreview} from '@/lib/canvas-runtime/model-preview';
import type {ReactRenderers} from '@/lib/canvas-bindings/react-flow';
import {createLoadingRendererCatalog} from '@/lib/canvas-bindings/renderers';
import {projectCanvas} from '@/lib/canvas-bindings/projection';
import {CanvasRendererCommandsContext,type CanvasRendererCommands} from '@/lib/canvas-bindings/react-flow';
import {UnsupportedNode,UnsupportedEdge} from './unsupported-elements';
import styles from './canvas.module.css';
/** 已知类型缺少专用preview renderer时展示准备占位;保留已投影连线需要的Handle,禁止新连接。 */
function PreparingNode({data}:NodeProps){return <div style={{width:'100%',height:'100%',padding:14,border:'1px solid #ded7ce',borderRadius:8,background:'#fffdfa'}} aria-label="正在准备元素"><span>{typeof data.title==='string'?data.title:'正在准备元素…'}</span>{(data.__previewHandles as {id?:string;type:'source'|'target'}[]??[]).map((handle,index)=><Handle key={index} type={handle.type} id={handle.id} position={handle.type==='source'?Position.Right:Position.Left} isConnectable={false}/>)}</div>;}
/** 所有预览命令统一拒绝为model_loading,避免复用的renderer意外修改数据。 */
const reject=()=>({status:'rejected' as const,code:'model_loading',targets:[]});
/** 冻结命令接口仅提供拒绝结果;inert与ReactFlow只读设置之外,命令层再提供一道限制。 */
const commands:CanvasRendererCommands=Object.freeze({dispatch:reject,transaction:reject});
/** 首次存在节点且视口初始化后fitView一次,限制最大缩放为1,并把结果交给外层用于视图交接。 */
function InitialPreviewViewport({onViewport,nodeCount}:{onViewport(viewport:Viewport):void;nodeCount:number}){
// fitted是本组件挂载期标记;清理只禁止迟到通知,不取消已经发起的fitView,也不重置fitted。
const flow=useReactFlow(),fitted=useRef(false),notify=useRef(onViewport);notify.current=onViewport;
useEffect(()=>{if(!flow.viewportInitialized||fitted.current||!nodeCount)return;fitted.current=true;let active=true;void flow.fitView({padding:.2,maxZoom:1}).then(()=>{if(active)notify.current(flow.getViewport());});return()=>{active=false;};},[flow,nodeCount]);
return null;
}
/** Render the validated read prefix directly. No temporary editor, ACK, ledger or
* writable engine is constructed, and no partial model enters durable storage. */
/** renderer catalog限制加载期可用渲染路径;无法解释引用关系时沿用opaque占位,不能猜测缺失内容。 */
export default function LoadingGraphCanvas({preview,canvasId,registry,renderers,workspaceId,onViewport}: {preview:ModelPreview;canvasId:string;workspaceId:string;registry:Registry;renderers:ReactRenderers;onViewport(viewport:Viewport):void}){
// Without a declared reference schema, use the existing opaque placeholder
// until the complete document is available to the installed renderer.
const catalog=useMemo(()=>createLoadingRendererCatalog(registry,renderers.catalog),[registry,renderers]);
// 对已收到文档做只读投影;从现有边补齐各节点Handle描述,不推断尚未读取到的连线。
const projection=useMemo(()=>{
const value=projectCanvas(preview.document,canvasId,registry,new Set(),()=>undefined,undefined,catalog);
const handles=new Map<string,{id?:string;type:'source'|'target'}[]>();for(const edge of value.edges)for(const type of ['source','target'] as const){const id=edge[type],rows=handles.get(id)??[];rows.push({type,id:edge[type==='source'?'sourceHandle':'targetHandle']??undefined});handles.set(id,rows);}
// 节点禁用拖动/选择/焦点/连接及指针,边禁用选择/焦点;已知组件声明assetId资源字段才设置预览URL。
return{nodes:value.nodes.map(node=>({...node,draggable:false,selectable:false,focusable:false,connectable:false,style:{...node.style,pointerEvents:'none' as const},data:{...node.data,__previewHandles:handles.get(node.id)??[],...(registry.resolve(preview.document.elements.get(node.id)!)==='known'&&typeof node.data.assetId==='string'&&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]);
// 普通rendererId优先使用专用preview,否则节点PreparingNode、边UnsupportedEdge;节点再套inert隔离内部控件。
const nodeTypes=useMemo<NodeTypes>(()=>{const result:NodeTypes={};for(const [id,Renderer]of Object.entries({...Object.fromEntries(Object.keys(renderers.nodeTypes).map(id=>[id,renderers.previewNodeTypes[id]??PreparingNode])),unsupported:UnsupportedNode}))result[id]=props=><div inert style={{width:'100%',height:'100%'}}><Renderer {...props}/></div>;return result;},[renderers]),edgeTypes=useMemo(()=>({...Object.fromEntries(Object.keys(renderers.edgeTypes).map(id=>[id,renderers.previewEdgeTypes[id]??UnsupportedEdge])),unsupported:UnsupportedEdge}),[renderers]);
// 仍允许拖动或滚动平移,关闭滚轮缩放和Delete删除;onMoveEnd记录视口,进度只显示读取数量。
return <div className={styles.editor} data-canvas-loading-preview aria-label="正在加载的画布,只读预览">
<CanvasRendererCommandsContext.Provider value={commands}>
<ReactFlow nodes={projection.nodes} edges={projection.edges} nodeTypes={nodeTypes} edgeTypes={edgeTypes} nodesDraggable={false} nodesConnectable={false} elementsSelectable={false} nodesFocusable={false} edgesFocusable={false} panOnDrag panOnScroll zoomOnScroll={false} minZoom={.1} maxZoom={4} deleteKeyCode={null} onMoveEnd={(_,viewport)=>onViewport(viewport)}>
<InitialPreviewViewport onViewport={onViewport} nodeCount={projection.nodes.length}/>
<Background variant={BackgroundVariant.Dots} gap={24} size={1} />
</ReactFlow>
</CanvasRendererCommandsContext.Provider>
<p role="status" style={{position:'absolute',left:'50%',transform:'translateX(-50%)',bottom:16,pointerEvents:'none'}}>正在加载… {preview.receivedElements}/{preview.totalElements},加载期间可浏览</p>
</div>;
}
+19 -4
View File
@@ -1,31 +1,46 @@
/**
* 文本、脚本、图片卡片共用的节点组件。组合标题、内容、通用变换容器与四向连线入口。
* 编辑通过作用域动作接口发出,不直接写文档;旧连线的无名连接点仍保留用于兼容定位。
*/
"use client";
import { CanvasImage } from "./canvas-image";
import { managedImageSource } from "@/lib/canvas-v2";
import { useContext, useRef } from "react";
import { useRef } from "react";
import { Handle, Position, useViewport, type NodeProps } from "@xyflow/react";
import { FileText, Image as ImageIcon, Type } from "lucide-react";
import { elementCapabilities } from "@/lib/canvas-elements";
import { nodeDirections, type CanvasNode } from "@/lib/canvas";
import { NodeTitle } from "./node-title";
import { CanvasTextEditor } from "./canvas-text-editor";
import {useScopedNodeEditing} from "./node-editing";
import styles from "./canvas.module.css";
import { TransformableNode, NodeEditing } from "./transformable-node";
import { TransformableNode } from "./transformable-node";
export { NodeEditing } from "./transformable-node";
/** 方向字符串到ReactFlow几何方位的映射,与nodeDirections的稳定Handle ID配套。 */
const positions = { top: Position.Top, right: Position.Right, bottom: Position.Bottom, left: Position.Left };
/** 四向连接入口的中文可访问名称,不参与端点数据编码。 */
const labels = { top: "上方", right: "右侧", bottom: "下方", left: "左侧" };
/** 按kind选择图片或文本编辑器,并显示对应标题图标;selected影响样式及连接操作入口。
* 指针点击连接点可打开分支菜单,拖动则留给ReactFlow创建连接。 */
export function MaterialNode(props: NodeProps<CanvasNode>) {
const { id, data, selected } = props;
const editing = useContext(NodeEditing);
// Headless目标携带元素生命周期与归属;旧渲染回调经过作用域检查,避免迟到事件编辑已替换的对象。
const editing = useScopedNodeEditing(id,data.__headlessTarget);
const { zoom } = useViewport();
// 以卡片宽度(默认260)×视口缩放×设备像素比估算显示成本;1024以内优先预览源。
const preview = (props.width || 260) * zoom * (typeof window === "undefined" ? 1 : window.devicePixelRatio || 1) <= 1024;
// 仅记录连接点按下的屏幕坐标,用小于5px的位移区分点击与拖动;不把它写进文档或history。
const pointerStart = useRef({ x: 0, y: 0 });
const Icon = data.kind === "image" ? ImageIcon : data.kind === "script" ? FileText : Type;
// 旧target/source Handle禁用新连接但保留定位;四向Handle使用方向作ID,选中时允许开始连线。
// 指针点击还检查branchable能力和位移;键盘Enter/空格使用Handle中心打开菜单,由动作层再处理合法性。
// 点击/按键停止冒泡,避免分支操作同时触发节点或画布交互;文本内容的编辑生命周期由子组件负责。
return (
<TransformableNode {...props} className={`${styles.node} ${selected ? styles.selectedNode : ""}`}>
<header className={styles.nodeHeading}><Icon size={13} /><NodeTitle id={id} title={data.title} selected={selected} /></header>
<header className={styles.nodeHeading}><Icon size={13} /><NodeTitle id={id} renderedTarget={data.__headlessTarget} title={data.title} selected={selected} /></header>
<Handle type="target" position={Position.Left} className={styles.legacyHandle} isConnectable={false} />
{data.kind === "image" ? <CanvasImage id={id} className={styles.image} src={managedImageSource(data.src, preview)} title={data.title} /> : (
<CanvasTextEditor id={id} data={data} selected={selected} />
+70
View File
@@ -0,0 +1,70 @@
/**
* 节点组件与画布控制器之间的动作上下文。Provider实现文本、名称、样式、几何、分区及编辑模式修改。
* useScopedNodeEditing为新版渲染回调追加目标身份/归属检查,旧版无Overlay上下文时沿用原接口。
* 本模块不拥有文档、history或队列;它只把界面动作交给正确的控制器。
*/
"use client";
import {createContext,useContext,useMemo} from 'react';
import type {XYPosition} from '@xyflow/react';
import type {NodeDirection} from '@/lib/canvas';
import type {TextStyle} from '@/lib/canvas-elements';
import type {NodeTransform} from '@/lib/canvas-geometry';
import type {EditingLease} from './editing-lease';
import type {RenderedTarget} from '@/lib/canvas-bindings/compat-types';
import {CanvasOverlays} from './canvas-overlays';
/** 默认动作为空实现,便于节点展示独立挂载;可选手势钩子兼容旧版调用与新版EditingLease。
* rotating/resizing是Provider提供的当前模式标记,workspace用于构造导航,schema区分适配路径。 */
const defaultEditing = {
beginTransform: undefined as undefined | ((id: string) => boolean | void | EditingLease<NodeTransform>), finishTransform: undefined as undefined | (() => void), cancelTransform: undefined as undefined | (() => void),
endEditing: undefined as undefined | ((id: string) => void),
workspace: "", schema: 1 as 1 | 2,
// begin/select/edit分别供编辑会话、选择与正文更新使用;branch取得屏幕坐标以定位分支菜单。
begin: (_id?: string) => {}, select: (_id: string) => {}, edit: (_id: string, _text: string) => {},
branch: (_id: string, _direction: NodeDirection, _point: XYPosition) => {},
// 几何、文字样式、标题和分区属性分别走独立动作,避免组件直接合并整份节点数据。
transform: (_id: string, _transform: NodeTransform) => {},
style: (_id: string, _style: TextStyle, _fontSize?: number) => {},
rename: (_id: string, _title: string) => {},
frame: (_id: string, _patch: { title?: string; labelMode?: import("@/lib/canvas-elements").FrameLabelMode; presentation?: import("@/lib/canvas-v2").FramePresentation }) => {},
// 传元素id开启相应模式,null请求退出;是否确属当前元素在作用域包装中进一步检查。
rotating: null as string | null, rotate: (_id: string | null) => {},
resizing: null as string | null, resize: (_id: string | null) => {},
};
/** 这些生命周期钩子可缺省,其余基础动作保留默认接口形状。 */
type GestureHooks = 'beginTransform' | 'finishTransform' | 'cancelTransform' | 'endEditing';
/** 由具体画布注入实现;默认上下文不会自行创建一个编辑器实例。 */
export const NodeEditing = createContext<Omit<typeof defaultEditing, GestureHooks> & Partial<Pick<typeof defaultEditing, GestureHooks>>>(defaultEditing);
/** Bind legacy renderer actions to the lifetime and placement that rendered them. */
/** 捕获本次渲染目标的incarnation、ownerCanvasId与视图canvasId,再为每次动作读取最新状态。
* 捕获值代表界面当时展示的对象,最新值用来拒绝过期回调;不能只凭相同元素id就认为仍是同一对象。 */
export function useScopedNodeEditing(id:string,rendered?:RenderedTarget){
// 只有rendered.id匹配当前节点才采用incarnation;有新版scope却缺少合法rendered身份时拒绝编辑。
const editing=useContext(NodeEditing),scope=useContext(CanvasOverlays),incarnation=rendered?.id===id?rendered.incarnation:undefined,owner=rendered?.ownerCanvasId,canvas=rendered?.canvasId;
// 无新版scope直接返回旧接口;否则只在接口、视图作用域或目标身份变化时重建包装。
return useMemo(()=>{if(!scope)return editing;
// 动作当刻检查engine未关闭、视图仍存在且仍在原canvas、元素生命周期与owner未变。
// candidate必须是本节点(或允许的空参数),阻止一个节点的包装替其它目标发起操作。
const current=(candidate:string|null|undefined=id)=>{const durability=scope.engine.getDurability(),view=scope.engine.sessions.getSnapshot().views.get(scope.index.viewId),record=scope.engine.getSnapshot().document.elements.get(id);return !durability.closing&&!durability.closed&&!!view&&view.canvasId===canvas&&incarnation!==undefined&&record?.incarnation===incarnation&&record.ownerCanvasId===owner&&(candidate===undefined||candidate===null||candidate===id);};
// finish/cancel没有id参数,额外确认当前手势属于本视图,且目标列表含本id/incarnation。
const ownGesture=()=>scope.engine.sessions.getSnapshot().gesture?.viewId===scope.index.viewId&&scope.engine.sessions.getSnapshot().gesture?.targets.some(target=>target.id===id&&target.incarnation===incarnation);
// 拷贝展示信息,仅替换动作入口;文本/样式/几何等动作逐次检查,不缓存“曾经有效”的结论。
const scoped:typeof editing={...editing,
begin:candidate=>{if(current(candidate))editing.begin(candidate??id);},select:candidate=>{if(current(candidate))editing.select(candidate);},
edit:(candidate,text)=>{if(current(candidate))editing.edit(candidate,text);},rename:(candidate,title)=>{if(current(candidate))editing.rename(candidate,title);},
style:(candidate,style,size)=>{if(current(candidate))editing.style(candidate,style,size);},frame:(candidate,patch)=>{if(current(candidate))editing.frame(candidate,patch);},
transform:(candidate,change)=>{if(current(candidate))editing.transform(candidate,change);},branch:(candidate,direction,point)=>{if(current(candidate))editing.branch(candidate,direction,point);},
// 退出模式用null表示,但只准退出本节点当前拥有的rotate/resize模式,避免关闭其它节点的模式。
rotate:candidate=>{if(current(candidate)&&(candidate!==null||scope.engine.sessions.getSnapshot().views.get(scope.index.viewId)?.modes.get(id)==='rotate'))editing.rotate(candidate);},resize:candidate=>{if(current(candidate)&&(candidate!==null||scope.engine.sessions.getSnapshot().views.get(scope.index.viewId)?.modes.get(id)==='resize'))editing.resize(candidate);},
};
// 保持可选钩子的存在性:失效时beginTransform返回false,其余动作静默忽略。
// begin取得的租约由创建者管理;这里不把旧事件转给当前另一个手势。
if(editing.beginTransform)scoped.beginTransform=candidate=>current(candidate)?editing.beginTransform!(candidate):false;
if(editing.endEditing)scoped.endEditing=candidate=>{if(current(candidate))editing.endEditing!(candidate);};
// 结束手势要同时通过对象有效性与手势归属检查,防止迟到的pointerup结束新交互。
if(editing.finishTransform)scoped.finishTransform=()=>{if(current()&&ownGesture())editing.finishTransform!();};
if(editing.cancelTransform)scoped.cancelTransform=()=>{if(current()&&ownGesture())editing.cancelTransform!();};
// 包装只负责入口检查;事务合法性、持久化和失败报告仍由Provider对应的动作实现负责。
return scoped;
},[editing,scope,id,incarnation,owner,canvas]);
}
+27
View File
@@ -0,0 +1,27 @@
/**
* ReactFlow节点内部几何更新的Hook适配。将同一个store内的更新交给共享批处理器合并,卸载时释放节点租约。
*/
"use client";
import {useCallback,useEffect,useRef} from 'react';
import {useStoreApi,useUpdateNodeInternals} from '@xyflow/react';
import {createNodeInternalsBatch} from '@/lib/canvas-bindings/node-internals';
// useStoreApi wraps the API per hook. Its shared getState function identifies the
// actual store, separating canvases even when they render the same project.
/** WeakMap按实际store的getState函数区分画布;不按projectId共享,避免多个视图误用另一视图的更新器。 */
const batches=new WeakMap<object,ReturnType<typeof createNodeInternalsBatch>>();
/** 返回稳定的invalidate回调;注册尚未建立或已卸载时调用无效,不直接触发布局读取。 */
export function useNodeInternalsUpdate(id:string){
// useStoreApi的包装对象可能每次不同,稳定getState函数作为store身份;update交给批处理器实际刷新。
const scope=useStoreApi().getState,update=useUpdateNodeInternals();
const lease=useRef<ReturnType<ReturnType<typeof createNodeInternalsBatch>['acquire']>|undefined>(undefined);
// 首个节点创建store级batch,每次挂载取得本id的owner;清理仅释放该owner。
useEffect(()=>{
let batch=batches.get(scope);if(!batch){batch=createNodeInternalsBatch(update);batches.set(scope,batch);}
const owner=batch.acquire(id);lease.current=owner;
return()=>{owner.dispose();if(lease.current===owner)lease.current=undefined;};
},[scope,update,id]);
// 回调通过ref读取当前租约,避免调用方保存旧闭包后继续刷新已卸载节点。
return useCallback(()=>lease.current?.invalidate(),[]);
}
+28 -9
View File
@@ -1,24 +1,43 @@
/**
* 选中节点的样式浮层。把颜色、文字排版、背景与旋转/拉伸模式转换成作用域编辑动作,不在此保存样式副本。
*/
"use client";
import { useContext } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { NodeToolbar, Position, type NodeProps } from "@xyflow/react";
import { AlignCenter, AlignLeft, AlignRight, Bold, Italic, RotateCw, Underline } from "lucide-react";
import type { CanvasNode } from "@/lib/canvas";
import type { TextStyle } from "@/lib/canvas-elements";
import { NodeEditing, ResizeToggle } from "./transformable-node";
import { ResizeToggle } from "./transformable-node";
import {useCanvasOverlay} from "./canvas-overlays";
import {useScopedNodeEditing} from "./node-editing";
import {canvasKeyContext} from "@/lib/canvas-bindings/input-router";
import styles from "./canvas.module.css";
/** NodeToolbar负责定位,AnimatePresence负责选中/取消选中时的进出动画,减少动态效果时关闭位移与时长。
* 浮层注册到overlay索引,保证Portal中的输入也能归属于正确视图与元素。 */
export function NodeStyleToolbar({ id, data, selected }: NodeProps<CanvasNode>) {
const overlay=useCanvasOverlay(id,"node-style");
const reduce = useReducedMotion();
const editing = useContext(NodeEditing);
const editing = useScopedNodeEditing(id,data.__headlessTarget);
const style = data.textStyle || {};
// 只有text/script/plain-text展示文字排版;图片不展示前景/背景色,图标可调颜色但不展示拉伸开关。
const text = data.kind === "text" || data.kind === "script" || data.kind === "plain-text";
/** 统一样式动作入口;字号作为独立可选参数,避免把它混进textStyle的字段空间。 */
const patch = (value: TextStyle, fontSize?: number) => editing.style(id, value, fontSize);
return <NodeToolbar style={{ zIndex: 100, pointerEvents: "auto" }} isVisible={true} position={Position.Top} offset={34}>
<AnimatePresence>{selected && <motion.div initial={{ opacity: 0, y: reduce ? 0 : 5 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: reduce ? 0 : 5 }} transition={{ duration: reduce ? 0 : 0.14 }} className={`${styles.styleToolbar} nodrag nopan nowheel`} data-node-style={id} role="toolbar" aria-label="元素样式"
// 颜色输入用现有值或默认色;字号仅提交8—160范围,透明背景在取色器中显示白色但不自动写回模型。
// 加粗/斜体/下划线按当前值取反,对齐和行高直接提交所选值;清除背景明确写transparent。
// 常规事件停止冒泡;Escape只在非组合输入、非文本编辑且未被处理时关闭当前节点的变换模式。
return <AnimatePresence>{selected && <NodeToolbar style={{ zIndex: 100, pointerEvents: "auto" }} isVisible={true} position={Position.Top} offset={34}>
<motion.div ref={overlay} initial={{ opacity: 0, y: reduce ? 0 : 5 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: reduce ? 0 : 5 }} transition={{ duration: reduce ? 0 : 0.14 }} className={`${styles.styleToolbar} nodrag nopan nowheel`} data-node-style={id} role="toolbar" aria-label="元素样式"
onPointerDown={(event) => event.stopPropagation()} onClick={(event) => event.stopPropagation()} onDoubleClick={(event) => event.stopPropagation()}
onKeyDown={(event) => { if (event.key !== "Escape") event.stopPropagation(); }}>
onKeyDown={(event) => {
// 其它按键留给当前控件并阻断全局快捷键;Escape让输入上下文先决定是否可用于退出模式。
if(event.key!=="Escape"){event.stopPropagation();return;}
if(event.defaultPrevented||event.nativeEvent.isComposing||canvasKeyContext(event.nativeEvent).textEditing)return;
// The registered overlay owns its mode dismissal; global shortcuts stay out.
editing.rotate(null);editing.resize(null);event.preventDefault();event.stopPropagation();
}}>
{!(["image", "plain-image"].includes(data.kind)) && <label title="文字或图标颜色" className={styles.colorPicker}><span style={{ background: style.color || "#37352f" }} /><input aria-label="文字或图标颜色" type="color" value={style.color || "#37352f"} onChange={(event) => patch({ color: event.target.value })} /></label>}
{text && <>
<select aria-label="字体" value={style.fontFamily || "sans"} onChange={(event) => patch({ fontFamily: event.target.value as TextStyle["fontFamily"] })}><option value="sans">正文</option><option value="serif">衬线</option><option value="mono">等宽</option></select>
@@ -29,8 +48,8 @@ export function NodeStyleToolbar({ id, data, selected }: NodeProps<CanvasNode>)
</>}
{!(["image", "plain-image"].includes(data.kind)) && <label className={styles.colorPicker} title="背景色"><span style={{ background: style.background && style.background !== "transparent" ? style.background : "#ffffff" }} /><input aria-label="背景色" type="color" value={style.background && style.background !== "transparent" ? style.background : "#ffffff"} onChange={(event) => patch({ background: event.target.value })} /></label>}
{!(["image", "plain-image"].includes(data.kind)) && <button title="清除背景" aria-label="清除背景" onClick={() => patch({ background: "transparent" })}>∅</button>}
{data.kind !== "icon" && <ResizeToggle id={id} />}
{data.kind !== "icon" && <ResizeToggle id={id} renderedTarget={data.__headlessTarget} />}
<button data-rotate-toggle={id} title="旋转模式" aria-label="旋转模式" aria-pressed={editing.rotating === id} onClick={() => editing.rotate(editing.rotating === id ? null : id)}><RotateCw size={16} /></button>
</motion.div>}</AnimatePresence>
</NodeToolbar>;
</motion.div>
</NodeToolbar>}</AnimatePresence>;
}
+14 -4
View File
@@ -1,18 +1,28 @@
/**
* 节点标题的局部编辑交互。展示态与输入态切换,提交时调用rename;本地value只是输入草稿。
*/
"use client";
import { useContext, useEffect, useRef, useState } from "react";
import { NodeEditing } from "./transformable-node";
import { useEffect, useRef, useState } from "react";
import {useScopedNodeEditing} from "./node-editing";
import styles from "./canvas.module.css";
export function NodeTitle({ id, title, selected }: { id: string; title: string; selected: boolean }) {
const actions = useContext(NodeEditing);
/** 双击先选择节点再编辑,键盘F2/Enter也可进入;选中时标题才加入正常Tab顺序。
* renderedTarget把后续rename绑定到当前渲染的元素生命周期与位置。 */
export function NodeTitle({ id, title, selected,renderedTarget }: { id: string; title: string; selected: boolean;renderedTarget?:import("@/lib/canvas-bindings/compat-types").RenderedTarget }) {
const actions = useScopedNodeEditing(id,renderedTarget);
const [editing, setEditing] = useState(false), [value, setValue] = useState(title);
// cancelled用于Escape触发blur时抑制保存;input用于进入编辑后聚焦并选中全部文字。
const input = useRef<HTMLInputElement>(null), cancelled = useRef(false);
// 展示态跟随外部标题,输入态保留当前草稿;失去选择会退出编辑,进入编辑则集中处理焦点。
useEffect(() => { if (!editing) setValue(title); }, [title, editing]);
useEffect(() => { if (!selected) setEditing(false); }, [selected]);
useEffect(() => { if (editing) { input.current?.focus(); input.current?.select(); } }, [editing]);
/** blur是统一提交入口:忽略取消、空名称与无变化名称,随后复位取消标记并退出输入态。 */
function save() {
if (!cancelled.current) { const name = value.trim(); if (name && name !== title) actions.rename(id, name); }
cancelled.current = false; setEditing(false);
}
// Enter在非输入法组合态触发blur保存,Escape先置取消标记再blur;按键不继续冒泡到画布。
// 标题长度限制300字符,nodrag/nopan/nowheel避免文本操作被当成画布手势。
if (editing) return <input ref={input} className={`${styles.nodeTitleInput} nodrag nopan nowheel`} value={value} maxLength={300} aria-label="节点名称" onChange={(event) => setValue(event.target.value)} onBlur={save}
onKeyDown={(event) => { event.stopPropagation(); if (event.key === "Enter" && !event.nativeEvent.isComposing) event.currentTarget.blur(); if (event.key === "Escape") { cancelled.current = true; event.currentTarget.blur(); } }} />;
return <span className={styles.nodeTitle} title="双击编辑名称" role="button" tabIndex={selected ? 0 : -1} onDoubleClick={(event) => { event.preventDefault(); event.stopPropagation(); actions.select(id); setEditing(true); }}
@@ -1,3 +1,6 @@
/**
* 无卡片标题的图片节点。用通用变换容器承载图片,随视口缩放选择预览或正式资源地址。
*/
"use client";
import { CanvasImage } from "./canvas-image";
import { managedImageSource } from "@/lib/canvas-v2";
@@ -5,8 +8,12 @@ import { useViewport, type NodeProps } from "@xyflow/react";
import type { CanvasNode } from "@/lib/canvas";
import { TransformableNode } from "./transformable-node";
import styles from "./canvas.module.css";
/** 依赖视口zoom估算图片在屏幕上的像素宽度;CanvasImage负责本地预览与正式图片的显示交接。 */
export function PlainImageNode(props: NodeProps<CanvasNode>) {
const { zoom } = useViewport();
// 节点宽度未提供时按320估算,再乘zoom与设备像素比;不超过1024时请求预览源。
// 这是显示宽度启发式,不检查图片原始尺寸;无window时像素比按1处理。
const preview = (props.width || 320) * zoom * (typeof window === "undefined" ? 1 : window.devicePixelRatio || 1) <= 1024;
// managedImageSource按资源格式选择可用地址,移动/缩放等编辑行为仍由TransformableNode处理。
return <TransformableNode {...props} className={styles.plainImageFrame}><CanvasImage id={props.id} className={styles.plainImage} src={managedImageSource(props.data.src, preview)} title={props.data.title} /></TransformableNode>;
}
@@ -1,3 +1,6 @@
/**
* 纯文本节点的显示装配。外层复用节点变换与选择交互,内层使用文本编辑器的plain模式;不在此持有第二份文本数据。
*/
"use client";
import type { NodeProps } from "@xyflow/react";
@@ -5,6 +8,7 @@ import type { CanvasNode } from "@/lib/canvas";
import { TransformableNode } from "./transformable-node";
import { CanvasTextEditor } from "./canvas-text-editor";
/** NodeProps整体交给TransformableNode;文本编辑器取得同一个id/data/selected,plain决定其纯文本呈现方式。 */
export function PlainTextNode(props: NodeProps<CanvasNode>) {
return (
<TransformableNode {...props}>
@@ -0,0 +1,26 @@
/**
* ReactFlow适配层的保留标记缓存。为需要保留的节点/边复制一份带__endoleapRetain的投影记录,原对象不变。
* 标记供应用固定版本的编译扩展使用,不是模型属性,也不应进入history、存储或业务renderer数据。
*/
/** Adapter-only flags consumed by the app's pinned React Flow compiler extension.
* They never enter the document, history, persistence or renderer business data. */
/** 每个投影使用者创建一份缓存:WeakMap复用同一原对象的带标记副本,previous复用最近一次整个输出。 */
export function createRetainedProjection(){
// 弱引用键避免缓存永久占住已被替换的投影对象;只按对象身份缓存,不监测对象内部原地修改。
const tagged=new WeakMap<object,object>();
let previous:{nodes:readonly object[];edges:readonly object[];ids:Set<string>;result:{nodes:object[];edges:object[]}}|undefined;
/** 输入数组和保留ID集合生成节点/边数组;ids为空时直接返回原数组并清除整批缓存。 */
return <N extends {id:string},E extends {id:string}>(nodes:N[],edges:E[],ids:ReadonlySet<string>):{nodes:N[];edges:E[]}=>{
if(!ids.size){previous=undefined;return{nodes,edges};}
// 数组引用及ID成员都相同才复用;快照复制ids,避免调用方之后原地修改Set影响比较基准。
if(previous?.nodes===nodes&&previous.edges===edges&&previous.ids.size===ids.size&&[...ids].every(id=>previous!.ids.has(id)))return previous.result as {nodes:N[];edges:E[]};
/** 未命中的记录保留原引用;命中时浅复制顶层加标记,嵌套数据仍与原投影共享。 */
const retain=<T extends {id:string}>(row:T):T=>{
if(!ids.has(row.id))return row;
let value=tagged.get(row);if(!value){value={...row,__endoleapRetain:true};tagged.set(row,value);}return value as T;
};
// 非空集合下会映射两张数组,即使没有命中也生成数组;下一次相同输入再复用该result。
const result={nodes:nodes.map(retain),edges:edges.map(retain)};
previous={nodes,edges,ids:new Set(ids),result};return result;
};
}
+6
View File
@@ -1,5 +1,11 @@
/**
* 将节点文本数据转换为React内联CSS的纯函数。统一卡片与纯文本的默认外观,不修改原数据或编辑状态。
*/
import type { CSSProperties } from "react";
import type { CanvasNodeData } from "@/lib/canvas-elements";
/** 字号优先data.fontSize,纯文本默认24、卡片默认13;布尔样式映射为明确的CSS值。
* 字体家族只识别serif/mono,其它继承外层;颜色、背景、对齐与行高使用各自默认值。
* 使用||回退,因此0和空字符串也走默认;这里是显示映射,不承担schema校验。 */
export function textAppearance(data: CanvasNodeData): CSSProperties {
const style = data.textStyle || {};
return { fontSize: data.fontSize || (data.kind === "plain-text" ? 24 : 13), color: style.color || "#37352f", fontWeight: style.bold ? 700 : 400, fontStyle: style.italic ? "italic" : "normal", textDecoration: style.underline ? "underline" : "none", textAlign: style.align || "left", lineHeight: style.lineHeight || 1.5,
+78 -39
View File
@@ -1,83 +1,118 @@
/**
* 节点共用的几何交互容器:绘制旋转后的内容、选择样式、拉伸手柄及模式提示,协调一次手势的预览/提交/取消。
* 拖动节点位置仍由外层ReactFlow处理;此处主要处理旋转与尺寸变化,通过NodeEditing而非直接写文档。
*/
"use client";
import { createContext, useContext, useEffect, useRef, type PointerEvent, type ReactNode } from "react";
import { useReactFlow, useUpdateNodeInternals, useViewport, type NodeProps, type XYPosition } from "@xyflow/react";
import { useEffect, useRef, type PointerEvent, type ReactNode } from "react";
import { useReactFlow, useViewport, type NodeProps, type XYPosition } from "@xyflow/react";
import { Grip, Maximize2, RotateCw } from "lucide-react";
import { elementCapabilities, type TextStyle } from "@/lib/canvas-elements";
import type { CanvasNode, NodeDirection } from "@/lib/canvas";
import { elementCapabilities } from "@/lib/canvas-elements";
import type { CanvasNode } from "@/lib/canvas";
import { resizeFromPointer, rotationFromPointer, type NodeTransform, type ResizeDirection } from "@/lib/canvas-geometry";
import type { EditingLease } from "./editing-lease";
import { NodeStyleToolbar } from "./node-style-toolbar";
import { MembershipBadge } from "./frame-feedback";
import { ContentHandles } from "./content-handles";
import {ownsPointerCapture,acquirePointerCapture,releasePointerCapture} from "@/lib/canvas-bindings/pointer-capture";
import {canvasKeyContext,routeCanvasKey} from "@/lib/canvas-bindings/input-router";
import styles from "./canvas.module.css";
import {useNodeInternalsUpdate} from "./node-internals";
export const NodeEditing = createContext({
workspace: "", schema: 1 as 1 | 2,
begin: (_id?: string) => {}, select: (_id: string) => {}, edit: (_id: string, _text: string) => {},
branch: (_id: string, _direction: NodeDirection, _point: XYPosition) => {},
transform: (_id: string, _transform: NodeTransform) => {},
style: (_id: string, _style: TextStyle, _fontSize?: number) => {},
rename: (_id: string, _title: string) => {},
frame: (_id: string, _patch: { title?: string; labelMode?: import("@/lib/canvas-elements").FrameLabelMode; presentation?: import("@/lib/canvas-v2").FramePresentation }) => {},
rotating: null as string | null, rotate: (_id: string | null) => {},
resizing: null as string | null, resize: (_id: string | null) => {},
});
type Gesture = { node: CanvasNode; start: XYPosition; direction: ResizeDirection | "rotate"; target: HTMLElement; pointerId: number };
export {NodeEditing} from "./node-editing";
import {useScopedNodeEditing} from "./node-editing";
/** 保存手势起始节点快照与画布坐标,防止每帧以已预览结果为基准累积误差;pointerId/capture限定事件所有权。
* 新版lease绑定具体交互,旧版则使用上下文的begin/transform/finish/cancel适配。 */
type Gesture = { lease?: EditingLease<NodeTransform>; node: CanvasNode; start: XYPosition; direction: ResizeDirection | "rotate"; capture: HTMLElement; pointerId: number };
/** children决定内容显示,hideStyleToolbar允许分区使用专属设置栏;className补充类型外观。
* 能否缩放/旋转由elementCapabilities和当前选中/模式共同决定,不在每个节点组件重复实现。 */
export function TransformableNode(props: NodeProps<CanvasNode> & { children: ReactNode; className?: string; hideStyleToolbar?: boolean }) {
const { id, data, selected, width, height, children, className = "" } = props;
const editing = useContext(NodeEditing);
const editing = useScopedNodeEditing(id,data.__headlessTarget);
const flow = useReactFlow<CanvasNode>();
const update = useUpdateNodeInternals();
const update = useNodeInternalsUpdate(id);
const { zoom } = useViewport();
const frame = useRef<HTMLDivElement>(null);
const gesture = useRef<Gesture | null>(null);
// 用ref让只挂载一次的window blur监听调用最新cancelGesture;失焦和卸载均取消尚未结束的交互。
const interrupt=useRef(()=>{});interrupt.current=()=>cancelGesture();
useEffect(()=>{const blur=()=>interrupt.current();window.addEventListener('blur',blur);return()=>{window.removeEventListener('blur',blur);interrupt.current();};},[]);
const rotation = data.rotation || 0;
const plain = data.kind === "plain-text" || data.kind === "icon" || data.kind === "plain-image";
const capabilities = elementCapabilities(data.kind);
const resizing = capabilities.resizable && editing.resizing === id && selected;
const rotating = capabilities.rotatable && editing.rotating === id && selected;
useEffect(() => { update(id); }, [id, rotation, width, height, selected, update]);
// 几何、旋转或选中变化后请求批量更新ReactFlow连接点内部数据,避免端点仍留在旧位置。
useEffect(() => { update(); }, [id, rotation, width, height, selected, update]);
// 仅在变换模式启用时监听外部点击与Escape;退出/切换模式拆除监听。
useEffect(() => {
if (!rotating && !resizing) return;
/** 点击容器与对应模式按钮之外退出模式;事件用捕获阶段观察,不要求控件把事件冒泡出来。 */
const outside = (event: globalThis.PointerEvent) => {
if (!frame.current?.contains(event.target as Node) && !(event.target as Element).closest?.('[data-rotate-toggle="' + id + '"], [data-resize-toggle="' + id + '"]')) { editing.rotate(null); editing.resize(null); }
};
const escape = (event: KeyboardEvent) => { if (event.key === "Escape") { cancelGesture(); editing.rotate(null); editing.resize(null); } };
/** 按统一输入路由判断Escape是否归工具,尊重文本/组合输入与已处理事件;认领后取消手势并退出模式。 */
const escape = (event: KeyboardEvent) => {
if(event.key!=="Escape")return;
const claim=routeCanvasKey({key:event.key,composing:event.isComposing,defaultPrevented:event.defaultPrevented,...canvasKeyContext(event)},{tool:()=>{cancelGesture();editing.rotate(null);editing.resize(null);return true;}});
if(claim==='tool'){event.preventDefault();event.stopPropagation();}
};
window.addEventListener("pointerdown", outside, true);
window.addEventListener("keydown", escape);
return () => { window.removeEventListener("pointerdown", outside, true); window.removeEventListener("keydown", escape); };
}, [rotating, resizing, id, editing]);
/** 主指针左键启动;旋转跳过文本输入目标,重复指针不得接管已有手势,另一指针到来先取消。
* 取得起始节点和租约后捕获指针;捕获失败按取消路径恢复,成功才阻止默认行为与冒泡。 */
function start(event: PointerEvent<HTMLElement>, direction: Gesture["direction"]) {
if (event.button !== 0) return;
event.preventDefault(); event.stopPropagation();
if (event.button !== 0||event.defaultPrevented) return;
if(direction==='rotate'&&event.target instanceof Element&&event.target.closest('input,textarea,[contenteditable]:not([contenteditable="false"]),[role="textbox"]'))return;
if(gesture.current){if(gesture.current.pointerId!==event.pointerId)cancelGesture();return;}
if(event.isPrimary===false)return;
const node = flow.getNode(id);
if (!node) return;
editing.begin(id);
// false表示动作层拒绝启动;缺少专用beginTransform时回退旧版begin,随后按需选中节点。
const lease = editing.beginTransform?.(id); if (lease === false) return;
if (!editing.beginTransform) editing.begin(id);
if (!node.selected) editing.select(id);
gesture.current = { node, direction, target: event.currentTarget, pointerId: event.pointerId, start: flow.screenToFlowPosition({ x: event.clientX, y: event.clientY }) };
event.currentTarget.setPointerCapture(event.pointerId);
gesture.current = { lease: lease && typeof lease === 'object' ? lease : undefined, node, direction, capture: event.currentTarget, pointerId: event.pointerId, start: flow.screenToFlowPosition({ x: event.clientX, y: event.clientY }) };
if(!acquirePointerCapture(event.currentTarget,event.pointerId)){cancelGesture();return;}
event.preventDefault();event.stopPropagation();
}
/** 只接受持有捕获的指针,把屏幕坐标变为画布坐标,再基于起始快照算旋转或尺寸变化。
* 旋转Shift吸附由geometry函数处理;八向拉伸模式把Shift传入比例约束,普通角落拖动沿用默认行为。 */
function move(event: PointerEvent<HTMLElement>) {
if (!gesture.current) return;
event.stopPropagation();
if(!gesture.current)return;event.stopPropagation();
if (!ownsPointerCapture(gesture.current,event)) return;
const current = flow.screenToFlowPosition({ x: event.clientX, y: event.clientY });
const { node, start, direction } = gesture.current;
editing.transform(id, direction === "rotate"
const { node, start, direction } = gesture.current!;
const change = direction === "rotate"
? { rotation: rotationFromPointer(node, start, current, event.shiftKey) }
: resizeFromPointer(node, direction, { x: current.x - start.x, y: current.y - start.y }, resizing ? event.shiftKey : undefined));
: resizeFromPointer(node, direction, { x: current.x - start.x, y: current.y - start.y }, resizing ? event.shiftKey : undefined);
// 新版只预览当前lease,旧适配发transform;历史单元何时发布由Provider控制。
if (gesture.current!.lease) gesture.current!.lease!.preview(change); else editing.transform(id, change);
}
function cancelGesture() {
/** 无事件参数可由失焦/卸载强制取消;有事件时先校验捕获归属,避免其它指针干扰。
* 优先lease.cancel,其次cancelTransform;最旧接口缺少取消钩子时把起始几何写回,最后释放捕获。 */
function cancelGesture(event?:{pointerId:number;type?:string;target?:unknown;stopPropagation?():void}) {
if(event&&gesture.current)event.stopPropagation?.();
if(event&&!ownsPointerCapture(gesture.current,event))return;
const current = gesture.current; if (!current) return;
gesture.current = null;
editing.transform(id, current.direction === "rotate" ? { rotation: current.node.data.rotation || 0 } : { position: current.node.position, width: current.node.width, height: current.node.height });
if (current.target.hasPointerCapture(current.pointerId)) current.target.releasePointerCapture(current.pointerId);
if (current.lease) current.lease.cancel(); else if (editing.cancelTransform) editing.cancelTransform(); else editing.transform(current.node.id, current.direction === "rotate" ? { rotation: current.node.data.rotation || 0 } : { position: current.node.position, width: current.node.width, height: current.node.height });
releasePointerCapture(current.capture,current.pointerId);
}
/** 抬起时先采用最后一个指针位置,再清手势引用并提交;随后释放捕获不会误取消已结束交互。 */
function finish(event: PointerEvent<HTMLElement>) {
event.stopPropagation(); gesture.current = null;
if (event.currentTarget.hasPointerCapture(event.pointerId)) event.currentTarget.releasePointerCapture(event.pointerId);
if(!gesture.current)return;event.stopPropagation();
if (!ownsPointerCapture(gesture.current,event)) return; const current = gesture.current!; move(event); gesture.current = null;
if (current.lease) current.lease.commit(); else editing.finishTransform?.();
releasePointerCapture(current.capture,current.pointerId);
}
// 容器的CSS zoom使用viewScale,宽高同时除以viewScale,保持投影尺寸与内部显示坐标对应。
// schema=2给纯文本/纯图片补四向连接点,非分区显示成员徽标;普通尺寸调整用右下角,模式内显示八向手柄。
// 八向手柄的尺寸与边框反向抵消zoom和viewScale,使屏幕上大小稳定;方向决定位置及cursor。
return <>
{!props.hideStyleToolbar && <NodeStyleToolbar {...props} />}
<div ref={frame} className={`${styles.transformFrame} ${selected ? styles.selectedFrame : ""} ${plain ? styles.plainFrame : ""} ${rotating ? styles.rotatingFrame + " nodrag nopan" : ""} ${className}`}
@@ -94,15 +129,18 @@ export function TransformableNode(props: NodeProps<CanvasNode> & { children: Rea
<Grip size={14} />
</button>}
{resizing && (["top-left", "top", "top-right", "right", "bottom-right", "bottom", "bottom-left", "left"] as const).map((direction) => {
// 从方向名推导0%/50%/100%锚点:边中点只占一个方向,角点同时包含横纵方向。
const left = direction.includes("left") ? "0%" : direction.includes("right") ? "100%" : "50%";
const top = direction.includes("top") ? "0%" : direction.includes("bottom") ? "100%" : "50%";
return <button type="button" key={direction} className={`${styles.resizeHandle} nodrag nopan`} data-resize-handle={direction} aria-label={`拉伸 ${direction}`} title="拖动拉伸 · Shift 固定比例" style={{ left, top, width: 10 / (zoom * (data.viewScale || 1)), height: 10 / (zoom * (data.viewScale || 1)), borderWidth: 1 / (zoom * (data.viewScale || 1)), cursor: direction === "top" || direction === "bottom" ? "ns-resize" : direction === "left" || direction === "right" ? "ew-resize" : direction === "top-left" || direction === "bottom-right" ? "nwse-resize" : "nesw-resize" }}
onPointerDown={(event) => start(event, direction)} onPointerMove={move} onPointerUp={finish} onPointerCancel={cancelGesture}
onKeyDown={(event) => {
// 键盘每次方向键形成一次完整变换:默认一个viewScale单位,Ctrl/Meta放大十倍,Shift固定比例。
const delta = { ArrowLeft: [-1, 0], ArrowRight: [1, 0], ArrowUp: [0, -1], ArrowDown: [0, 1] }[event.key]; if (!delta) return;
event.preventDefault(); event.stopPropagation(); const node = flow.getNode(id); if (!node) return;
editing.begin(id); const scale = (data.viewScale || 1) * (event.ctrlKey || event.metaKey ? 10 : 1);
editing.transform(id, resizeFromPointer(node, direction, { x: delta[0] * scale, y: delta[1] * scale }, event.shiftKey));
const lease = editing.beginTransform?.(id); if (lease === false) return; if (!editing.beginTransform) editing.begin(id); const scale = (data.viewScale || 1) * (event.ctrlKey || event.metaKey ? 10 : 1);
const change = resizeFromPointer(node, direction, { x: delta[0] * scale, y: delta[1] * scale }, event.shiftKey);
if (lease && typeof lease === 'object') { lease.preview(change); lease.commit(); } else { editing.transform(id, change); editing.finishTransform?.(); }
}} />;
})}
{resizing && <span className={styles.rotationHint}>拖动八向手柄 · Shift 固定比例 · Esc 退出</span>}
@@ -111,7 +149,8 @@ export function TransformableNode(props: NodeProps<CanvasNode> & { children: Rea
</>;
}
export function ResizeToggle({ id }: { id: string }) {
const editing = useContext(NodeEditing);
/** 受控拉伸模式开关;aria-pressed反映当前节点是否处于模式,data标记供外部点击判定排除自身。 */
export function ResizeToggle({ id,renderedTarget }: { id: string;renderedTarget?:import("@/lib/canvas-bindings/compat-types").RenderedTarget }) {
const editing = useScopedNodeEditing(id,renderedTarget);
return <button type="button" data-resize-toggle={id} aria-label="八向拉伸模式" title="八向拉伸 · Shift 固定比例" aria-pressed={editing.resizing === id} onClick={() => editing.resize(editing.resizing === id ? null : id)}><Maximize2 size={16} /></button>;
}
@@ -0,0 +1,32 @@
/**
* 兼容占位组件:显示可理解的公共几何与说明文字,不解析未知类型的业务payload。
* 原始数据由模型保留;这些组件只提供占位绘制,不执行数据迁移,也不自行决定外层是否允许移动或选择。
*/
"use client";
import { BaseEdge, Handle, Position, getBezierPath, type EdgeProps, type NodeProps } from "@xyflow/react";
import type {ReactFlowProjectionNode as ProjectionNode} from "@/lib/canvas-bindings/react-flow";
import type { CanvasEdgeModel } from "@/lib/canvas-v2";
import styles from "./canvas.module.css";
/** 将投影提供的四边方位转换成ReactFlow Handle要求的枚举。 */
const positions={top:Position.Top,right:Position.Right,bottom:Position.Bottom,left:Position.Left};
/** Render only the known envelope and derived endpoints, never interpret opaque payload fields. */
/** 使用投影已提供的标题、旋转和fallback元数据绘制占位节点,尺寸填满外层节点容器。
* renderer-unavailable明确表示当前缺少显示实现,其它情形提示原内容保留。
* 仍绘制已知连接点供现有连线定位,但禁止从这些Handle发起或接收新连接;不推断未知内容字段。 */
export function UnsupportedNode({data,selected}:NodeProps<ProjectionNode>){
// fallback来自bindings投影;缺失时仍显示通用提示,不尝试从data猜测组件结构。
const fallback=data.__headlessFallback;
return <div className={`${styles.node} ${selected?styles.selectedNode:""}`} style={{width:"100%",height:"100%",padding:14,transform:`rotate(${data.rotation||0}deg)`}} role="note" aria-label="暂不支持编辑的元素">
<span>{data.title}</span><small style={{display:"block"}}>此元素需要兼容的组件版本才能编辑</small>
<small title={fallback?`${fallback.componentId} · v${fallback.dataVersion}`:undefined}>{fallback?.reason==='renderer-unavailable'?'当前视图暂无此类型的编辑器':'原始内容已保留'}</small>
{fallback?.handles.map(handle=><Handle key={handle.id} id={handle.id} type="source" position={positions[handle.side]} className={styles.centerHandle} isConnectable={false} isConnectableStart={false} isConnectableEnd={false} aria-hidden />)}
</div>;
}
/** 直接使用ReactFlow推导的端点生成贝塞尔路径,附可访问标题;不调用未知类型的编辑/样式逻辑。
* BaseEdge绘制统一颜色的可见线条,数据保留与版本兼容仍归模型层负责。 */
export function UnsupportedEdge(props:EdgeProps<CanvasEdgeModel>){
const [path]=getBezierPath(props);
return <g role="img" aria-label="暂不支持编辑的连线"><title>原始连线内容已保留</title><BaseEdge id={props.id} path={path} style={{stroke:'#aa968b',strokeWidth:1.5}} /></g>;
}
+26
View File
@@ -1,10 +1,16 @@
/**
* 旧画布树的AI任务跟踪Hook。发现根及后代画布的活动任务,按事件游标增量读取,协调结果刷新与本地冲突显示。
* 此路径使用内存Map/Set和定时轮询,不具备新版runtime ledger的持久请求恢复;卸载会停止本Hook跟踪。
*/
"use client";
import { useEffect, useRef, useState } from "react";
import { api } from "@/lib/api";
import { CANVAS_ACCEPT } from "@/lib/canvas-v2";
import { canvasPath } from "@/lib/canvas-sync";
import type { useCanvasTree } from "./use-canvas-tree";
/** 服务端模型目录条目:输入/输出模态、可用性与可选参数schema;本Hook只传递模型选择。 */
export type CanvasModel = { id: string; label?: string; display_name?: string; output_modalities: string[]; input_modalities: string[]; available: boolean; unavailable_reason?: string; parameters_schema?: unknown };
/** 旧AI接口的任务分页结果;next_seq/has_more跟踪事件页,steps/outputs/events供面板和活动连线显示。 */
export type AIRun = {
run_id: string; board_id: string; status: string; next_seq: number; has_more?: boolean; error?: string;
steps?: { step_id: string; board_id: string; node_id: string; status: string; error?: string }[];
@@ -12,7 +18,9 @@ export type AIRun = {
events?: { seq: number; type: string; board_id?: string; node_id?: string; logical_edge_id?: string; canvas_version?: number; output?: { output_id: string; text?: string; src?: string } }[];
applied_versions?: Record<string, number>;
};
/** 只有queued/running视为执行中;终态任务仍可能因has_more继续拉取剩余事件。 */
const active = (status: string) => status === "queued" || status === "running";
/** enabled仅控制画布AI模式显示;live才启用服务请求。tree通过ref保持最新,轮询effect按作用域重建。 */
export function useCanvasAI(workspace: string, root: string, live: boolean, tree: ReturnType<typeof useCanvasTree>) {
const [enabled, setEnabled] = useState(false);
const [runs, setRuns] = useState(new Map<string, AIRun>());
@@ -21,16 +29,22 @@ export function useCanvasAI(workspace: string, root: string, live: boolean, tree
const [activeEdges, setActiveEdges] = useState(new Set<string>());
const reference = useRef(tree); reference.current = tree;
const wake = useRef<() => void>(() => {});
// cache是跟踪结果缓存,runs是发布给React的副本;每个run有自己的活动边集合,合并后供连线动画使用。
const cache = useRef(new Map<string, AIRun>());
const edgeStates = useRef(new Map<string, Set<string>>());
// 每个board只保留最高待刷版本,避免同一批step.applied触发多次重复整画布读取。
const pendingRefresh = useRef(new Map<string, number>());
/** 发布新的Map/Set引用触发React更新;不把可变ref集合原样暴露成受控state。 */
const changed = () => { setRuns(new Map(cache.current)); setActiveEdges(new Set([...edgeStates.current.values()].flatMap((values) => [...values]))); };
// 单个effect内polling防并发轮询,dirty合并UI发布;AbortController统一中止这次作用域的读取。
useEffect(() => {
if (!live) return;
let alive = true, timer: ReturnType<typeof setTimeout> | undefined;
let polling = false, dirty = false, nextDiscovery = 0;
const controller = new AbortController();
/** 所有发现/分页GET共享取消信号并协商旧v2响应格式。 */
const request = <T,>(path: string) => api<T>(path, { signal: controller.signal, headers: { Accept: CANVAS_ACCEPT } });
/** 分页发现根和后代活动run,每页50;已跟踪run不覆盖,重复cursor判为协议异常避免无限循环。 */
async function discover() {
let cursor = ""; const seen = new Set<string>();
do {
@@ -39,7 +53,9 @@ export function useCanvasAI(workspace: string, root: string, live: boolean, tree
cursor = page.next_cursor || ""; if (seen.has(cursor)) throw new Error("invalid_run_cursor"); seen.add(cursor);
} while (cursor && alive);
}
/** 是否仍需较快跟踪,包含终态但事件尚未读完的任务。 */
const hasActive = () => [...cache.current.values()].some((run) => active(run.status) || run.has_more);
/** 隐藏页面延后30秒;可见时串行发现、读事件、刷新已应用画布,再按活跃程度安排下一次。 */
async function poll() {
if (!alive || polling) return;
if (document.visibilityState === "hidden") { timer = setTimeout(poll, 30000); return; }
@@ -49,9 +65,11 @@ export function useCanvasAI(workspace: string, root: string, live: boolean, tree
for (const [id, previous] of cache.current) {
if (!active(previous.status) && !previous.has_more) continue;
let current = previous;
// 每页100事件,游标必须安全且不倒退,has_more时必须前进;每run最多保留最近1000条事件。
do {
const page = await request<Omit<AIRun, "board_id">>(canvasPath(workspace, previous.board_id) + `/ai/runs/${id}?after_seq=${current.next_seq}&limit=100`);
if (!Number.isSafeInteger(page.next_seq) || page.next_seq < current.next_seq || page.has_more && page.next_seq === current.next_seq) throw new Error("invalid_run_cursor");
// edge.active加入动画集合,completed/failed/skipped移除;任务终态清空剩余活动边。
const edges = edgeStates.current.get(id) || new Set<string>();
for (const event of page.events || []) {
if (event.logical_edge_id) { if (event.type === "edge.active") edges.add(event.logical_edge_id); else if (["edge.completed", "edge.failed", "edge.skipped"].includes(event.type)) edges.delete(event.logical_edge_id); }
@@ -63,6 +81,7 @@ export function useCanvasAI(workspace: string, root: string, live: boolean, tree
cache.current.set(id, current);
} while (current.has_more && alive);
}
// 等树不忙且不在本地事务中才刷新;版本冲突记录到conflicts,不直接用生成快照覆盖本地草稿。
for (const [board, version] of pendingRefresh.current) {
if (reference.current.busy || reference.current.transactionBusy) break;
const writer = reference.current.writers.current.get(board);
@@ -79,27 +98,34 @@ export function useCanvasAI(workspace: string, root: string, live: boolean, tree
polling = false;
if (alive) timer = setTimeout(poll, hasActive() || pendingRefresh.current.size ? 1200 : 30000);
}
/** 被唤醒时清计时器并强制下一次重新发现;已有poll运行时由其收尾继续调度。 */
const resume = () => { if (timer) clearTimeout(timer); nextDiscovery = 0; void poll(); };
wake.current = resume;
/** 页面重新可见时立即唤醒,避免继续等待后台长间隔。 */
const visible = () => { if (document.visibilityState === "visible") resume(); };
document.addEventListener("visibilitychange", visible);
void poll();
// effect清理中止GET、定时器和可见性监听,wake退为空实现;此处不清除缓存或自动取消服务端任务。
return () => { alive = false; wake.current = () => {}; controller.abort(); if (timer) clearTimeout(timer); document.removeEventListener("visibilitychange", visible); };
}, [workspace, root, live]);
/** 真实画布经tree.command启动任务,先插queued占位供UI显示,再唤醒跟踪;演示模式明确拒绝。 */
async function run(boardId: string, node: string, model: string, prompt: string, propagate: boolean) {
if (!live) throw new Error("demo_ai_unavailable");
const result = await reference.current.command(boardId, "ai/runs", { node_id: node, model_id: model, prompt, parameters: {}, propagate }, undefined, false);
const id = result.run_id as string; cache.current.set(id, { run_id: id, board_id: boardId, status: "queued", next_seq: 0, steps: [{ step_id: "pending", board_id: boardId, node_id: node, status: "queued" }] }); changed(); wake.current();
}
/** 以新幂等键发送取消命令后继续查询终态;暂记queued用于保持轮询,不表示服务端重新执行任务。 */
async function cancel(run: AIRun) {
await api(canvasPath(workspace, run.board_id) + `/ai/runs/${run.run_id}/cancel`, { method: "POST", headers: { Accept: CANVAS_ACCEPT, "Idempotency-Key": crypto.randomUUID() }, body: "{}" });
const next = cache.current.get(run.run_id); if (next) { next.status = "queued"; } wake.current(); // Poll canonical terminal state, including already applied steps.
}
/** 先prepareApply协调目标本地状态,再发显式采用命令;成功后移除冲突提示并继续跟踪服务端最终状态。 */
async function apply(run: AIRun, node: string, output: string, targetBoard: string) {
await reference.current.prepareApply(targetBoard, node);
await reference.current.command(run.board_id, `ai/runs/${run.run_id}/apply`, { node_id: node, output_id: output }, undefined, false, targetBoard);
setConflicts((current) => { const next = new Set(current); next.delete(targetBoard); return next; });
const previous = cache.current.get(run.run_id); if (previous) { previous.status = "queued"; changed(); } wake.current();
}
// 对外只暴露显示状态和三个动作;调用者不直接修改cache、分页游标或刷新等待表。
return { enabled, setEnabled, runs, conflicts, activeEdges, error, run, cancel, apply, live };
}
@@ -1,17 +1,26 @@
/**
* 旧版单画布的自动保存Hook:短延迟备份本地草稿,较长延迟把最新快照交给CanvasSync。
* 它使用旧快照writer,不是新版冻结请求ledger;本地recovery记录也不是可靠保存文件字节的资源仓库。
*/
"use client";
import { useEffect, useRef, useState } from "react";
import { api } from "@/lib/api";
import { canvasFingerprint, canvasPath, CanvasSync, type CanvasSnapshot, type SyncState } from "@/lib/canvas-sync";
import { decodeCanvas, type CanvasDocument } from "@/lib/canvas";
/** snapshot存在才建立远端writer,无snapshot仍可本地备份;writer在挂载时初始化一次,调用方应按画布重挂载。
* key区分草稿作用域,paused阻断这里的新备份/入队,不在此取消已交给writer的请求。 */
export function useCanvasPersistence(document: CanvasDocument, workspace: string, board: string, key: string, snapshot: CanvasSnapshot | undefined, paused: boolean) {
const [sync, setSync] = useState<SyncState>({ status: "saved", version: snapshot?.version || 1 });
// alive限制writer通知更新已卸载组件;latest和pausedRef让异步回调读取当前文档/暂停值。
const alive = useRef(false);
const latest = useRef(document); latest.current = document;
const pausedRef = useRef(paused); pausedRef.current = paused;
// 同一挂载复用writer;状态变化时刷新sync并补本地备份,不随每次文档渲染新建同步器。
const [writer] = useState(() => snapshot ? new CanvasSync(api, canvasPath(workspace, board), snapshot, (next) => {
if (alive.current) { setSync(next); backup(); }
}) : null);
/** 保存规范化document、writer当前版本及内容指纹dirty;回调路径吞掉存储异常,定时effect路径会展示错误。 */
function backup() {
if (pausedRef.current) return;
try {
@@ -19,6 +28,7 @@ export function useCanvasPersistence(document: CanvasDocument, workspace: string
localStorage.setItem(key + ":recovery", JSON.stringify({ document: decodeCanvas(JSON.stringify(current)), baseVersion: writer?.version || 1, dirty: writer ? canvasFingerprint(current) !== writer.saved : false }));
} catch { /* Storage failure is surfaced by the effect below. */ }
}
// 文档变化重置250ms本地/800ms远端计时器,形成防抖;暂停不建立计时器,清理只取消尚未触发的任务。
useEffect(() => {
if (paused) return;
const local = setTimeout(() => {
@@ -28,13 +38,16 @@ export function useCanvasPersistence(document: CanvasDocument, workspace: string
const remote = writer ? setTimeout(() => void writer.enqueue(latest.current), 800) : null;
return () => { clearTimeout(local); if (remote) clearTimeout(remote); };
}, [document, writer, key, paused]);
// pagehide与卸载只执行同步本地backup,不保证远端请求在离页前完成;卸载后不再接收writer的UI通知。
useEffect(() => {
alive.current = true;
/** 页面隐藏/离开时尽量保存当前内存文档,暂停状态由backup内部再次检查。 */
const flush = () => backup();
window.addEventListener("pagehide", flush);
return () => { window.removeEventListener("pagehide", flush); backup(); alive.current = false; };
// Writer and key are stable for this board's mounted editor.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [writer, key]);
// retry先补本地备份再委托writer重试;没有远端writer时返回undefined,不伪造同步成功。
return { ...sync, retry: () => { backup(); return writer?.retry(); } };
}
+163 -14
View File
@@ -1,61 +1,107 @@
/**
* 旧版画布树的状态与操作协调Hook。documents按boardId保存已加载快照,views把嵌套子画布投影为当前ReactFlow场景。
* 每个board有独立CanvasSync writer,本地历史和服务端结构事务共用两个栈,成员迁移另用串行内存队列。
* 该实现保留旧快照协议与本地草稿恢复,不等同于新版engine的统一提交根、持久ledger和恢复状态机。
* 调用方应按根/工作区身份重挂载;refs提供异步操作读取的最新状态,React state负责发布界面变化。
*/
"use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { EdgeChange, NodeChange } from "@xyflow/react";
import { api, ApiError } from "@/lib/api";
import { api as requestAPI, ApiError } from "@/lib/api";
import { canvasReducer, decodeCanvas, type CanvasAction, type CanvasDocument, type CanvasHistory, type CanvasNode } from "@/lib/canvas";
import { CanvasSync, canvasFingerprint, canvasPayload, canvasPath, decodeSnapshot, readLocalRecovery, type CanvasSnapshot } from "@/lib/canvas-sync";
import { CanvasSync, canvasFingerprint, canvasPayload, canvasPath, decodeSnapshot, readLocalRecovery, type CanvasSnapshot, type CanvasRequest } from "@/lib/canvas-sync";
import { CANVAS_ACCEPT, type EdgeData } from "@/lib/canvas-v2";
import { subgraphMovePath, subgraphMoveParticipants } from "@/lib/canvas-subgraphs";
import { mergeCanvasDocuments, projectCanvasTree, transformEdgeData, referenceAfterMoves } from "@/lib/canvas-tree";
/** 命令返回的快照额外携带board_id,用于把多画布结果安装回各自writer。 */
type OwnedSnapshot = CanvasSnapshot & { board_id: string };
/** 兼容多个旧命令端点的结果包络,可能返回父/子/多快照、事务身份、归档列表和版本;各使用路径再解码快照。 */
export type CanvasCommandResult = { transaction_id?: string; parent?: OwnedSnapshot; child?: OwnedSnapshot; snapshots?: OwnedSnapshot[]; children?: OwnedSnapshot[]; canvas_version?: number; applied_versions?: Record<string, number>; archived_board_ids?: string[]; undo_receipt?: { expires_at: string }; [key: string]: unknown };
/** 本地历史保存操作前整份画布,可附关联画布;pendingMove标记尚未上传图片的跨画布移动,便于后来按服务端归属撤销。 */
type LocalHistory = { boardId: string; before: CanvasDocument; related?: Map<string, CanvasDocument>; pendingMove?: { id: string; source: string; target: string } };
/** 服务端结构事务历史保存可重放的路径/负载、事务ID和参与画布;撤销请求服务端undo,重做重新执行原命令。 */
type TransactionHistory = { boardId: string; path: string; payload: Record<string, unknown>; id: string; participants: string[] };
/** 两种历史条目共用undo/redo栈,通过是否有id字段区分;不是每次pointermove都直接创建条目。 */
type Entry = LocalHistory | TransactionHistory;
/** 成员归属迁移的FIFO任务;run在准备后捕获固定body/key供不确定失败重试,refresh表示确定冲突后需刷新基线。 */
type MemberJob = { source: string; target: string; ids: string[]; parent: string; frame: string; participants: string[]; error?: string; refresh?: boolean; run?: () => Promise<void>; resolve: () => void };
/** 只为一次reducer运算建立临时空历史,借其past是否变化判断该动作是否应进入本Hook自己的历史栈。 */
const history = (document: CanvasDocument): CanvasHistory => ({ past: [], present: document, future: [] });
export function useCanvasTree(root: string, workspace: string, initial: CanvasDocument, snapshot?: CanvasSnapshot, paused = false, parentBoardId?: string) {
/** initial建立根画布,snapshot决定是否连接服务端writer;paused阻止保存/独占事务等准入,migration额外限制旧写入。
* parentBoardId用于补读祖先快照;导出、跨分区移动和事务可能按需加载更多画布。 */
export function useCanvasTree(root: string, workspace: string, initial: CanvasDocument, snapshot?: CanvasSnapshot, paused = false, parentBoardId?: string, migration?: {accountId:string;isCurrentIdentity():boolean}) {
// 有迁移上下文时先关旧编辑入口,等迁移协调组件取得共享租约再开放;flush只有迁移准备阶段临时启用。
const migrationGate=useRef(!!migration);
const migrationFlush=useRef(false);
const [migrationHeld,setMigrationState]=useState(!!migration);
/** 同步检查门闩和账号身份,事件处理无需等待React的migrationHeld重新渲染。 */
const migrationBlocked=()=>migrationGate.current||!!migration&&!migration.isCurrentIdentity();
/** 同时更新同步门闩与界面状态;只更新state会留下同一事件轮次中的旧写入窗口。 */
function setMigrationHeld(value:boolean){migrationGate.current=value;setMigrationState(value);}
/** 旧接口请求护栏:await前后检查账号,写请求受迁移门闩限制;迁移排空时只放行canvas PATCH。
* X-Expected-Account要求代理校验目标账号,避免切号后旧请求误用新会话。 */
const api:CanvasRequest=async <T,>(path:string,options?:RequestInit)=>{
if(migration&&!migration.isCurrentIdentity())throw new Error('identity_changed');
if(migrationBlocked()&&options?.method&&options.method!=='GET'&&!(migrationFlush.current&&options.method==='PATCH'&&path.endsWith('/canvas')))throw new Error('canvas_busy');
const headers=new Headers(options?.headers);if(migration)headers.set('X-Expected-Account',migration.accountId);
const value=await requestAPI<T>(path,{...options,headers:Object.fromEntries(headers)});
if(migration&&!migration.isCurrentIdentity())throw new Error('identity_changed');return value;
};
// documents是已加载board的模型表,current立即跟随发布供异步回调读取;status/error是旧writer汇总显示值。
const [documents, setDocuments] = useState(new Map([[root, initial]]));
const current = useRef(documents); current.current = documents;
const [status, setStatus] = useState("saved");
const [error, setError] = useState("");
const [busy, setBusy] = useState(false);
const [memberJobs, setMemberJobs] = useState<MemberJob[]>([]);
// capability缓存查询Promise;members是实际内存队列,memberJobs只是供React显示的数组副本。
const memberCapability = useRef<Promise<boolean> | null>(null);
const members = useRef<MemberJob[]>([]), memberRunning = useRef(false), dragging = useRef(false);
/** 仅挂载期间发布队列副本;任务对象仍共享引用,修改error等字段后需显式通知。 */
const showMembers = () => { if (mounted.current) setMemberJobs([...members.current]); };
/** 拖动期间延后自动保存,结束时发布新Map触发防抖重新评估;不在这里执行成员归属变化。 */
function setDragging(value: boolean) { dragging.current = value; if (!value) publish(new Map(current.current)); }
/** 成员任务涉及的节点暂不能再被普通动作改动;队列非空时也保护子图入口几何及结构,避免请求基准漂移。 */
function protectedNodes(boardId: string) {
const ids = new Set(members.current.flatMap((job) => [job.source, job.target].includes(boardId) ? job.ids : []));
if (members.current.length) for (const node of current.current.get(boardId)?.nodes || []) if (node.data.subgraph) ids.add(node.id);
return ids;
}
const [pendingCommand, setPendingCommand] = useState(false);
// 不确定命令失败时保留同一execute闭包,pendingCommand驱动UI;locked只管当前独占异步操作。
const retryOperation = useRef<(() => Promise<CanvasCommandResult>) | null>(null);
const locked = useRef(false), mounted = useRef(true), pause = useRef(paused); pause.current = paused;
// writer持每画布ACK基线与保存队列,loading去重子画布读取;二者都不是documents的替代存储。
const writers = useRef(new Map<string, CanvasSync>());
const loading = useRef(new Set<string>());
const [retryLoad, setRetryLoad] = useState(0);
const [recoveries, setRecoveries] = useState(new Map<string, CanvasDocument>());
const pendingRecovery = useRef(recoveries); pendingRecovery.current = recoveries;
/** 以当前服务端快照筛选有内容差异的本地恢复候选,按board记录,等待用户决定;不证明可安全覆盖模型。 */
function inspectRecovery(boardId: string, value: CanvasSnapshot) {
const local = readLocalRecovery(localStorage, `endoleap_canvas_v1:${workspace}:${boardId}`, value);
if (local) { const next = new Map(pendingRecovery.current).set(boardId, local); pendingRecovery.current = next; setRecoveries(next); }
}
/** 可编辑时按选择替换本地文档或采用现状,移除恢复候选和草稿记录;远端写入仍走后续writer。 */
function resolveRecovery(boardId: string, restore: boolean) {
if(migrationBlocked())return;
const draft = pendingRecovery.current.get(boardId);
if (restore && draft) update(boardId, { type: "replace", document: draft });
const next = new Map(pendingRecovery.current); next.delete(boardId); pendingRecovery.current = next; setRecoveries(next);
localStorage.removeItem(`endoleap_canvas_v1:${workspace}:${boardId}:recovery`);
}
// 两个ref数组作为历史栈,boardAliases记录重做子图创建后新旧boardID对应;tick用于仅栈变化时通知UI。
const undo = useRef<Entry[]>([]), redo = useRef<Entry[]>([]);
const boardAliases = useRef(new Map<string, string>());
const [, tick] = useState(0);
// 根据根与documents派生投影索引,nodeRefs/edgeRefs把场景ID映射回所属board、本地ID和坐标变换。
const views = useMemo(() => projectCanvasTree(root, documents), [root, documents]);
const view = useRef(views); view.current = views;
/** 先更新同步ref,再在挂载期间发布React state;等待中的操作可立即读取新Map。 */
function publish(next: Map<string, CanvasDocument>) { current.current = next; if (mounted.current) setDocuments(next); }
/** 将服务端快照作为writer基线:已有writer采用新ACK,否则在真实快照模式建立writer;这里只安装,不发保存。 */
function install(boardId: string, value: CanvasSnapshot) {
const writer = writers.current.get(boardId);
if (writer) writer.adopt(value);
@@ -67,25 +113,33 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo
// Initialization never writes a snapshot to the server.
if (snapshot && !writers.current.has(root)) install(root, snapshot);
useEffect(() => { mounted.current = true; return () => { mounted.current = false; }; }, []);
/** 普通单画布动作入口:检查门闩,经reducer计算新文档,保留成员迁移保护字段,再按需要记历史并发布。 */
const update = (boardId: string, action: CanvasAction) => {
if (locked.current || retryOperation.current) return;
if (migrationBlocked() || locked.current || retryOperation.current) return;
const before = current.current.get(boardId); if (!before) return;
const result = canvasReducer(history(before), action);
const protectedIDs = protectedNodes(boardId);
// 迁移中的普通节点只允许选中变化;未作为移动目标的子图入口保留原几何、subgraph/ports/presentation。
// reducer删掉的受保护节点也补回,避免异步成员请求到来时对象已被本地删除。
if (protectedIDs.size) result.present = { ...result.present, nodes: result.present.nodes.map((node) => {
const prior = before.nodes.find((item) => item.id === node.id);
if (!prior || !protectedIDs.has(node.id)) return node;
const moving = members.current.some((job) => [job.source, job.target].includes(boardId) && job.ids.includes(node.id));
return prior.data.subgraph && !moving ? { ...node, position: prior.position, width: prior.width, height: prior.height, data: { ...node.data, subgraph: prior.data.subgraph, ports: prior.data.ports, presentation: prior.data.presentation } } : { ...prior, selected: node.selected };
}).concat(before.nodes.filter((node) => protectedIDs.has(node.id) && !result.present.nodes.some((item) => item.id === node.id))) };
// reducer产生历史才入undo,普通更新路径最多留40项并清redo;其它事务/刷新路径不一定使用同一截断逻辑。
if (result.past.length) { undo.current.push({ boardId, before }); undo.current = undo.current.slice(-40); redo.current = []; }
publish(new Map(current.current).set(boardId, result.present));
};
/** 默认对根发动作,也允许调用方显式指定已加载board。 */
const dispatch = (action: CanvasAction, boardId = root) => update(boardId, action);
/** 从投影节点ID解析实际owner,再用原始节点ID构造动作,避免把场景复合ID写回快照。 */
const nodeAction = (id: string, action: (id: string) => CanvasAction) => { const ref = view.current.nodeRefs.get(id); if (ref) update(ref.boardId, action(ref.id)); };
/** 按owner分组ReactFlow节点变化;忽略add/replace,将场景位置和尺寸还原为所属board局部坐标。 */
const nodesChanged = (changes: NodeChange<CanvasNode>[]) => {
if (locked.current || retryOperation.current) return;
if (migrationBlocked() || locked.current || retryOperation.current) return;
const groups = new Map<string, NodeChange<CanvasNode>[]>();
// 同批父分区也在移动时先计算调整后的原点,避免对子节点重复施加父位移;尺寸再除投影scale。
const moves = new Map(changes.flatMap((change) => change.type === "position" && change.position ? [[change.id, change.position] as const] : []));
for (const change of changes) {
if (change.type === "add" || change.type === "replace") continue;
@@ -93,6 +147,7 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo
const adjusted = referenceAfterMoves(root, current.current, view.current.boards, ref.boardId, moves);
const next = { ...change, id: ref.id };
if (next.type === "position" && next.position) next.position = { x: (next.position.x - adjusted.origin.x) / ref.scale, y: (next.position.y - adjusted.origin.y) / ref.scale };
// ResizeObserver只更新测量信息;非显式resizing不得把测量宽高当模型尺寸写回。
if (next.type === "dimensions") {
// ResizeObserver measurements are editor state. Only an explicit resize
// gesture may write node dimensions back to the owning snapshot.
@@ -103,6 +158,7 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo
}
for (const [boardId, changes] of groups) update(boardId, { type: "nodes", changes });
};
/** 按edgeRefs分组选择/删除等变化,忽略add/replace;最终update仍统一检查写入准入。 */
const edgesChanged = (changes: EdgeChange[]) => {
const groups = new Map<string, EdgeChange[]>();
for (const change of changes) {
@@ -111,13 +167,17 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo
}
for (const [boardId, changes] of groups) update(boardId, { type: "edges", changes });
};
/** 曲线路径从场景坐标反变换回owner局部空间,再派发edge-data;transient交reducer决定历史语义。 */
const patchEdge = (id: string, data: EdgeData, transient = false) => {
const ref = view.current.edgeRefs.get(id); if (ref) update(ref.boardId, { type: "edge-data", id: ref.id, data: transformEdgeData(data, ref, true)!, transient });
};
/** 遍历已加载文档,只有目标owner/localID被选中,其余节点取消选择;不直接操作投影数组。 */
const select = (id: string) => {
for (const [boardId, document] of current.current) update(boardId, { type: "nodes", changes: document.nodes.map((node) => ({ type: "select", id: node.id, selected: view.current.nodeRefs.get(id)?.boardId === boardId && view.current.nodeRefs.get(id)?.id === node.id })) });
};
/** 读取并解码一个board的旧v2快照,可传取消信号;api层仍检查迁移账号身份。 */
const read = async (boardId: string, signal?: AbortSignal) => decodeSnapshot(await api<CanvasSnapshot>(canvasPath(workspace, boardId), { headers: { Accept: CANVAS_ACCEPT }, signal }));
/** 查询服务端是否支持成员迁移同时提交坐标;演示为false,成功结果复用,失败清缓存供按需重试。 */
function supportsMemberPositions(): Promise<boolean> {
if (!snapshot) return Promise.resolve(false);
if (!memberCapability.current) {
@@ -128,9 +188,11 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo
}
return memberCapability.current;
}
// 能力预取失败不视作确定不支持,真正成员操作仍可重新请求。
useEffect(() => {
void supportsMemberPositions().catch(() => { /* A failed prefetch is retried on demand, never cached as unsupported. */ });
}, [root, workspace, !!snapshot]);
/** 按需加载真实快照或演示本地草稿,装writer并检查恢复候选;recursive递归加载子图,祖先集合防环且限制深度。 */
async function ensure(boardId: string, recursive = false, ancestors = new Set<string>()): Promise<void> {
if (ancestors.has(boardId) || ancestors.size > 8) throw new Error("subgraph_cycle");
let document = current.current.get(boardId);
@@ -140,14 +202,17 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo
}
if (recursive) { const next = new Set(ancestors); next.add(boardId); for (const node of document.nodes) if (node.data.subgraph) await ensure(node.data.subgraph.boardId, true, next); }
}
/** 独占期间完整加载根下可达子图,然后收集导出Map;不会把已加载但不可达的祖先或其它分支混入导出。 */
async function exportDocuments() {
return exclusive(async () => {
await ensure(root, true);
const reachable = new Map<string, CanvasDocument>();
/** 可达集合兼作去重;ensure已验证递归关系,此处只遍历当前已加载文档。 */
const collect = (id: string) => { const document = current.current.get(id)!; reachable.set(id, document); for (const node of document.nodes) if (node.data.subgraph && !reachable.has(node.data.subgraph.boardId)) collect(node.data.subgraph.boardId); };
collect(root); return reachable;
});
}
// 打开子画布时向上读取元数据与快照,补齐祖先关系;alive阻止离页后的采用,不在本effect中abort请求。
useEffect(() => {
if (!snapshot || !parentBoardId) return;
let alive = true;
@@ -165,6 +230,7 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo
// Root identity is stable until this editor unmounts.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [parentBoardId, root, workspace]);
// 场景可见且未折叠的子图按需异步读取,loading去重;回包时重新确认仍被当前文档引用。
useEffect(() => {
for (const [boardId, document] of documents) for (const node of document.nodes) {
if (!view.current.boards.has(boardId)) continue;
@@ -179,9 +245,12 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo
// Values and writers are scoped to this mounted root/workspace.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [documents, retryLoad]);
// 文档变化防抖:250ms本地备份、800ms远端保存。恢复待决画布跳过,防止覆盖用户尚未选择的草稿。
// 远端另等独占锁、命令重试、成员任务和拖动结束;本地备份不需要等这些普通操作全部结束。
useEffect(() => {
if (paused) return;
if (paused || migrationBlocked()) return;
const backup = setTimeout(() => {
if(migrationBlocked())return;
for (const [boardId, document] of current.current) try {
if (pendingRecovery.current.has(boardId)) continue;
const writer = writers.current.get(boardId);
@@ -189,14 +258,16 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo
} catch { setError("local_backup_failed"); }
}, 250);
const save = setTimeout(() => {
if (locked.current || retryOperation.current || pause.current || members.current.length || dragging.current) return;
if (migrationBlocked() || locked.current || retryOperation.current || pause.current || members.current.length || dragging.current) return;
for (const [boardId, document] of current.current) if (!pendingRecovery.current.has(boardId)) void writers.current.get(boardId)?.enqueue(document);
}, 800);
return () => { clearTimeout(backup); clearTimeout(save); };
}, [documents, paused, workspace, memberJobs]);
}, [documents, paused, workspace, memberJobs, migrationHeld]);
// pagehide/卸载尽量做本地同步备份,不等待远端flush;暂停/迁移仍禁止写,存储满时保留已有记录。
useEffect(() => {
/** 遍历最新内存文档,备份规范化payload与ACK版本/dirty;页面离开时错误不再触发界面更新。 */
const backup = () => {
if (pause.current) return;
if (pause.current || migrationBlocked()) return;
for (const [boardId, document] of current.current) {
if (pendingRecovery.current.has(boardId)) continue;
try { const writer = writers.current.get(boardId); localStorage.setItem(`endoleap_canvas_v1:${workspace}:${boardId}:recovery`, JSON.stringify({ document: canvasPayload(document), baseVersion: writer?.version || 1, dirty: writer ? writer.needsSave(document) : false })); } catch { /* Existing draft is retained if storage is full. */ }
@@ -205,6 +276,8 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo
window.addEventListener("pagehide", backup);
return () => { window.removeEventListener("pagehide", backup); backup(); };
}, [workspace]);
/** 串行排空各writer,任一恢复待决或writer错误就失败;deferMemberPositions保留待移动节点原ACK坐标。
* 支持原子坐标的成员接口会在归属事务里提交新位置,避免先把新位置单独写入旧owner。 */
async function flush(deferMemberPositions = false) {
for (const [boardId, document] of current.current) {
if (pendingRecovery.current.has(boardId)) throw new Error("draft_recovery_required");
@@ -214,15 +287,50 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo
await writer.enqueue(saving); await writer.settled(); if (writer.error) throw new Error(writer.error);
}
}
/** 迁移前必须已关门,无事务/成员/拖动/加载,且恢复已处理;加载整棵迁移树并核查上传占位。
* 先备份本页,再临时放行PATCH排空旧writer;所有writer确认无待保存内容后把备份标为clean。 */
async function prepareMigration(projectRoot:string){
if(!migrationGate.current)throw new Error('migration_gate_required');
if(locked.current||retryOperation.current||members.current.length||dragging.current||loading.current.size)throw new Error('canvas_busy');
if(pause.current||pendingRecovery.current.size)throw new Error('draft_recovery_required');
await Promise.all([...writers.current.values()].map(writer=>writer.settled()));
await ensure(projectRoot,true);
if(!mounted.current||migration&&!migration.isCurrentIdentity())throw new Error('identity_changed');
if(pendingRecovery.current.size)throw new Error('draft_recovery_required');
if([...current.current.values()].some(document=>document.nodes.some(node=>node.pendingUpload)))throw new Error('migration_pending_uploads');
for(const[boardId,document]of current.current){
const writer=writers.current.get(boardId);
const local=readLocalRecovery(localStorage,`endoleap_canvas_v1:${workspace}:${boardId}`,writer?{version:writer.version,document:writer.document}:undefined);
// 本地另有不同恢复内容时转为用户选择,不能用当前内存直接覆盖它后迁移。
if(local&&canvasFingerprint(local,false)!==canvasFingerprint(document,false)){
const next=new Map(pendingRecovery.current).set(boardId,local);pendingRecovery.current=next;setRecoveries(next);throw new Error('draft_recovery_required');
}
// Preserve the current view even if it closes while PATCH is in flight.
localStorage.setItem(`endoleap_canvas_v1:${workspace}:${boardId}:recovery`,JSON.stringify({document:canvasPayload(document),baseVersion:writer?.version||1,dirty:writer?writer.needsSave(document):false}));
}
// 只在flush的try/finally窗口放开旧PATCH,其它写命令仍被api护栏拒绝。
migrationFlush.current=true;
try{await flush();}finally{migrationFlush.current=false;}
if(!mounted.current||migration&&!migration.isCurrentIdentity())throw new Error('identity_changed');
for(const[boardId,document]of current.current){
const writer=writers.current.get(boardId);
if(writer?.error||writer?.needsSave(document))throw new Error(writer.error||'canvas_busy');
localStorage.setItem(`endoleap_canvas_v1:${workspace}:${boardId}:recovery`,JSON.stringify({document:canvasPayload(document),baseVersion:writer?.version||1,dirty:false}));
}
}
/** 旧版内存独占执行器,阻断冲突准入并发布busy;异常记error后继续抛出,finally可靠释放锁。
* 它不是跨标签页锁,也不提供操作的整批服务端回滚。 */
async function exclusive<T>(operation: () => Promise<T>) {
if (locked.current || pause.current || members.current.length) throw new Error("canvas_busy");
if (migrationBlocked() || locked.current || pause.current || members.current.length) throw new Error("canvas_busy");
locked.current = true; setBusy(true);
try { return await operation(); } catch (error) { setError(error instanceof Error ? error.message : "request_failed"); throw error; }
finally { locked.current = false; if (mounted.current) setBusy(false); }
}
/** 服务端快照更新时恢复本地选中标记,避免结构事务结果抹掉当前视图选择。 */
function keepSelection(document: CanvasDocument, local?: CanvasDocument): CanvasDocument {
return { ...document, nodes: document.nodes.map((node) => ({ ...node, selected: local?.nodes.find((item) => item.id === node.id)?.selected })), edges: document.edges.map((edge) => ({ ...edge, selected: local?.edges.find((item) => item.id === edge.id)?.selected })) };
}
/** 汇总命令返回的各类快照,按boardID去重,基于旧ACK做三方合并再安装新基线。 */
function accept(result: CanvasCommandResult) {
const next = new Map(current.current);
const values = [...new Map([...(result.parent ? [result.parent] : []), ...(result.child ? [result.child] : []), ...(result.snapshots || []), ...(result.children || [])].map((value)=>[value.board_id,value])).values()];
@@ -231,6 +339,8 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo
const merged=local&&base?mergeCanvasDocuments(base,local,decoded.document):decoded.document;
install(value.board_id,decoded);next.set(value.board_id,keepSelection(merged,local));
}
// 子图归档后,将仍在内存上传的节点及相关边投到父画布,避免服务端快照中没有的占位被丢弃。
// 其余已归档文档及writer从当前表移除,不在这里删除上传任务的原文件。
for (const id of result.archived_board_ids || []) {
const local=current.current.get(id);
const owner=[...current.current].find(([,doc])=>doc.nodes.some((node)=>node.data.subgraph?.boardId===id));
@@ -245,6 +355,8 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo
publish(next);
return [...new Set(values.map((value) => value.board_id))];
}
/** 结构/AI等旧命令入口:加载参与writer、flush,再冻结expected_versions、If-Match和幂等key。
* execute闭包在不确定失败后复用同一body/key/version;这些恢复信息只在内存,不保证刷新后继续。 */
async function command(boardId: string, path: string, payload: Record<string, unknown>, participants?: string[], record = true, versionBoard = boardId) {
if (retryOperation.current) throw new Error("canvas_busy");
return exclusive(async () => {
@@ -254,21 +366,25 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo
await flush();
const body = { ...payload, ...(participants ? { expected_versions: Object.fromEntries(participants.map((id) => [id, writers.current.get(id)?.version])) } : {}) };
const key = crypto.randomUUID(), version = writers.current.get(versionBoard)?.version || 1;
/** 发送、采用回执、按需补读目标快照,并把有transaction_id的结果记入服务端事务历史。 */
const execute = async (): Promise<CanvasCommandResult> => {
try {
const result = await api<CanvasCommandResult>(canvasPath(workspace, boardId) + "/" + path, { method: "POST", headers: { Accept: CANVAS_ACCEPT, "If-Match": `"${version}"`, "Idempotency-Key": key }, body: JSON.stringify(body) });
const changed = accept(result);
// 改变子图/事务后通知侧栏刷新目录;没有快照但有版本结果时补读并保留期间的本地编辑。
if (path.startsWith("subgraphs") || path.startsWith("transactions/")) window.dispatchEvent(new window.CustomEvent("endoleap:board-directory-changed", { detail: { workspace } }));
if (!changed.length && result.canvas_version) {
const target=typeof result.board_id === "string"?result.board_id:boardId,remote=await read(target),local=current.current.get(target),base=writers.current.get(target)?.document;
const merged=base&&local?mergeCanvasDocuments(base,local,remote.document):remote.document;
install(target,remote);publish(new Map(current.current).set(target,keepSelection(merged,local)));
}
// record=false用于undo/redo等已有历史流程,避免将历史遍历再次当新操作压栈。
if (record && result.transaction_id) { undo.current.push({ boardId, path, payload, id: result.transaction_id, participants: changed }); redo.current = []; tick((v) => v + 1); }
retryOperation.current = null; setPendingCommand(false); setError("");
return result;
} catch (error) {
const code = error instanceof Error ? error.message : "request_failed";
// 网络/上游/解码等不确定失败保留execute并阻断普通编辑;确定拒绝清重试入口,错误仍交调用方处理。
if (error instanceof TypeError || error instanceof ApiError && error.status >= 500 || ["upstream_timeout", "upstream_unavailable", "request_failed", "Failed to fetch", "internal_error", "invalid_canvas_snapshot"].includes(code)) { retryOperation.current = execute; setPendingCommand(true); }
else { retryOperation.current = null; setPendingCommand(false); }
throw error;
@@ -279,12 +395,15 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo
}
// Ownership changes pause autosaves, not unrelated local editing. Server
// acknowledgements merge against the persisted base, preserving intervening edits.
/** 成员队列只跑队首;归属移动期间暂停autosave,允许不相关本地编辑,结果按捕获ACK合并。
* 只有成功才shift并resolve;失败任务留队首供重试,Promise不在失败时假装完成。 */
async function runMembers() {
if (memberRunning.current || !members.current.length) return;
if (migrationBlocked() || memberRunning.current || !members.current.length) return;
const job = members.current[0]; memberRunning.current = true; job.error = undefined; showMembers();
try {
if (!job.run) {
for (const id of job.participants) if (!writers.current.has(id)) await ensure(id);
// 412等确定冲突后的重新准备:读参与快照,先三方合并本地变化,再更新各writer基线。
if (job.refresh) {
const refreshed = await Promise.all(job.participants.map(async (id) => [id, await read(id)] as const));
const next = new Map(current.current);
@@ -295,6 +414,7 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo
for (const [id, remote] of refreshed) install(id, remote);
publish(next); job.refresh = false;
}
// 支持坐标时flush延后待移动节点位置;否则先按旧保存行为排空,再捕获参与文档作为合并基线。
const atomic = await supportsMemberPositions();
await flush(atomic === true);
const bases = new Map(job.participants.map((id) => [id, writers.current.get(id)!.document]));
@@ -303,17 +423,22 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo
const node = source.nodes.find((node) => node.id === id); if (!node) throw new Error("invalid_reference");
return [id, node.position];
}));
// 请求内容含参与版本和可选局部坐标;创建job.run后重试不得换body或幂等key,除非确定原请求未应用。
const body = JSON.stringify({ source_board_id: job.source, target_board_id: job.target, node_ids: job.ids, ...(atomic ? { positions } : {}),
expected_versions: Object.fromEntries(job.participants.map((id) => [id, writers.current.get(id)!.version])) });
const key = crypto.randomUUID(), historyIndex = undo.current.length;
/** 一次准备生成的可重试闭包;要求回执覆盖所有参与画布,不能凭缺失快照移动本地owner。 */
job.run = async () => {
const result = await api<CanvasCommandResult>(canvasPath(workspace, job.parent) + `/subgraphs/${job.frame}/members`, {
method: "POST", headers: { Accept: CANVAS_ACCEPT, "Idempotency-Key": key }, body });
const snapshots = [...(result.parent ? [result.parent] : []), ...(result.snapshots || [])];
const values = new Map(snapshots.map((value) => [value.board_id, { ...decodeSnapshot(value), board_id: value.board_id }]));
if (!job.participants.every((id) => values.has(id))) throw new Error("invalid_canvas_snapshot");
/** 移动结果已含新坐标,合并前只把源节点暂存位置还原到基线,避免重复施加坐标变化。 */
const normalize = (id: string, local: CanvasDocument) => id !== job.source ? local : { ...local, nodes: local.nodes.map((node) => job.ids.includes(node.id) ? { ...node, position: bases.get(id)!.nodes.find((item) => item.id === node.id)!.position } : node) };
/** 参与画布基于原ACK+归一化本地内容+返回快照重放,未参与文档原样保留。 */
const rebaseLocal=(id:string,doc:CanvasDocument)=>values.has(id)?mergeCanvasDocuments(bases.get(id)!,normalize(id,doc),values.get(id)!.document):doc;
// 请求期间新增本地历史也基于新owner结果调整;开始前历史和服务端事务条目保持原语义。
const rebasedHistory = undo.current.map((entry,index)=>index<historyIndex||"id" in entry?entry:{...entry,before:rebaseLocal(entry.boardId,entry.before),...(entry.related?{related:new Map([...entry.related].map(([id,doc])=>[id,rebaseLocal(id,doc)]))}:{})});
const next = new Map(current.current);
for (const [id, value] of values) {
@@ -328,6 +453,7 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo
for (const [id, value] of values) install(id, value);
publish(next);
undo.current = rebasedHistory;
// 将迁移事务插回开始时的historyIndex,保留请求期间后来编辑的顺序,并清除旧redo。
if (result.transaction_id) { undo.current.splice(historyIndex, 0, { boardId: job.parent, path: `subgraphs/${job.frame}/members`, payload: { source_board_id: job.source, target_board_id: job.target, node_ids: job.ids, positions }, id: result.transaction_id, participants: [...values.keys()] }); redo.current = []; }
};
}
@@ -337,6 +463,7 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo
job.error = error instanceof Error ? error.message : "request_failed";
// A definite rejection performed zero writes. A retry may prepare fresh
// versions; ambiguous transport failures must retain the exact body/key.
// 确定拒绝可清冻结闭包重新准备;412还需刷新版本。不确定传输失败继续保留原job.run。
if (error instanceof ApiError && error.status < 500) { job.run = undefined; job.refresh = error.status === 412; }
showMembers();
} finally {
@@ -344,24 +471,33 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo
if (members.current.length && !members.current[0].error) void runMembers();
}
}
/** 检查准入和重叠成员任务后入FIFO队列;返回Promise由成功出队时resolve,错误在memberJobs中供UI重试。 */
function moveMembers(parent: string, frame: string, source: string, target: string, ids: string[], participants: string[]) {
if (locked.current || retryOperation.current || pause.current || members.current.some((job) => job.ids.some((id) => ids.includes(id)) && [job.source, job.target].includes(source))) return Promise.reject(new Error("canvas_busy"));
if (migrationBlocked() || locked.current || retryOperation.current || pause.current || members.current.some((job) => job.ids.some((id) => ids.includes(id)) && [job.source, job.target].includes(source))) return Promise.reject(new Error("canvas_busy"));
return new Promise<void>((resolve) => {
members.current.push({ parent, frame, source, target, ids, participants, resolve }); showMembers(); void runMembers();
});
}
/** 将上传资源/尺寸绑定到所有当前文档及本地历史,避免撤销恢复旧Blob占位。
* 无asset时保留pendingUpload;尺寸仅在纯图片仍为320×240默认值时采用,不覆盖用户已调整大小。 */
function resolveImageUpload(id: string, asset?: { url: string; id: string }, size?: {width:number;height:number}) {
if(migrationBlocked())return;
/** 只匹配pendingUpload身份,移除或保留占位标记,并按文档schema选择是否写assetId。 */
const resolve = (document: CanvasDocument): CanvasDocument => ({ ...document, nodes: document.nodes.map((node) => {
if (node.pendingUpload !== id) return node;
const fitted=node.data.kind==="plain-image"&&node.width===320&&node.height===240?size:undefined;
const {pendingUpload,...ready}=node;
return { ...ready, ...(!asset?{pendingUpload}:{}), ...(fitted?{...fitted,style:{...ready.style,...fitted}}:{}), data: { ...ready.data, ...(asset?{src:asset.url,...(document.schema_version===2?{assetId:asset.id}:{})}:{}) } };
}) });
/** 仅改本地快照历史(含related),服务端事务条目仍由服务端重放解释。 */
const rebase = (entry: Entry): Entry => "id" in entry ? entry : { ...entry, before: resolve(entry.before), ...(entry.related ? {related:new Map([...entry.related].map(([id,doc])=>[id,resolve(doc)]))} : {}) };
undo.current = undo.current.map(rebase); redo.current = redo.current.map(rebase);
publish(new Map([...current.current].map(([board,doc])=>[board,resolve(doc)])));
}
/** 未上传且无关联边的图片可先在内存跨分区移动;校验容量/ID后按两owner原点和scale换算。
* 保存双画布before及pendingMove历史,上传完成后若进入服务端,撤销可能需要真实成员事务。 */
function movePendingImage(source: string, target: string, id: string) {
if(migrationBlocked())throw new Error("canvas_busy");
const from = current.current.get(source)!, to = current.current.get(target)!;
const node = from.nodes.find((node)=>node.id===id); if (!node?.pendingUpload || to.nodes.length>=500 || to.nodes.some((item)=>item.id===id)) throw new Error("invalid_reference");
const projected=projectCanvasTree(root,current.current), a=projected.boards.get(source)!,b=projected.boards.get(target)!;
@@ -370,6 +506,7 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo
undo.current.push({boardId:source,before:from,related:new Map([[target,to]]),pendingMove:{id,source,target}});redo.current=[];
publish(new Map(current.current).set(source,{...from,nodes:from.nodes.filter((node)=>node.id!==id)}).set(target,{...to,nodes:[...to.nodes,moved]}));
}
/** 独占等待writer停止后读新快照并三方合并;内容指纹改变才记本地历史,再安装新ACK。 */
async function refresh(boardId: string) {
return exclusive(async () => {
const writer = writers.current.get(boardId); if (!writer) return;
@@ -380,6 +517,8 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo
install(boardId, remote); publish(new Map(current.current).set(boardId, merged));
});
}
/** 显式采用生成结果前,针对目标节点把基线text/src/assetId/ai对齐到远端,再合并其它本地编辑。
* 合并结果先排空保存,后续apply命令才能在明确版本上覆盖目标;此方法本身不发送生成结果采用请求。 */
async function prepareApply(boardId: string, nodeId: string) {
await exclusive(async () => {
const writer = writers.current.get(boardId); if (!writer) throw new Error("invalid_reference");
@@ -393,12 +532,17 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo
setError("");
});
}
/** 查看栈顶并检查准入,成功后才移动栈项;本地条目替换before,事务条目调用服务端undo或重做。
* 重做可能创建新子board,使用alias将后续历史引用转到新身份,避免继续操作已归档旧子图。 */
async function undoAction(isRedo = false) {
const stack = isRedo ? redo.current : undo.current, destination = isRedo ? undo.current : redo.current;
const original = stack.at(-1); if (!original || locked.current || retryOperation.current || members.current.length) return;
const original = stack.at(-1); if (!original || migrationBlocked() || locked.current || retryOperation.current || members.current.length) return;
/** 沿别名链解析最新boardID并防循环;只用于历史重放,不篡改任意元素ID。 */
const alias = (id: string) => { const seen = new Set<string>(); while (boardAliases.current.has(id) && !seen.has(id)) { seen.add(id); id = boardAliases.current.get(id)!; } return id; };
/** 重做本地快照时更新子图board引用,其它节点内容保留。 */
const remapDocument = (document: CanvasDocument) => ({ ...document, nodes: document.nodes.map((node) => node.data.subgraph ? { ...node, data: { ...node.data, subgraph: { boardId: alias(node.data.subgraph.boardId) } } } : node) });
const entry: Entry = isRedo ? "id" in original ? { ...original, boardId: alias(original.boardId), participants: original.participants.map(alias), payload: { ...original.payload, ...(typeof original.payload.source_board_id === "string" ? { source_board_id: alias(original.payload.source_board_id) } : {}), ...(typeof original.payload.target_board_id === "string" ? { target_board_id: alias(original.payload.target_board_id) } : {}), ...Object.fromEntries(["source", "target"].flatMap((key) => { const endpoint = original.payload[key] as { board_id?: string } | undefined; return endpoint?.board_id ? [[key, { ...endpoint, board_id: alias(endpoint.board_id) }]] : []; })) } } : { ...original, boardId: alias(original.boardId), before: remapDocument(original.before), ...(original.related?{related:new Map([...original.related].map(([id,doc])=>[alias(id),remapDocument(doc)]))}:{}) } : original;
// 待上传图片若已存在于writer基线,纯本地快照切换已不够,需按归属路径逐跳请求成员移动后再转栈。
if (!("id" in entry) && entry.pendingMove) {
const move=entry.pendingMove,desired=alias(isRedo?move.target:move.source);
const owner=[...current.current].find(([,doc])=>doc.nodes.some((node)=>node.id===move.id));
@@ -409,6 +553,7 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo
stack.pop();destination.push(entry);tick((v)=>v+1);return;
}
}
// 服务端事务撤销调用transactions/{id}/undo;重做执行原命令并保存新的事务ID/参与快照身份。
if ("id" in entry) {
if (isRedo) {
const result = await command(entry.boardId, entry.path, entry.payload, entry.path === "subgraphs" ? undefined : entry.participants, false);
@@ -421,6 +566,7 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo
await command(entry.boardId, `transactions/${entry.id}/undo`, {}, entry.participants, false);
stack.pop(); destination.push(entry);
}
// 普通本地历史用当前文档作为反向条目,连同related画布一起发布,保证下一次redo可还原。
} else {
const before = current.current.get(entry.boardId); if (!before) return;
const next=new Map(current.current).set(entry.boardId, entry.before), related=new Map<string,CanvasDocument>();
@@ -429,6 +575,9 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo
}
tick((v) => v + 1);
}
/** 重试普通writer并触发子图读取effect;不在这里自动重发成员任务或未确认command,它们有各自入口。 */
const retry = async () => { setError(""); setRetryLoad((v) => v + 1); for (const writer of writers.current.values()) await writer.retry(); };
return { documents, current, views, resolveImageUpload, movePendingImage, memberJobs, protectedNodes, setDragging, moveMembers, retryMembers: () => void runMembers(), transactionBusy: !!memberJobs.length, pendingCommand, retryPending: () => retryOperation.current ? exclusive(retryOperation.current) : Promise.resolve(null), prepareApply, recoveries, resolveRecovery, ensure, exportDocuments, dispatch, nodeAction, nodesChanged, edgesChanged, patchEdge, select, flush, command, refresh, read, busy: busy || pendingCommand, error, status, retry, undo: () => undoAction(), redo: () => undoAction(true), canUndo: !memberJobs.length && !!undo.current.length, canRedo: !memberJobs.length && !!redo.current.length, writers, rootDocument: documents.get(root)!, setError };
// 对外暴露投影、动作、恢复、事务及writer引用,旧版UI依赖此组合接口;busy合并独占、待确认命令与迁移门闩。
// transactionBusy/canUndo/canRedo反映成员队列与栈是否有项,最终undoAction仍再次检查实际准入。
return { migrationHeld,setMigrationHeld,prepareMigration,isMigrationHeld:migrationBlocked, documents, current, views, resolveImageUpload, movePendingImage, memberJobs, protectedNodes, setDragging, moveMembers, retryMembers: () => void runMembers(), transactionBusy: !!memberJobs.length, pendingCommand, retryPending: () => retryOperation.current ? exclusive(retryOperation.current) : Promise.resolve(null), prepareApply, recoveries, resolveRecovery, ensure, exportDocuments, dispatch, nodeAction, nodesChanged, edgesChanged, patchEdge, select, flush, command, refresh, read, busy: busy || pendingCommand || migrationHeld, error, status, retry, undo: () => undoAction(), redo: () => undoAction(true), canUndo: !memberJobs.length && !!undo.current.length, canRedo: !memberJobs.length && !!redo.current.length, writers, rootDocument: documents.get(root)!, setError };
}
+20 -1
View File
@@ -1,28 +1,42 @@
/**
* 旧编辑器的内存上传任务管理。原文件、Blob预览URL和AbortController随Hook挂载存活,资源上传成功后由commit绑定到本地节点。
* 这是可取消的页面内流程,没有持久文件仓库;卸载会中止上传并清理URL,不能承诺跨刷新继续上传。
*/
"use client";
import { useEffect, useRef, useState } from "react";
import { api, errorText } from "@/lib/api";
import { uploadCanvasImage, type CanvasAsset } from "@/lib/canvas-assets";
import type { CanvasDocument, CanvasNode } from "@/lib/canvas";
/** draft指定节点归属与可选关联边,调用方负责先创建可编辑占位并正确处理提交时归属。 */
export type ImageUploadDraft = { owner: string; node: CanvasNode; edge?: CanvasDocument["edges"][number] };
/** file保存原字节、localURL用于即时预览、key用于同任务重试幂等;ready可等待本地图片尺寸解码。 */
export type ImageUploadJob = ImageUploadDraft & { file: File; localURL: string; key: string; status: "uploading" | "failed" | "uploaded"; error?: string; controller: AbortController; ready?: Promise<void>; asset?: CanvasAsset };
/** Uploads attach file references to existing editable local nodes; transient URLs never enter autosaves. */
/** commit通过ref读取最新回调;pending按节点ID存任务,jobs数组只作React显示发布。 */
export function useImageUploads(workspace: string, commit: (draft: ImageUploadDraft, asset: CanvasAsset) => void) {
const pending = useRef(new Map<string, ImageUploadJob>());
const [jobs, setJobs] = useState<ImageUploadJob[]>([]);
const mounted = useRef(true), onCommit = useRef(commit); onCommit.current = commit;
/** 挂载期间发布新的任务数组;任务对象仍是同一引用,字段更新后需调用changed触发显示。 */
const changed = () => { if (mounted.current) setJobs([...pending.current.values()]); };
/** 中止任务、释放预览URL并移出Map;并不在这里删除文档节点或服务端资源。 */
function remove(id: string) { const job = pending.current.get(id); if (!job) return; job.controller.abort(); URL.revokeObjectURL(job.localURL); pending.current.delete(id); changed(); }
// 卸载统一abort并回收URL,清Map防止迟到上传结果提交;StrictMode重新挂载effect时复位mounted。
useEffect(() => { mounted.current = true; return () => { mounted.current = false; for (const job of pending.current.values()) { job.controller.abort(); URL.revokeObjectURL(job.localURL); } pending.current.clear(); }; }, []);
/** uploading状态防并发重试;开始新尝试前换controller并清错误,保留原幂等key。
* 上传与ready并行,二者都成功后才commit;commit抛错也会进入failed状态,允许再次处理。 */
async function run(job: ImageUploadJob) {
if (job.status === "uploading") return;
job.status = "uploading"; job.error = undefined; job.controller = new AbortController(); changed();
try {
const [asset] = await Promise.all([uploadCanvasImage(api, workspace, job.file, job.file.name, job.controller.signal, job.key), job.ready || Promise.resolve()]);
// 结果返回后核对挂载、Map内仍是同一个job且未取消,旧任务不能写到后来同ID的新任务。
if (!mounted.current || pending.current.get(job.node.id) !== job || job.controller.signal.aborted) return;
job.asset = asset; onCommit.current(job, asset); job.status = "uploaded"; changed();
} catch (error) { if (mounted.current && pending.current.get(job.node.id) === job && !job.controller.signal.aborted) { job.status = "failed"; job.error = errorText(error); changed(); } }
}
/** 先创建Blob URL并让调用方建立草稿,草稿构造失败立即释放URL;任务初始failed以便run统一转uploading。 */
function start(file: File, draft: (localURL: string) => ImageUploadDraft) {
const localURL = URL.createObjectURL(file);
let value: ImageUploadDraft;
@@ -30,18 +44,23 @@ export function useImageUploads(workspace: string, commit: (draft: ImageUploadDr
const job: ImageUploadJob = { ...value, file, localURL, key: crypto.randomUUID(), status: "failed", controller: new AbortController() };
pending.current.set(job.node.id, job); changed();
// Browser decoding runs locally, concurrently with the original-byte upload.
// 纯图片并行解码自然尺寸,限制初始长宽不超过480(不主动放大),再分别设置至少24px的显示尺寸。
if (job.node.data.kind === "plain-image") {
job.ready = new Promise<void>((resolve,reject) => {
const image = new window.Image(); image.onload = () => {
if (pending.current.get(job.node.id) !== job) { resolve(); return; }
// 分别钳制到最小24可能改变极端长宽比;这里只初始化节点和style尺寸,不重编码上传文件。
const scale = Math.min(1,480/image.naturalWidth,480/image.naturalHeight);
const size = { width: Math.max(24,Math.round(image.naturalWidth*scale)), height: Math.max(24,Math.round(image.naturalHeight*scale)) };
job.node = { ...job.node, ...size, style: { ...job.node.style, ...size } }; changed(); resolve();
}; image.onerror = () => reject(new Error("无法读取图片尺寸。")); image.src = localURL;
});
}
// start同步返回任务对象,异步上传由run内部捕获失败,不要求调用方等待才显示占位。
void run(job);
return job;
}
return { jobs, active: jobs.some((job) => job.status === "uploading"), start, retry: (id: string) => { const job = pending.current.get(id); if (job) void run(job); }, remove, loaded: (id: string) => { if (pending.current.get(id)?.status === "uploaded") remove(id); } };
// hasPending包含失败但不包含uploaded,active只看uploading;loaded仅在正式图片加载后移除已上传预览。
// retry复用job及幂等键;若ready曾拒绝,重试不会在这里重建尺寸解码Promise。
return { jobs, hasPending:()=>[...pending.current.values()].some(job=>job.status!=="uploaded"), active: jobs.some((job) => job.status === "uploading"), start, retry: (id: string) => { const job = pending.current.get(id); if (job) void run(job); }, remove, loaded: (id: string) => { if (pending.current.get(id)?.status === "uploaded") remove(id); } };
}
+6
View File
@@ -1,3 +1,9 @@
/**
* 按既有静态资源命名约定展示仪表盘图标的小封装,不负责画布图标库的分片加载或搜索。
*/
/** name是调用方提供的资源后缀,拼成/assets/dashboardcontext-imgIcon{name}.svg。
* size同时设置宽高,默认18px;空alt表示装饰用途,承载动作的外层控件须另有可访问名称。
* 此处没有资源白名单校验或加载失败占位,调用方应使用项目已有的图标名。 */
export function Icon({ name, size = 18 }: { name: string; size?: number }) {
return (
<img
+14
View File
@@ -1,3 +1,7 @@
/**
* 公开落地页的客户端展示组件。包含品牌导航、可点击的创作示意图、功能说明、工作方式、FAQ与登录/演示入口。
* 示意卡片只改变本页高亮,不创建真实画布元素;本模块不读取账号、不调用编辑器,也不持久化状态。
*/
"use client";
import { useState } from "react";
@@ -6,7 +10,9 @@ import { ArrowUpRight, ArrowRight, ChevronDown, FileText, Image as ImageIcon, La
import { Brand } from "@/components/brand";
import styles from "./landing-page.module.css";
/** 三个示意卡片对应的页脚说明;数组索引与active及预览按钮一一对应。 */
const cards = ["先留住一个念头", "让不同的线索相遇", "慢慢成为你的作品"];
/** FAQ静态问答数据;这里只呈现文案,访问权限与演示存储的实际执行在对应业务模块中。 */
const questions = [
["还没想好要做什么,也可以开始吗?", "当然。一个词、一张参考图、一段还不成形的想法,都可以成为起点。先放下来,再慢慢找到它们之间的联系。"],
["我可以放些什么在画布里?", "文字、笔记、图片、脚本和图标。你可以自由移动、调整样式、用线连接,也可以用分区把相关内容放在一起。"],
@@ -14,9 +20,17 @@ const questions = [
["可以先体验,再注册吗?", "可以。点击「先逛逛工作台」进入本地演示。演示内容保留在当前浏览器,登录后可以建立自己的工作空间。"],
];
/** 本页只有两个交互状态:预览卡片单选,以及最多展开一个问题的FAQ。
* 导航锚点定位本页三个section;登录入口进入/login,本地演示入口携带demo=1进入工作台。
* 主体的SVG连线、花朵、光标和功能插图是展示图形,不接入画布命中、拖动或同步链路。 */
export default function LandingPage() {
// 初始选中第一张预览卡;三张卡片与底部三个圆点均直接设置同一个索引,aria-pressed同步表达选择。
const [active, setActive] = useState(0);
// null表示全部收起;点击当前问题置null,点击其它问题改索引。hidden与aria-expanded/controls对应同一状态。
const [faq, setFaq] = useState<number | null>(null);
// 页面顺序:导航→首屏交互预览→理念横条→三个功能说明→工作步骤→FAQ→行动入口→页脚。
// 功能卡与步骤通过静态内容展示;FAQ以问题文本作key,步骤以序号作key,预览以说明作key。
// 状态仅在本次组件挂载期间保留;切换预览不自动轮播,FAQ展开不发网络请求。
return <div className="landing"><div className={styles.page}>
<header className={styles.nav}>
<Brand />
+8
View File
@@ -1,11 +1,19 @@
/**
* 应用级反馈设施:提供共享Tooltip时间参数与通知挂载点。
* 这个模块只负责提示的呈现环境,任务进度、错误事实和通知触发仍由业务调用方管理。
*/
"use client";
import { createContext, type ReactNode } from "react";
import * as Tooltip from "@radix-ui/react-tooltip";
import { Toaster } from "sonner";
/** 默认false使独立挂载的IconButton退回原生title;只有AppFeedback子树启用统一浮层提示。 */
export const FeedbackContext = createContext(false);
/** 包住页面一次即可共享提示延迟;400ms首次出现,200ms内切换提示可跳过等待。
* Toaster放在右下角,最多展示三条通知,默认4500ms消失并允许关闭;颜色读取主题变量。
* children正常透传,本组件不订阅文档、队列或服务端状态。 */
export function AppFeedback({ children }: { children: ReactNode }) {
return <FeedbackContext.Provider value={true}>
<Tooltip.Provider delayDuration={400} skipDelayDuration={200}>
+11
View File
@@ -1,9 +1,15 @@
/**
* 通用按钮的样式与属性适配层。variant表达视觉语义,size控制尺寸;点击、禁用和表单行为由调用方传入。
* 可通过asChild复用子元素,避免链接外再套按钮;这里不保存业务状态,也不默认指定button的type。
*/
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
/** 将基础交互样式、语义变体和尺寸变体组合成className;未指定时使用default/default。
* focus-visible提供键盘焦点反馈,disabled类限制指针交互并淡化外观,内嵌SVG统一尺寸。 */
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
{
@@ -34,6 +40,7 @@ const buttonVariants = cva(
},
);
/** 在原生button属性上增加样式变体和asChild;ref仍按按钮声明,实际宿主由asChild决定。 */
export interface ButtonProps
extends
React.ButtonHTMLAttributes<HTMLButtonElement>,
@@ -41,8 +48,11 @@ export interface ButtonProps
asChild?: boolean;
}
/** 转发ref和剩余原生属性,供调用方设置事件、可访问名称、disabled和表单属性。
* Slot把属性合并到唯一子元素;使用链接等宿主时,其语义及行为由该子元素承担。 */
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
// 只选择宿主,不另包一层DOM;cn合并变体和调用方样式,业务逻辑不进入这个封装。
const Comp = asChild ? Slot : "button";
return (
<Comp
@@ -53,6 +63,7 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
);
},
);
// forwardRef生成包装组件后补显示名,便于React开发工具定位。
Button.displayName = "Button";
export { Button, buttonVariants };
+16
View File
@@ -1,3 +1,7 @@
/**
* 基于Radix的对话框展示封装:保留其开关、焦点管理和可访问语义,补项目统一的遮罩、布局与动画。
* 调用方通过Root的open/onOpenChange控制业务流程;保存中是否允许关闭等规则不由本模块决定。
*/
"use client";
import * as React from "react";
@@ -6,14 +10,19 @@ import { X } from "lucide-react";
import { cn } from "@/lib/utils";
/** Root提供受控或非受控开关上下文,本层不再复制一份open状态。 */
const Dialog = DialogPrimitive.Root;
/** Trigger接入同一Root,负责触发打开;可沿用Radix的asChild组合调用方控件。 */
const DialogTrigger = DialogPrimitive.Trigger;
/** Portal把浮层移出页面普通布局,避免受父容器裁切;挂载方式沿用Radix。 */
const DialogPortal = DialogPrimitive.Portal;
/** 暴露关闭原语,供业务在底部动作区等位置放额外关闭入口。 */
const DialogClose = DialogPrimitive.Close;
/** 转发遮罩ref与属性,铺满视口;开关动画由Radix的data-state驱动,className允许调用方补样式。 */
const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
@@ -29,6 +38,9 @@ const DialogOverlay = React.forwardRef<
));
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
/** 组合Portal、遮罩与居中内容面板,并在children之后固定附带右上角关闭按钮。
* 面板ref和剩余属性交给Radix Content,保留焦点及外部交互事件;关闭按钮有读屏文字。
* 本封装没有单独隐藏默认关闭按钮的参数,业务若要阻止关闭应在受控开关/事件层处理。 */
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
@@ -53,6 +65,7 @@ const DialogContent = React.forwardRef<
));
DialogContent.displayName = DialogPrimitive.Content.displayName;
/** 标题区只组织纵向间距与响应式文字对齐;原生div属性和children原样透传。 */
const DialogHeader = ({
className,
...props
@@ -67,6 +80,7 @@ const DialogHeader = ({
);
DialogHeader.displayName = "DialogHeader";
/** 动作区在窄屏使用反向纵排,在sm及以上改为靠右横排;不绑定提交或取消动作。 */
const DialogFooter = ({
className,
...props
@@ -81,6 +95,7 @@ const DialogFooter = ({
);
DialogFooter.displayName = "DialogFooter";
/** 转发Radix标题的ref/属性并统一字号;标题仍参与对话框的可访问命名关联。 */
const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
@@ -96,6 +111,7 @@ const DialogTitle = React.forwardRef<
));
DialogTitle.displayName = DialogPrimitive.Title.displayName;
/** 转发Radix说明文字的ref/属性并使用弱化文字样式;说明关联由Radix上下文维护。 */
const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
+9
View File
@@ -1,3 +1,7 @@
/**
* 画布等工具栏使用的图标按钮。统一按钮尺寸、图标与可访问名称,并按反馈Provider决定使用浮层提示还是原生title。
* 按钮的业务动作由onClick等原生属性传入;这里不维护工具选择状态。
*/
"use client";
import { FeedbackContext } from "./app-feedback";
@@ -6,13 +10,17 @@ import { useContext, forwardRef, type ButtonHTMLAttributes } from "react";
import type { LucideIcon } from "lucide-react";
import styles from "./icon-button.module.css";
/** icon提供Lucide图标实现,label提供默认可访问名称与提示;不接受任意children替换内部图标。 */
export type IconButtonProps = Omit<ButtonHTMLAttributes<HTMLButtonElement>, "children"> & {
icon: LucideIcon;
label: string;
};
/** 将ref交给真实button;type默认为button,避免放进表单时意外提交。
* 调用方仍可传aria-pressed、disabled等状态,组件不会自行判断命令是否可用。 */
export const IconButton = forwardRef<HTMLButtonElement, IconButtonProps>(
function IconButton({ icon: Icon, label, title = label, className = "", type = "button", ...props }, ref) {
// Provider存在时由Radix显示提示,移除原生title以免出现两层提示;图标自身对读屏隐藏。
const feedback = useContext(FeedbackContext);
const button = (
<button
@@ -26,6 +34,7 @@ export const IconButton = forwardRef<HTMLButtonElement, IconButtonProps>(
<Icon size={16} strokeWidth={1.6} aria-hidden="true" />
</button>
);
// 没有全局提示环境就返回原生按钮;空title也不创建Tooltip。提示通过Portal避免被工具栏裁切。
if (!feedback || !title) return button;
return <Tooltip.Root><Tooltip.Trigger asChild>{button}</Tooltip.Trigger><Tooltip.Portal>
<Tooltip.Content className={styles.tooltip} sideOffset={7} collisionPadding={8}>
@@ -1,3 +1,7 @@
/**
* 账户资料编辑与新建工作区共用的客户端对话框。保存后通过回调更新宿主目录/资料,再关闭;
* demo分支只构造本地结果,真实分支走profile PATCH或workspaces POST,不由组件直接操作canvas runtime。
*/
"use client";
import { useState, type FormEvent } from "react";
@@ -7,6 +11,7 @@ import type { Workspace } from "@/lib/workspace";
import { UserAvatar } from "./user-avatar";
import styles from "./workspace.module.css";
/** mode决定表单,busy控制当前提交和退出,error用于就地反馈;输入默认值来自打开时profile。 */
export function AccountPanel({ mode, profile, demo, onClose, onProfile, onWorkspace }: {
mode: "settings" | "workspace";
profile: Profile;
@@ -20,6 +25,8 @@ export function AccountPanel({ mode, profile, demo, onClose, onProfile, onWorksp
const settings = mode === "settings";
const name = profile.display_name || profile.username;
/** 阻止原生导航,捕获FormData后提交;成功先通知宿主再onClose,失败显示错误并恢复按钮。
* HTML输入限制是交互提示,服务端仍校验真实资料/空间名称。 */
async function submit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (busy) return;
@@ -27,6 +34,7 @@ export function AccountPanel({ mode, profile, demo, onClose, onProfile, onWorksp
setBusy(true);
setError("");
try {
// 设置模式只提交display_name/username,避免将整个profile(含只读信息)作为修改payload。
if (settings) {
const input = { display_name: String(data.get("display_name")).trim(), username: String(data.get("username")).trim() };
const updated = demo ? { ...profile, ...input } : await api<Profile>("profile", { method: "PATCH", body: JSON.stringify(input) });
@@ -41,6 +49,7 @@ export function AccountPanel({ mode, profile, demo, onClose, onProfile, onWorksp
finally { setBusy(false); }
}
// 保存中禁用取消/提交,并拒绝Dialog的关闭状态变化;请求未结束不假装已成功关闭。
return (
<Dialog open onOpenChange={(open) => { if (!open && !busy) onClose(); }}>
<DialogContent className={styles.settingsPanel}>
@@ -1,20 +1,28 @@
/**
* 侧栏与画布页面共享的当前board元数据Context。只保存ID、工作区、路由引用与标题,
* 不保存元素、加载任务或ProjectEngine;目录改名可更新标题而无需重建业务画布。
*/
"use client";
import { createContext, useContext, useMemo, useState, type Dispatch, type ReactNode, type SetStateAction } from "react";
import type { Board } from "@/lib/workspace";
/** null代表当前没有已解析画布,workspace_ref保留路径slug/引用与真实workspace_id的对应关系。 */
type ActiveBoard = (Pick<Board, "id" | "workspace_id" | "title" | "slug"> & { workspace_ref?: string }) | null;
/** 暴露React状态setter,调用方可条件更新以免迟到改名覆盖另一个活动board。 */
const ActiveBoardContext = createContext<{
board: ActiveBoard;
setBoard: Dispatch<SetStateAction<ActiveBoard>>;
} | null>(null);
/** 在工作台布局内保持元数据,useMemo稳定Context对象,避免只因父渲染就通知全部使用方。 */
export function ActiveBoardProvider({ children }: { children: ReactNode }) {
const [board, setBoard] = useState<ActiveBoard>(null);
const value = useMemo(() => ({ board, setBoard }), [board]);
return <ActiveBoardContext.Provider value={value}>{children}</ActiveBoardContext.Provider>;
}
/** 缺少Provider时拒绝读取;不从浏览器路径凭空构造一条已认证board记录。 */
export function useActiveBoard() {
const context = useContext(ActiveBoardContext);
if (!context) throw new Error("useActiveBoard requires ActiveBoardProvider");
@@ -1,3 +1,7 @@
/**
* 侧栏画布树显示组件,使用业务目录库派生结构/搜索路径,只保存哪些目录项被用户折叠。
* 不重写board父子归属;活动路径和搜索可临时展开祖先,路由Link负责进入真实画布页面。
*/
"use client";
import { useEffect, useMemo, useState } from "react";
import Link from "next/link";
@@ -6,13 +10,16 @@ import { activeBoardAncestors, boardDirectory, filterBoardDirectory, type BoardB
import { boardHref, matchesRef, type Board } from "@/lib/workspace";
import styles from "./workspace.module.css";
/** branches只随boards/query重算,closed是当前挂载视图局部状态,宿主切工作区可用key重建。 */
export function BoardDirectory({ boards, workspace, active, query, demo }: { boards: Board[]; workspace: string; active?: string; query: string; demo: boolean }) {
const [closed, setClosed] = useState(new Set<string>());
const branches = useMemo(() => filterBoardDirectory(boardDirectory(boards), query), [boards, query]);
/** 活动画布或目录刷新时展开其祖先路径,防当前页藏在折叠树下。 */
useEffect(() => {
const path = activeBoardAncestors(boards, active);
setClosed((previous) => { const next = new Set(previous); for (const id of path) next.delete(id); return next; });
}, [active, boards]);
/** 递归渲染树,视觉缩进最多8层但不裁掉更深子项;搜索时强制展开匹配路径而不清空用户closed记录。 */
function render(items: BoardBranch[], depth = 0) {
return <ul className={styles.directoryList}>{items.map(({ board, children }) => {
const expanded = !!query.trim() || !closed.has(board.id);
@@ -22,5 +29,6 @@ export function BoardDirectory({ boards, workspace, active, query, demo }: { boa
</div>{expanded && children.length > 0 && render(children, depth + 1)}</li>;
})}</ul>;
}
// 有结果显示树;仅搜索无命中给提示,原始目录空时不伪造一条默认页面。
return branches.length ? render(branches) : query ? <p className={styles.hint}>没有找到相关页面</p> : null;
}
@@ -0,0 +1,64 @@
/**
* 应用级Canvas运行时装配点。一次安装模型registry与React renderer目录,按账户/工作区创建runtime,
* 按项目root安装Foundry服务适配。组件只装配依赖,业务根、history、ledger和持久化仍由下层实例管理。
* 真实宿主卸载才关闭项目;单个画布页面切换只借还视图,不在这里每次重建engine。
*/
"use client";
import { createContext, useContext, useEffect, useRef, useState, type ReactNode } from "react";
import { defaultCanvasRenderers } from "@/components/canvas/canvas-renderers";
import { createReactRenderers, type CanvasRuntimeExtension } from "@/lib/canvas-bindings/react-flow";
import { notify } from "@/lib/notifications";
import { errorText } from "@/lib/api";
import { createRegistry, builtinComponents } from "@/lib/canvas-core/index";
import { createRuntimeHost, createWorkspaceRuntime, type RuntimeHost } from "@/lib/canvas-runtime/index";
import { createBrowserDigests, createBrowserImageDecoder, createBrowserStorage } from "@/lib/canvas-runtime/browser";
import { createFoundryAssets, createFoundryPersistence, createFoundryTransport, createFoundryRuns,createFoundryModelLoader } from "@/lib/canvas-service/index";
/** 应用共享RuntimeHost引用,未装配时为null;renderer Context另有内置显示目录默认值。 */
const Context = createContext<RuntimeHost | null>(null);
/** 模型定义与显示实现一起在宿主初始化捕获,子页面用此目录保持一致解释。 */
const Renderers=createContext(defaultCanvasRenderers);
/** 读取宿主安装的React renderer集合,不为每个节点重新构造类型表。 */
export function useCanvasRenderers(){return useContext(Renderers);}
/** extensions仅在useState惰性初始化时捕获;后续prop换引用不会热替换已打开项目的schema/renderer。 */
export function CanvasRuntimeHost({ children,extensions=[] }: { children: ReactNode;extensions?:readonly CanvasRuntimeExtension[] }) {
const [{host,renderers}] = useState(() => {
// 先组合内置与扩展模型,再按精确版本/结构验证renderer,冲突在打开项目之前失败。
const registry = createRegistry([...builtinComponents(),...extensions.flatMap(extension=>extension.components??[])]),renderers=createReactRenderers([...defaultCanvasRenderers.definitions,...extensions.flatMap(extension=>extension.renderers??[])],registry);
const host=createRuntimeHost({ registry,
/** 每身份作用域绑定transport,current护栏由host提供,流读取也要防换账户后的迟到结果。 */
modelLoader(accountId,workspaceId,_authEpoch,current){return createFoundryModelLoader({workspaceId,registry,transport:createFoundryTransport({accountId,isCurrentIdentity:current})});},
/** 工作区持有浏览器存储、摘要/解码池;root项目共享其身份范围,各自再装配业务端口。 */
workspace(accountId, workspaceId, authEpoch, isCurrentIdentity) {
return createWorkspaceRuntime({ accountId, workspaceId, authEpoch, isCurrentIdentity, registry, storage: createBrowserStorage(),digests:createBrowserDigests(),images:createBrowserImageDecoder(),
/** 一项目一组协议适配器;summary策略只压缩已协调回执,不改变网络请求或原始操作身份。 */
adapters(rootId, current) {
const transport = createFoundryTransport({ accountId, isCurrentIdentity: current });
return { runs: createFoundryRuns({ workspaceId, rootId, registry, transport }), persistence: createFoundryPersistence({ workspaceId, rootId, registry, transport, resolvedReceiptPolicy:'summary' }), assets: createFoundryAssets({ workspaceId, transport, project: process.env.NEXT_PUBLIC_FOUNDRY_PROJECT || "endoleap", origin: process.env.NEXT_PUBLIC_FOUNDRY_API_ORIGIN }) };
},
// 同工作区错误用稳定通知id更新,不创建另一份任务状态;错误恢复仍须runtime明确操作。
onError: error => notify({ id: `canvas-runtime:${accountId}:${workspaceId}`, title: "画布同步需要处理", description: errorText(error), level: "error" }),
});
},
onError: error => notify({ id: "canvas-host-close", title: "本地修改尚未保存成功", description: errorText(error), level: "error" }),
});
return {host,renderers};
});
// effect重放的代次令牌只保护宿主清理,不是账户authEpoch,也不进入持久模型。
const lifetime = useRef(0);
/** cleanup延到微任务检查代次:Strict Mode同步重设effect会递增代次,旧cleanup不关闭新挂载宿主。
* 真正卸载才发起host.close,保存失败由宿主错误回调/日志报告,不在此丢弃根或强行释放leader。 */
useEffect(() => {
const generation = ++lifetime.current;
// Strict Mode replays effects synchronously; only a real detach retires the host.
return () => { queueMicrotask(() => { if (lifetime.current === generation) void host.close().catch(error => console.error("[canvas-host]", error.message)); }); };
}, [host]);
return <Context.Provider value={host}><Renderers.Provider value={renderers}>{children}</Renderers.Provider></Context.Provider>;
}
/** 需要runtime的页面必须在根Provider内,缺失装配明确抛错而不偷建一个临时host。 */
export function useCanvasRuntimeHost() {
const host = useContext(Context);
if (!host) throw new Error("CanvasRuntimeHost is missing");
return host;
}
@@ -1,8 +1,13 @@
/**
* 工作台标题的局部交互状态机:idle→selected→editing→saving,支持二次点击/F2编辑、Enter/失焦保存、Escape取消。
* 只管理草稿、焦点和提示,真正改名由异步onSave负责;组件本身不修改目录或画布文档。
*/
"use client";
import { useEffect, useRef, useState } from "react";
import styles from "./editable-title.module.css";
/** editable控制能否进入编辑;title是宿主已采用值,draft只在一次编辑会话内使用,保存失败回editing重试。 */
export function EditableTitle({ title, editable, onSave, className = "" }: {
title: string;
editable: boolean;
@@ -15,11 +20,14 @@ export function EditableTitle({ title, editable, onSave, className = "" }: {
const shell = useRef<HTMLDivElement>(null);
const input = useRef<HTMLInputElement>(null);
const button = useRef<HTMLButtonElement>(null);
// ref防Enter和随后的blur重复调用;cancelled防Escape/已完成后的blur重新提交。
const pending = useRef(false);
const cancelled = useRef(false);
/** selected阶段才监听外侧pointerdown;离开此模式即退订,编辑/保存不由这条外侧点击逻辑擅自退出。 */
useEffect(() => {
if (mode !== "selected") return;
/** 只在实际DOM目标落在shell外时取消选择,标题内部点击保留进入编辑的机会。 */
const deselect = (event: PointerEvent) => {
if (event.target instanceof Node && !shell.current?.contains(event.target)) setMode("idle");
};
@@ -27,10 +35,12 @@ export function EditableTitle({ title, editable, onSave, className = "" }: {
return () => document.removeEventListener("pointerdown", deselect);
}, [mode]);
/** 进入editing后挂载的input才可聚焦/全选,不在渲染阶段直接访问未就绪DOM。 */
useEffect(() => {
if (mode === "editing") { input.current?.focus(); input.current?.select(); }
}, [mode]);
/** 每次从宿主最新title初始化草稿并清旧错误,不能沿用上次取消的输入。 */
function edit() {
if (!editable) return;
cancelled.current = false;
@@ -38,12 +48,15 @@ export function EditableTitle({ title, editable, onSave, className = "" }: {
setError("");
setMode("editing");
}
/** 标记取消以抑制blur保存,回selected并下一帧把焦点还给标题按钮。 */
function cancel() {
cancelled.current = true;
setError("");
setMode("selected");
requestAnimationFrame(() => button.current?.focus());
}
/** 同步ref挡重复提交,trim后校验非空及UTF-8≤300字节;同名不发请求。
* 成功回idle,失败留草稿/错误重编辑,finally释放pending允许下一次明确尝试。 */
async function save() {
if (pending.current || cancelled.current) return;
const name = draft.trim();
@@ -65,6 +78,7 @@ export function EditableTitle({ title, editable, onSave, className = "" }: {
setMode("editing");
} finally { pending.current = false; }
}
// saving保留input但只读;输入法组合不把Enter当提交,键盘事件停止冒泡避免触发画布快捷键。
return (
<div ref={shell} className={`${styles.shell} ${className}`}>
{mode === "editing" || mode === "saving" ? (
+80 -13
View File
@@ -1,30 +1,53 @@
/**
* 画布路由的客户端生命周期桥。把路径引用解析成board/profile,再选择新版engine、渐进预览或旧snapshot编辑器。
* 页面只持显示与导航状态,项目由RuntimeHost/WorkspaceRuntime拥有;卸载中断本页订阅,但已开始的项目加载可转后台完成。
*/
"use client";
import { useEffect, useState } from "react";
import { useEffect, useRef, useState } from "react";
import type {Viewport} from '@xyflow/react';
import type {LiveModelPreview} from '@/lib/canvas-runtime/model-preview';
import { useRouter, useSearchParams } from "next/navigation";
import { api, errorText } from "@/lib/api";
import { readDemoBoards } from "@/lib/demo-workspace";
import { matchesRef, type Board } from "@/lib/workspace";
import { loadBoardCanvas } from "@/lib/canvas-loader";
import { loadCanvasEntry } from "@/lib/canvas-entry";
import {createBrowserCanvasMigration,type LegacyMigrationContext} from "@/lib/browser-canvas-migration";
import {readCanvasMigration} from "@/lib/canvas-migration";
import type { ProjectEngine } from "@/lib/canvas-runtime/workspace";
import { useCanvasRuntimeHost, useCanvasRenderers } from "./canvas-runtime-host";
import type { CanvasSnapshot } from "@/lib/canvas-sync";
import styles from "./workspace.module.css";
import { useActiveBoard } from "./active-board";
import dynamic from "next/dynamic";
/** 旧编辑器只在浏览器加载,避免服务端执行依赖DOM的画布实现。 */
const GraphCanvas = dynamic(() => import("@/components/canvas/graph-canvas"), { ssr: false, loading: () => <p role="status">正在准备画布…</p> });
type Loaded = { board: Board; snapshot?: CanvasSnapshot; route: string };
/** 新版编辑器同样独立客户端bundle,模型选择由loadCanvasEntry结果决定,而非界面手动猜格式。 */
const HeadlessGraphCanvas = dynamic(() => import("@/components/canvas/headless-graph-canvas"), { ssr: false, loading: () => <p role="status">正在准备画布…</p> });
/** 只读渐进预览先出现,已知内容足够后可升级为partial engine,保持原视口。 */
const LoadingGraphCanvas=dynamic(()=>import("@/components/canvas/loading-graph-canvas"),{ssr:false});
/** route将结果绑定到这次路径;engine/snapshot/migration分别来自实际选择的协议路径。 */
type Loaded = { board: Board; snapshot?: CanvasSnapshot; route: string; engine?: ProjectEngine; migration?:LegacyMigrationContext };
/** 当前页面只借用host与renderer,retry触发重新读取;不在页面state复制业务文档或history。 */
export default function GraphPage({ workspaceId, boardId }: { workspaceId: string; boardId: string }) {
const renderers=useCanvasRenderers();
const demo = useSearchParams().get("demo") === "1";
const router = useRouter();
const runtimeHost = useCanvasRuntimeHost();
const route = workspaceId + ":" + boardId;
const { board: activeBoard, setBoard: setActiveBoard } = useActiveBoard();
const [loaded, setLoaded] = useState<Loaded | null>(null);
const [preview,setPreview]=useState<{board:Board;value:LiveModelPreview;route:string}|null>(null);
// 预览视口暂存于ref,切成可编辑视图时交作initialViewport;新路由重置,不当文档编辑保存。
const previewViewport=useRef<Viewport|undefined>(undefined);
const [error, setError] = useState("");
const [retry, setRetry] = useState(0);
const navigationGeneration=useRef(0);
/** 每条路由/重试新建读取生命周期,清旧显示;active、AbortController和host身份护栏共同隔离迟到结果。 */
useEffect(() => {
setLoaded(null); setError("");
// Fetch data while the much larger editor chunk loads.
void import("@/components/canvas/graph-canvas").catch(() => {});
setLoaded(null);setPreview(null);previewViewport.current=undefined; setError("");
// 演示目录走旧本地画布,不发canonical读取、不绑定真实账户项目。
if (demo) {
const item = readDemoBoards(workspaceId).find((board) => matchesRef(board, boardId));
if (item) setLoaded({ board: item, route });
@@ -33,20 +56,64 @@ export default function GraphPage({ workspaceId, boardId }: { workspaceId: strin
}
let active = true;
const controller = new AbortController();
loadBoardCanvas(api, workspaceId, boardId, controller.signal)
.then((result) => { if (active) setLoaded({ ...result, route }); })
// 保存开始时身份,并在profile解析后取得currentIdentity闭包,后续await返回均据此判断是否还能采用结果。
const startingIdentity=runtimeHost.identity();let currentIdentity:(()=>boolean)|undefined;
// latest合并待采用的预览,pumping串行openPartial/persist,防快流把多个partial打开交错到同一项目。
let latest:{value:LiveModelPreview;board:Board;accountId:string}|undefined,pumping:Promise<void>|undefined,partialEngine:ProjectEngine|undefined,partialRuntime:ReturnType<typeof runtimeHost.workspace>|undefined;
/** 后台任务失败仅在页面仍活跃时显示,离开页面不再更新本页error。 */
const reportBackground=(error:unknown)=>{if(active)setError(errorText(error));};
/** 已建立partial engine且身份仍有效时,交工作区继续完整读取;视图离开不等于关闭可恢复项目。 */
const completeInBackground=()=>{if(partialEngine?.loading.getState()&&currentIdentity?.())void partialRuntime?.resumeLoading(partialEngine.getSnapshot().document.projectId).catch(reportBackground);};
/** 串行采用最新预览,先openPartial再发布UI并保存;期间新预览覆盖latest等待槽而非并行改根。 */
function pump(){
if(pumping)return pumping;
const task=(async()=>{while(latest){const next=latest;latest=undefined;if(!currentIdentity?.())return;const runtime=runtimeHost.workspace(next.accountId,next.board.workspace_id);partialRuntime=runtime;const engine=await runtime.openPartial(next.value.document,next.value.versions,next.value.loading);partialEngine=engine;if(active&&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;
}
// 导航代次递增传入共享loader,查询身份与取消订阅由host/loader维护;入口优先canonical防旧writer误开。
loadCanvasEntry({ request: api, workspaceRef: workspaceId, boardRef: boardId, registry: runtimeHost.registry, signal: controller.signal,navigationGeneration:++navigationGeneration.current,
/** 只有本board已在前缀中才显示;有元素后推动partial engine,尚未建立engine时用只读预览。 */
onPreview:(value,board,profile)=>{if(active&&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);}}},
/** profile解析后绑定账户并读取模型;若开始后的authEpoch已变且当前账户不同于响应账户,拒绝旧导航认领。 */
readModel:input=>{const identity=runtimeHost.identity();if(identity.authEpoch!==startingIdentity.authEpoch&&identity.accountId!==input.accountId)throw new Error('identity_changed');currentIdentity=runtimeHost.bindAccount(input.accountId);return runtimeHost.loadModel(input.accountId,input.workspaceId,input.canvasId,{signal:input.signal,navigationGeneration:input.navigationGeneration,onPreview:input.onPreview});},
}).then(async result => {
if (!active||currentIdentity&&!currentIdentity()) return;
// 迁移完成回调只递增retry重开入口,不在旧组件内手工切换文档格式。
if (result.mode === 'legacy') {
const migration:LegacyMigrationContext={accountId:result.profile.id,rootId:result.migration?.rootId,registry:runtimeHost.registry,isCurrentIdentity:()=>!!currentIdentity?.(),onApplied:()=>{if(active&&currentIdentity?.())setRetry(value=>value+1);}};
setLoaded({board:result.board,snapshot:result.snapshot,route,migration});setPreview(null);return;
}
// 服务已返回新模型但本地仍有同根pending时,查询并收尾原迁移记录,不生成新迁移请求。
const pending=readCanvasMigration(localStorage,{accountId:result.profile.id,workspaceId:result.board.workspace_id});
if(pending?.phase==='pending'&&pending.rootId===result.model.document.projectId){
try{await createBrowserCanvasMigration({scope:{accountId:result.profile.id,workspaceId:result.board.workspace_id},registry:runtimeHost.registry,isCurrentIdentity:()=>active&&!!currentIdentity?.(),signal:controller.signal}).resume();}
catch(error){reportBackground(error);}
}
// 完整模型到来先等partial采用链结束,再open同一项目,避免尾部覆盖尚在发布的前缀根。
while(pumping)await pumping;
if(!active||!currentIdentity?.())return;
const engine = await runtimeHost.workspace(result.profile.id, result.board.workspace_id).open(result.model.document, result.model.versions,{sourceToken:result.model.sourceToken});
if (active&&(!currentIdentity||currentIdentity())){setLoaded({ board: result.board, engine, route });setPreview(null);}
})
.catch((error) => { if (active) { setError(errorText(error)); if (error.message === "invalid_session") router.replace("/login"); } });
return () => { active = false; controller.abort(); };
}, [workspaceId, boardId, route, demo, retry, router]);
// 清掉未采用预览并中断本页读取;已拥有的partial项目交后台,绝不在这里直接engine.close。
return () => { active = false;latest=undefined; controller.abort();completeInBackground(); };
}, [workspaceId, boardId, route, demo, retry, router, runtimeHost]);
// 即使effect清理/新读取尚未完成,只显示route匹配的结果,不短暂闪出上个board。
const current = loaded?.route === route ? loaded : null;
const board = current?.board;
const currentPreview=preview?.route===route?preview:null;
const board = current?.board??currentPreview?.board;
/** 发布当前board元数据供侧栏标题使用,卸载/换board归还该Context显示状态,不删除服务端board。 */
useEffect(() => {
if (board) setActiveBoard({ id: board.id, slug: board.slug, workspace_id: board.workspace_id, workspace_ref: workspaceId, title: board.title });
return () => setActiveBoard(null);
}, [board, workspaceId, setActiveBoard]);
// 本board在侧栏改名后优先读共享新标题;其它board的活动状态不能覆盖当前名称。
const displayBoard = board && activeBoard?.id === board.id && activeBoard.workspace_id === board.workspace_id ? { ...board, title: activeBoard.title } : board;
return <main className={styles.graph} aria-label={displayBoard ? displayBoard.title + " · 无限图谱" : "无限图谱"} aria-busy={!board && !error}>
{displayBoard ? <GraphCanvas key={displayBoard.workspace_id + ":" + displayBoard.id} board={displayBoard} snapshot={current?.snapshot} /> : <div className={styles.graphSurface} aria-label="正在读取画布" />}
// 显示优先级:engine→只读预览→旧snapshot→读取占位;重试错误提示与已显示前缀可并存。
return <main className={styles.graph} aria-label={displayBoard ? displayBoard.title + " · 无限图谱" : "无限图谱"} aria-busy={!current && !error}>
{displayBoard && current?.engine ? <HeadlessGraphCanvas key={displayBoard.workspace_id + ":" + displayBoard.id} board={displayBoard} engine={current.engine} renderers={renderers} initialViewport={previewViewport.current} /> : displayBoard&&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>;
}
+5
View File
@@ -1,8 +1,13 @@
/**
* 头像的轻量显示降级:指定URL加载失败时用名字首段的大写表示,新的URL可重新尝试。
* 失败标记仅属当前组件,不写用户资料,也不把一次图片失败当成账户失效。
*/
"use client";
import { useState } from "react";
import styles from "./workspace.module.css";
/** 同一失败URL不反复重载;large仅控制尺寸,图片不发送Referrer,alt保留用户身份文本。 */
export function UserAvatar({ name, url, large = false }: {
name: string;
url?: string | null;
@@ -94,7 +94,8 @@
.newWorkspace { color: #2383e2; }
.logout { color: #78746d; font-size: 12px; }
.separator { height: 1px; margin: 6px 7px; background: #eeece8; }
.floating { position: fixed; left: 12px; top: 12px; max-width: calc(100vw - 24px); z-index: 40; }
.floating { --floating-left: 12px; position: fixed; left: var(--floating-left); top: 12px; max-width: calc(100vw - var(--floating-left) - 12px); z-index: 40; transition: left 240ms cubic-bezier(0.22, 1, 0.36, 1), max-width 240ms cubic-bezier(0.22, 1, 0.36, 1); }
.floating[data-sidebar-collapsed="false"], .accountPopup[data-sidebar-collapsed="false"] { --floating-left: calc(min(var(--sidebar-width), calc(100vw - 40px)) + 12px); }
.floatingError { position: absolute; left: 0; top: 42px; width: 240px; padding: 10px; background: white; border: 1px solid #e7e2db; border-radius: 8px; color: #b14b3c; font-size: 12px; }
.settingsPanel { color: #37352f; border-color: #e6e2dc; border-radius: 12px; }
.settingsForm { display: flex; flex-direction: column; gap: 16px; padding-top: 4px; }
@@ -119,11 +120,17 @@
.floatingAvatarButton { display: grid; place-items: center; flex: 0 0 auto; box-sizing: border-box; width: var(--toolbar-control-width, 32px); height: var(--toolbar-control-height, 32px); border-radius: 5px; }
.floatingAvatarButton:hover { background: #e9e7e3; }
.accountPopup[data-floating="true"] { inset: 62px auto auto 12px; max-height: calc(100dvh - 76px); transform-origin: top left; }
.accountPopup[data-floating="true"] { inset: 62px auto auto clamp(8px, var(--floating-left, 12px), max(8px, calc(100vw - 308px))); max-height: calc(100dvh - 76px); transform-origin: top left; }
.floatingAvatarButton .avatar { flex-basis: var(--toolbar-icon-size, 20px); width: var(--toolbar-icon-size, 20px); height: var(--toolbar-icon-size, 20px); font-size: 14px; }
.floatingTitle { flex: 1 1 auto; min-width: 0; max-width: 240px; margin: 0; color: #37352f; font-size: 14px; line-height: var(--toolbar-control-height, 32px); font-weight: 500; overflow: visible; }
@media (max-width: 700px) { .floatingTitle { max-width: 180px; } }
@media (max-width: 420px) {
.floating[data-sidebar-collapsed="false"] .logoLink,
.floating[data-sidebar-collapsed="false"] .floatingTitle,
.floating[data-sidebar-collapsed="false"] > span[aria-hidden="true"] { display: none; }
}
@media (prefers-reduced-motion: reduce) { .floating { transition: none; } }
.directoryList { list-style: none; padding: 0; margin: 0; }
.directoryRow { display: flex; align-items: center; min-width: 0; }
+50
View File
@@ -1,3 +1,9 @@
/**
* 应用通用JSON/FormData请求与登录跳转辅助。请求默认经过同源Foundry代理,错误封装为ApiError,
* 界面通过errorText映射中文说明;业务schema和账户代次等更强校验由各领域适配器处理。
* 本函数本身不设置超时或自动重试,取消依赖传入signal,代理传输时限在对应路由/响应封装中管理。
*/
/** 账户资料响应的静态类型,不能用它代替登录会话有效性或权限校验。 */
export type Profile = {
id: string;
username: string;
@@ -5,20 +11,25 @@ export type Profile = {
email: string | null;
avatar_url?: string | null;
};
/** 保留HTTP状态、服务details和原Retry-After头,message使用稳定错误码,供上层决定显示/重试。 */
export class ApiError extends Error {
readonly status: number;
readonly details?: unknown;
readonly retryAfter?: string | null;
/** 以Error兼容方式封装服务错误,不把details自动拼进面向用户文案。 */
constructor(code: string, status: number, details?: unknown, retryAfter?: string | null) {
super(code); this.name = "ApiError"; this.status = status; this.details = details; this.retryAfter = retryAfter;
}
}
/** 泛型T只是调用方的响应形状声明,不执行运行时schema检查。
* 204返回null;其它响应必须可解析JSON,非2xx统一抛ApiError,成功body直接交业务层验证。 */
export async function api<T>(
path: string,
options: RequestInit = {},
): Promise<T> {
const res = await fetch("/api/foundry/" + path, {
...options,
// FormData让浏览器生成boundary,其它请求默认JSON;调用方headers可覆盖默认Content-Type。
headers: { ...(options.body instanceof FormData ? {} : { "Content-Type": "application/json" }), ...options.headers },
});
let body: unknown;
@@ -26,21 +37,27 @@ export async function api<T>(
else {
try { body = await res.json(); }
catch (error) {
// 消费body失败时优先传播明确取消原因;语法坏JSON与传输中断使用不同错误码。
if (options.signal?.aborted) throw options.signal.reason;
const malformed = error instanceof SyntaxError;
throw new ApiError(malformed ? "invalid_api_response" : "response_interrupted", res.ok ? 502 : res.status);
}
}
const payload = body as { error?: string | { code?: string }; details?: unknown } | null;
// 服务error兼容字符串或{code},无可用码则request_failed,并保留Retry-After原值供上层解释。
if (!res.ok) {
const code = typeof payload?.error === "object" ? payload.error?.code : payload?.error;
throw new ApiError(typeof code === "string" ? code : "request_failed", res.status, payload?.details, res.headers.get("retry-after"));
}
return body as T;
}
/** 公开项目标识,来自构建时NEXT_PUBLIC配置;不是密码或用户账户身份。 */
export const PROJECT = process.env.NEXT_PUBLIC_FOUNDRY_PROJECT || "endoleap";
/** 创建PKCE verifier和SHA-256 challenge:verifier留在当前标签页sessionStorage,
* 只把challenge带到同源OAuth入口,后续交换流程负责取回verifier。此处不直接取得令牌。 */
export async function startOAuth(provider: "google" | "slack") {
const bytes = crypto.getRandomValues(new Uint8Array(32));
// 32随机字节编码为无填充base64url,避免普通base64字符影响URL参数。
const verifier = btoa(String.fromCharCode(...bytes))
.replace(/\+/g, "-")
.replace(/\//g, "_")
@@ -53,12 +70,43 @@ export async function startOAuth(provider: "google" | "slack") {
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=/g, "");
// 页面跳转前保存原verifier;使用单一固定键,同标签页后一次发起会替换上一次值。
sessionStorage.setItem("foundry_verifier", verifier);
window.location.assign("/api/oauth/" + provider + "?challenge=" + challenge);
}
/** 应用错误码→用户文案适配,不改变任务状态或执行恢复;未知错误给通用提示,不直接泄漏内部异常文本。 */
export const errorText = (error: unknown) => {
const code = error instanceof Error ? error.message : "";
// 文案只解释既有领域状态;“保留/暂停”等实际行为须由触发错误的模块保证,不能由映射表证明。
const messages: Record<string, string> = {
invalid_model_stream: "画布返回的数据无法解析,请重新加载。",
incomplete_model_stream: "画布数据未加载完整,请重新加载。",
unsupported_model_stream: "当前画布加载格式暂不受支持,请更新后重试。",
model_stream_budget: "画布数据量超过当前加载上限,请联系支持。",
load_snapshot_changed: "加载期间画布快照发生变化,请重新加载。",
invalid_model_identity: "画布的元素身份清单不一致,请重新加载。",
loading_reference_incomplete: "相关引用尚未完整加载,暂时无法修改。",
invalid_model_coverage: "画布的加载范围信息不一致,请重新加载。",
loading_waiting_for_receipts: "正在确认已发送的修改,请稍后重试。",
invalid_loading_asset: "图片任务的加载范围记录不一致,已保留本地内容并停止同步。",
loading_asset_interpreter_unavailable: "此图片任务需要更新版本后才能恢复。",
asset_source_changed: "图片资源的地址规则发生变化,请使用原配置恢复后重试。",
invalid_model_topology: "画布的分区关系不一致,请重新加载。",
loading_scope_incomplete: "所需分区或上级引用尚未完整加载,请稍后重试。",
invalid_loading_import: "导入任务的加载范围记录不完整,任务已停止。",
loading_copy_scope_changed: "复制源的范围校验失败,请重新复制。",
loading_import_interpreter_unavailable: "此导入任务需要更新版本后才能恢复。",
model_loading: "画布尚在加载,请等待加载完成后编辑。",
identity_changed: "账户已变化,当前画布已停止同步,请重新打开。",
engine_closing: "画布正在关闭,本地修改会保留用于恢复。",
storage_scope_in_use: "这张图谱已在另一个标签页中打开,请先关闭那里的编辑器。",
persistent_storage_unavailable: "浏览器无法保存本地恢复记录,请检查存储权限后重试。",
workspace_close_failed: "本地修改尚未保存成功,请释放浏览器存储空间后重试退出。",
host_close_failed: "部分本地修改尚未保存成功,请释放浏览器存储空间后重试退出。",
resource_bytes_unavailable: "此图片的本地文件暂不可用,相关修改已保留,请重新选择图片。",
recovery_checksum_mismatch: "本地恢复记录校验失败,原记录已保留,请先备份再处理。",
recovery_journal_gap: "本地恢复记录不完整,原记录已保留,请先备份再处理。",
unsupported_component: "这个元素需要更新版本或安装对应功能后才能编辑。",
invalid_credentials: "邮箱或密码不正确,或邮箱尚未验证。",
rate_limited: "请求太频繁,请稍后再试。",
invalid_session: "登录已过期,请重新登录。",
@@ -72,6 +120,8 @@ export const errorText = (error: unknown) => {
invalid_canvas: "画布数据未通过服务端校验,请导出备份后检查节点内容。",
response_interrupted: "画布数据传输中断或超时,请重新加载;当前内容未被覆盖。",
invalid_api_response: "接口返回了不完整或无法解析的数据,请重新加载。",
asset_target_changed: "目标分区已变化,请重新选择添加位置。",
asset_authorization_failed: "暂时无法读取这张图片,请确认仍有原画布的访问权限。",
invalid_asset: "图片格式无法识别,请选择有效的 PNG、JPEG、WebP 或 GIF。",
asset_too_large: "图片超出上传限制,请选择小于 20 MiB 的文件。",
invalid_asset_response: "图片上传结果无效,画布内容未替换,请重试。",
+12
View File
@@ -1,7 +1,14 @@
/**
* 工作区画布目录的读取与展示树派生。完整分页包含子分区,随后用parent_board_id构建侧栏关系。
* 显示树可保守容纳孤儿/循环记录,不据此修改服务端owner,也不替代文档模型中的canvas树校验。
*/
import { matchesRef, type Board } from "./workspace.ts";
/** 页面注入的通用JSON请求能力,传入signal供导航/卸载取消。 */
type Request = <T>(path: string, options?: RequestInit) => Promise<T>;
/** Include children and finish pagination before building ownership paths. */
/** 分页取齐board后返回按ID去重的列表,后出现的同ID记录更新内容。
* next_offset必须严格前进且安全,seen防重复游标,不能只拿首页就推断某canvas没有父分区。 */
export async function loadBoardDirectory(request: Request, workspace: string, signal?: AbortSignal) {
const boards = new Map<string, Board>();
let offset = 0;
@@ -16,7 +23,10 @@ export async function loadBoardDirectory(request: Request, workspace: string, si
offset = page.next_offset;
}
}
/** 侧栏派生分支:保留原board记录引用,children是本次展示树容器。 */
export type BoardBranch = { board: Board; children: BoardBranch[] };
/** 每条记录沿父链检查循环;父缺失或成环时作为展示根,避免递归挂载成无限树。
* 同层顺序沿用输入列表,不在此另做排序或写回parent_board_id。 */
export function boardDirectory(boards: Board[]): BoardBranch[] {
const entries = new Map(boards.map((board) => [board.id, { board, children: [] as BoardBranch[] }]));
const roots: BoardBranch[] = [];
@@ -33,12 +43,14 @@ export function boardDirectory(boards: Board[]): BoardBranch[] {
}
return roots;
}
/** 按ID或slug找到当前项,包含自身向上收集祖先ID,重复ID即停,供侧栏自动展开。 */
export function activeBoardAncestors(boards: Board[], ref?: string) {
const result = new Set<string>();
let item = boards.find((board) => matchesRef(board, ref));
while (item && !result.has(item.id)) { result.add(item.id); item = boards.find((board) => board.id === item!.parent_board_id); }
return result;
}
/** 忽略首尾空白并做本地小写匹配;父标题命中保留整子树,否则保留命中后代及其路径祖先。 */
export function filterBoardDirectory(branches: BoardBranch[], query: string): BoardBranch[] {
const term = query.trim().toLocaleLowerCase();
if (!term) return branches;
+62
View File
@@ -0,0 +1,62 @@
/**
* 迁移协调器的浏览器装配层。旧编辑视图持共享Web Lock,迁移操作取同一键的独占锁,
* 配合本地pending记录阻止已发出迁移却结果未知时继续旧格式写入。锁协调本浏览器,服务端版本/幂等校验另行负责。
*/
import type {Registry} from './canvas-core/types.ts';
import {fault} from './canvas-core/values.ts';
import {createFoundryTransport} from './canvas-service/foundry.ts';
import {canvasMigrationStorageKey,createCanvasMigration,migrationBlocksCanvas,readCanvasMigration,type CanvasMigrationScope} from './canvas-migration.ts';
/** 页面提供身份、可迁移根和完成后的重开回调;rootId缺失不代表可任意推测迁移范围。 */
export type LegacyMigrationContext={accountId:string;rootId?:string;registry:Registry;isCurrentIdentity():boolean;onApplied():void};
/** 锁/Storage可注入测试,生产默认使用navigator.locks/localStorage;身份和signal都由宿主管理。 */
type BrowserScope={scope:CanvasMigrationScope;isCurrentIdentity():boolean;signal:AbortSignal;locks?:LockManager;storage?:Storage};
/** 同账户/工作区共享编辑锁键,与持久迁移记录同一作用域,跨页面形成读写互斥。 */
const lockKey=(scope:CanvasMigrationScope)=>canvasMigrationStorageKey(scope)+':editing';
/** 缺少Web Locks明确拒绝安全迁移装配,不用不可靠的计时器冒充独占锁。 */
function ports(options:BrowserScope){
const locks=options.locks??globalThis.navigator?.locks;
if(!locks)throw fault('migration_lock_unavailable');
return{locks,storage:options.storage??globalThis.localStorage};
}
/** 获取锁前后均检查身份/取消,旧页面不能借已等待的锁继续对新账户操作。 */
const check=(options:BrowserScope)=>{if(!options.isCurrentIdentity())throw fault('identity_changed');options.signal.throwIfAborted();};
/** Held for the entire lifetime of an editable legacy view, not just each PATCH. */
/** 为旧编辑视图整个生命周期借共享锁,release返回Promise确认锁请求已结束。
* ifAvailable不排长队;迁移占用或本canvas有未确认迁移时立即拒绝开放旧编辑。 */
export async function claimLegacyCanvasEditing(options:BrowserScope&{canvasId:string}){
check(options);const{locks,storage}=ports(options);
let resolve!:(value:{release():Promise<void>})=>void,reject!:(error:unknown)=>void;
// ready在锁授予且本地pending检查通过后返回;request则在release/abort后才结束,两者用途不同。
const ready=new Promise<{release():Promise<void>}>((yes,no)=>{resolve=yes;reject=no;});
const request=locks.request(lockKey(options.scope),{mode:'shared',ifAvailable:true},async lock=>{
try{
check(options);if(!lock)throw fault('migration_busy');
if(migrationBlocksCanvas(readCanvasMigration(storage,options.scope),options.canvasId))throw fault('migration_response_unknown');
check(options);
// 回调等待released以保持共享锁;不能在第一次PATCH完成后就把编辑门解除。
let unlock!:()=>void;const released=new Promise<void>(yes=>{unlock=yes;});
/** 解开锁回调并等待平台释放;resolve可重复调用,已释放后的再次release仍等同一请求结束。 */
const release=async()=>{unlock();await request;};
/** 页面生命周期中止时释放锁,异步清理异常不制造未处理拒绝。 */
const abort=()=>{void release().catch(()=>{});};options.signal.addEventListener('abort',abort,{once:true});
resolve({release});
try{await released;}finally{options.signal.removeEventListener('abort',abort);}
}catch(error){reject(error);}
});
void request.catch(reject);return ready;
}
/** 连接localStorage、专属账户transport和UUID生成器;真正迁移在同一工作区独占锁内执行。 */
export function createBrowserCanvasMigration(options:BrowserScope&{registry:Registry}){
check(options);const{locks,storage}=ports(options);
return createCanvasMigration({scope:options.scope,registry:options.registry,storage,
transport:createFoundryTransport({accountId:options.scope.accountId,isCurrentIdentity:options.isCurrentIdentity}),
isCurrentIdentity:options.isCurrentIdentity,signal:options.signal,createId:()=>crypto.randomUUID(),
// 任一共享旧编辑页仍开着时独占锁不可用;调用方先保存/释放自己的编辑租约,再进入迁移。
lock:work=>locks.request(lockKey(options.scope),{mode:'exclusive',ifAvailable:true},async lock=>{
check(options);if(!lock)throw fault('migration_busy');return work();
}),
});
}
+11
View File
@@ -1,15 +1,26 @@
/**
* 旧画布的图片上传辅助,与新版canvas-service/assets的冻结资源协议分开保留。
* 同一次调用的重试沿用FormData和幂等键;这里只返回旧版素材记录,不维护可恢复上传账本。
*/
/** 由应用注入JSON/FormData请求端口,凭证与错误形状沿用宿主API封装。 */
type Request = <T>(path: string, options?: RequestInit) => Promise<T>;
/** 旧上传接口的素材响应形状,原件规范URL与尺寸由返回前校验。 */
export type CanvasAsset = { id: string; url: string; width: number; height: number; bytes: number; mime_type: string };
/** 上传原文件大小上限20MiB,空文件同样拒绝。 */
export const ASSET_LIMIT = 20 * 1024 * 1024;
/** 仅网络TypeError或5xx且未取消时追加一次同键重试;其它错误直接交给调用方。
* 调用方可提供key以跨尝试复用;默认key只在这次函数调用开始时生成,不是持久恢复记录。 */
export async function uploadCanvasImage(request: Request, workspace: string, file: Blob, name: string, signal?: AbortSignal, key = crypto.randomUUID()): Promise<CanvasAsset> {
if (!file.size || file.size > ASSET_LIMIT) throw new Error("asset_too_large");
const form = new FormData(); form.append("image", file, name);
/** 两次发送共用同一字节、文件名和key,避免丢响应后新建第二份素材。 */
const send = () => request<CanvasAsset>(`workspaces/${encodeURIComponent(workspace)}/assets`, { method: "POST", headers: { "Idempotency-Key": key }, body: form, signal });
let asset: CanvasAsset;
try { asset = await send(); } catch (error) {
if (signal?.aborted || !(error instanceof TypeError || typeof error === "object" && error && "status" in error && Number(error.status) >= 500)) throw error;
asset = await send(); // The same bytes/key retry a possibly committed upload.
}
// 成功HTTP不等于合法素材,继续核对ID、规范源站/路径及正有限尺寸,不接受任意远端URL。
if (!asset || typeof asset.url !== "string") throw new Error("invalid_asset_response");
let url: URL;
try { url = new URL(asset.url); } catch { throw new Error("invalid_asset_response"); }
+61
View File
@@ -0,0 +1,61 @@
/**
* 旧卡片 renderer 使用的结构化输入类型,与 headless 元素 schema 分离。
* 这些类型描述投影/显示约定,不能用类型断言替代 registry 对原始数据、版本和引用的运行时校验。
*/
/** 旧绘制路径使用的二维点;是否局部/场景由投影路径的调用约定决定。 */
type Point={x:number;y:number};
/** Compatibility renderer data only; never the authoritative element schema. */
/** 兼容卡片显示的 AI 配置,不是已受理生成任务或冻结请求。 */
export type AIConfig = { enabled?: boolean; modelId?: string; prompt?: string; propagate?: boolean; parameters?: Record<string, unknown> };
/** 兼容分区端口布局数据,连接到内部 node/handle 的关系由业务模型解释。 */
export type FramePort = { id: string; direction: "input" | "output"; nodeId: string; handle: string };
/** 展开/折叠时的显示尺寸与内容比例,投影时用于分区展示。 */
export type FramePresentation = { collapsed?: boolean; contentScale?: number; expandedSize?: { width: number; height: number }; collapsedSize?: { width: number; height: number } };
/** 内置旧卡片的类型枚举,不限制 registry 可扩展的 componentId 集合。 */
export type ElementKind = "text" | "image" | "script" | "plain-text" | "icon" | "frame" | "plain-image";
/** 分区标题的隐藏/固定大小/随缩放显示模式。 */
export type FrameLabelMode = "hidden" | "fixed" | "scaled";
/** 卡片正文的兼容排版配置,不含编辑器实例或 DOM 状态。 */
export type TextStyle = { color?: string; background?: string; bold?: boolean; italic?: boolean; underline?: boolean; align?: "left" | "center" | "right"; fontFamily?: "sans" | "serif" | "mono"; lineHeight?: number };
/** 显示视图与真实 owner 分开记录;id+incarnation 供命令拒绝旧代次目标。 */
export type RenderedTarget={readonly id:string;readonly incarnation:number;readonly ownerCanvasId:string;readonly canvasId:string};
/** 节点投影输入:正文/样式/资源及分区入口,不是另一份可直接写入的文档记录。 */
export type CanvasNodeData = {
__headlessTarget?:RenderedTarget;
kind: ElementKind;
title: string;
text: string;
src?: string;
rotation?: number;
fontSize?: number;
textStyle?: TextStyle;
icon?: string;
frameId?: string;
labelMode?: FrameLabelMode;
ai?: AIConfig;
assetId?: string;
subgraph?: { boardId: string };
ports?: FramePort[];
presentation?: FramePresentation;
viewScale?: number;
};
/** 旧边绘制器支持的端点标记形状。 */
export type MarkerShape = "none" | "open-arrow" | "triangle" | "circle" | "diamond" | "square";
/** 箭头/符号尺寸与填充;不等于连线的真实端口身份。 */
export type EdgeMarker = { type: MarkerShape; size?: number; filled?: boolean };
/** 贝塞尔链控制结点,包含两侧切线与编辑约束模式。 */
export type BezierKnot = { id: string; position: Point; in: Point; out: Point; mode: "corner" | "smooth" | "symmetric" };
/** 旧边外观、动画、已投影路径和执行样式;实际生成/执行事实由 runtime 持有。 */
export type EdgeData = {
appearance?: { color?: string; width?: number; opacity?: number; line?: "solid" | "dashed" | "dotted"; head?: EdgeMarker; tail?: EdgeMarker };
animation?: { mode?: "off" | "always" | "executing"; effect?: "flow" | "dash" | "pulse"; direction?: "forward" | "reverse"; durationMs?: number; color?: string };
path?: { type: "bezier-chain"; startControl?: Point; endControl?: Point; knots: BezierKnot[] };
execution?: { enabled?: boolean };
};
/** Geometry input consumed by the compatibility layout, independent of UI nodes. */
/** 仅供兼容锚点/布局计算的节点几何,measured 优先级由具体布局函数决定。 */
export type CanvasGeometryNode={position:Point;width?:number;height?:number;style?:{width?:unknown;height?:unknown};measured?:{width?:number;height?:number};data:{kind:string;rotation?:number;fontSize?:number;viewScale?:number}};
@@ -0,0 +1,68 @@
/**
* 连线超出原生端点包围范围的额外绘制索引。用曲线控制点凸包加半线宽保守包住笔画,
* 不要求求出三次曲线的精确极值。无法获取锚点/协议或耗尽预算时返回 unknown,宿主应保守保留显示。
*/
import type {Registry} from '../canvas-core/types.ts';
import type {projectCanvas} from './projection.ts';
import type {HitServices} from './hit-query.ts';
import {createPaintIndex,type PaintRegion} from './paint-index.ts';
import {equal} from '../canvas-core/values.ts';
import {compatConnector} from './connectors.ts';
/** Conservative cubic control hull + half stroke, in the displayed scene space.
* Missing/custom geometry stays unknown so a host cannot interpret it as invisible. */
/** 可选缓存的共享实现,限制本次边数和总曲线段数。
* 缓存命中仍扣减段预算,不让缓存历史悄悄放宽相同查询的工作量语义。 */
function connectionPaint(projection:ReturnType<typeof projectCanvas>,registry:Registry,services:HitServices={},maxEdges=4096,maxSegments=4096,cache?:Map<string,{signature:unknown;segments:number;region?:PaintRegion}>){
if(![maxEdges,maxSegments].every(n=>Number.isSafeInteger(n)&&n>0&&n<=1000000))throw Error('invalid_paint_budget');
const regions:PaintRegion[]=[],unknown:string[]=[],nodes=new Map<string,typeof projection.nodes[number]>(),endpoints=new Set<string>();
// Only inspected edges need node entries. Preserve last-ID-wins behavior without
// allocating an entry and pair array for every unrelated projected node.
// 仅为边预算内真正要检查的端点建节点表;仍遍历节点,但不为所有无关节点分配 Map 条目。
for(let i=0;i<Math.min(maxEdges,projection.edges.length);i++){const edge=projection.edges[i];endpoints.add(edge.source);endpoints.add(edge.target);}
if(endpoints.size)for(const node of projection.nodes)if(endpoints.has(node.id))nodes.set(node.id,node);
let remainingSegments=maxSegments,computed=0;
for(let index=0;index<projection.edges.length;index++){
// 超出边预算给全局 paint_budget,而非声称未检查的边没有额外范围。
if(index>=maxEdges){unknown.push('paint_budget');break;}const edge=projection.edges[index];
try{const record=projection.document.elements.get(edge.id),source=nodes.get(edge.source),target=nodes.get(edge.target);if(!record||record.structure!=='connection'||!source||!target)throw Error();
/** 优先用 renderer 锚点;原生 record-v1 不能猜旧卡片把手,缺测量即归 unknown。 */
const anchor=(node:typeof source,handle:string|null|undefined,end:'source'|'target')=>services.anchor?services.anchor(node,handle,end,edge):node.data.__headlessRecord?undefined:compatConnector(node,handle,end==='source'?'right':'left');
const from=anchor(source,edge.sourceHandle,'source'),to=anchor(target,edge.targetHandle,'target'),transform=projection.transforms.get(record.ownerCanvasId);if(!from||!to||!transform||![from.point,from.normal,to.point,to.normal].every(point=>point&&Number.isFinite(point.x)&&Number.isFinite(point.y))||![transform.x,transform.y,transform.scale,transform.rotation].every(Number.isFinite)||transform.scale<=0)throw Error();
const protocol=edge.type!=='unsupported'&&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++;
// renderer 服务优先,随后用组件几何协议;stroke 也必须来自同一显示语义。
const segments=services.edgeGeometry?.(edge,from,to)??(protocol?.kind==='connection'?protocol.segments({element:record,source:from,target:to,displayPath:edge.data?.__headlessRecord?undefined:edge.data?.path??null,transform}):undefined),stroke=services.strokeWidth?.(edge)??(protocol?.kind==='connection'?protocol.strokeWidth({element:record,transform}):undefined);
// 校验每边段数、线宽,再扣全局剩余段数;空段列表是已知无笔画,不应误标 unknown。
if(!segments||!Array.isArray(segments)||segments.length>maxSegments||typeof stroke!=='number'||!Number.isFinite(stroke)||stroke<0)throw Error();if(!segments.length){cache?.set(edge.id,{signature,segments:0});continue;}if(segments.length>remainingSegments){unknown.push('paint_budget');break;}remainingSegments-=segments.length;
let left=Infinity,top=Infinity,right=-Infinity,bottom=-Infinity;
// 三次曲线完全位于控制点凸包内,所有控制点的轴对齐包围盒提供保守范围。
for(const segment of segments){if(!Array.isArray(segment)||segment.length!==4)throw Error();for(const point of segment){if(!point||!Number.isFinite(point.x)||!Number.isFinite(point.y))throw Error();left=Math.min(left,point.x);top=Math.min(top,point.y);right=Math.max(right,point.x);bottom=Math.max(bottom,point.y);}}
// 向四侧扩半线宽,同时检查端点相加不溢出,否则当前边转 unknown。
const half=stroke/2,x=left-half,y=top-half,width=right-left+stroke,height=bottom-top+stroke;if(![x,y,width,height,x+width,y+height].every(Number.isFinite))throw Error();
// 原生范围由两端节点矩形并集包围;只有实际笔画包围盒超出它,才添加补充空间行。
const native={left:Math.min(source.position.x,target.position.x),top:Math.min(source.position.y,target.position.y),right:Math.max(source.position.x+source.width!,target.position.x+target.width!),bottom:Math.max(source.position.y+source.height!,target.position.y+target.height!)};
if(!Object.values(native).every(Number.isFinite))throw Error();const region=x<native.left||y<native.top||x+width>native.right||y+height>native.bottom?{id:edge.id,bounds:{x,y,width,height}}:undefined;if(region)regions.push(region);cache?.set(edge.id,{signature,segments:segments.length,region});
// 异常几何不保留过时缓存;单边失败隔离为该 ID 的 unknown,不影响其它已知范围。
}catch{cache?.delete(edge.id);unknown.push(edge.id);}
}
return{regions,unknown,computed};
}
/** 无缓存的单次计算入口,供调用方独立获取额外范围及未知原因。 */
export function projectedConnectionPaintOverflow(projection:ReturnType<typeof projectCanvas>,registry:Registry,services:HitServices={},maxEdges=4096,maxSegments=4096){return connectionPaint(projection,registry,services,maxEdges,maxSegments);}
/** Host revision invalidates geometry services whose external configuration changes. */
/** 视图级曲线范围缓存与网格。外部锚点/样式服务配置改变时,宿主必须同步推进 geometryRevision,
* 仅换闭包或修改服务内部状态不在元素签名中,不能指望缓存自行察觉。 */
export function createConnectionPaintIndex(){
const cache=new Map<string,{signature:unknown;segments:number;region?:PaintRegion}>(),index=createPaintIndex();let owner:Registry|undefined,project:string|undefined,canvas:string|undefined,revision:unknown,computed=0;
/** 同步当前场景;范围身份或 geometryRevision 变化整体失效,删除已移出的边缓存。
* query 与 unknown 配套使用;computed 是累计重新计算数,不把缓存命中记成一次曲线计算。 */
return Object.freeze({sync(projection:ReturnType<typeof projectCanvas>,registry:Registry,services:HitServices={},geometryRevision?:unknown,budget:{maxEdges?:number;maxSegments?:number}={}){
if(owner!==registry||project!==projection.document.projectId||canvas!==projection.canvasId||!Object.is(revision,geometryRevision)){cache.clear();index.clear();owner=registry;project=projection.document.projectId;canvas=projection.canvasId;revision=geometryRevision;}
const result=connectionPaint(projection,registry,services,budget.maxEdges??4096,budget.maxSegments??4096,cache),seen=new Set(projection.edges.map(edge=>edge.id));for(const id of cache.keys())if(!seen.has(id))cache.delete(id);index.sync(result.regions);computed+=result.computed;return Object.freeze({query:index.query,unknown:Object.freeze(result.unknown)});
},stats:()=>Object.freeze({computed,entries:cache.size}),clear(){cache.clear();index.clear();owner=undefined;project=undefined;canvas=undefined;revision=undefined;}});
}
+28
View File
@@ -0,0 +1,28 @@
/**
* 提供旧卡片兼容布局与原生测量布局的连线锚点。
* 两种协议不能混猜:compat-v1 依卡片边框/ports 排布,record-v1 应使用 renderer 实测 Handle。
*/
import type {CanvasGeometryNode} from './compat-types.ts';
import type {ProjectionNode} from './projection.ts';
import {nodeConnector,type Connector,type ConnectorSide} from '../canvas-geometry.ts';
/** 兼容几何节点加投影 data,保留 measured 尺寸和旧卡片端口声明。 */
type CompatNode=CanvasGeometryNode&{data:ProjectionNode['data']};
/** Same card-border and port layout used by CanvasEdge. Never infer it for record-v1 DOM. */
/** 方向优先取降级外壳提示,再取标准 handle 名、port 方向或调用方 fallback。
* 同方向多个 port 沿高度均匀分布,与兼容 CanvasEdge 使用同一布局。 */
export function compatConnector(node:CompatNode,handle:string|null|undefined,fallback:ConnectorSide):Connector{
const port=Array.isArray(node.data.ports)?node.data.ports.find(port=>port&&`port:${port.id}`===handle):undefined,hints=(node.data as ProjectionNode['data']&{__headlessFallback?:{handles?:{id:string;side:ConnectorSide}[]}}).__headlessFallback?.handles,projected=Array.isArray(hints)?hints.find(hint=>hint?.id===handle&&['top','right','bottom','left'].includes(hint.side))?.side:undefined,side=projected??(['top','right','bottom','left'].includes(handle??'')?handle as ConnectorSide:port?.direction==='input'?'left':port?'right':fallback),result=nodeConnector(node,side);
// 输入/输出各自独立排序,用测量高度优先;默认400仅属于旧卡片布局约定。
if(port){const peers=node.data.ports!.filter(item=>item.direction===port.direction);result.point.y+=((peers.indexOf(port)+1)/(peers.length+1)-.5)*(node.measured?.height||node.height||400);}return result;
}
/** renderer 测量端口:绝对位置与 source/target handle 边界,避免在这里依赖整个 UI store。 */
export type MeasuredNode={readonly internals:{readonly positionAbsolute:{x:number;y:number};readonly handleBounds?:{readonly source?:readonly MeasuredHandle[]|null;readonly target?:readonly MeasuredHandle[]|null}}};
/** 单个 Handle 在节点中的相对矩形及所在边。 */
type MeasuredHandle={readonly id?:string|null;readonly position:ConnectorSide;readonly x:number;readonly y:number;readonly width:number;readonly height:number};
/** Native Handle outer edge, matching the installed React Flow loose connection mode. */
/** 从实际 Handle 外侧中心生成锚点与朝外法线。target 可采用 source 把手以匹配 loose 模式,
* 指定 handleId 未找到或尚无测量时返回 undefined,不编造中心点。 */
export function measuredConnector(node:MeasuredNode|undefined,handleId:string|null|undefined,end:'source'|'target'):Connector|undefined{
const bounds=node?.internals.handleBounds;if(!node||!bounds)return;const handles=end==='source'?bounds.source??[]:[...(bounds.target??[]),...(bounds.source??[])],handle=handleId?handles.find(handle=>handle.id===handleId):handles[0];if(!handle)return;
const {x,y}=node.internals.positionAbsolute,side=handle.position,normals={top:{x:0,y:-1},right:{x:1,y:0},bottom:{x:0,y:1},left:{x:-1,y:0}},point={x:x+handle.x+(side==='left'?0:side==='right'?handle.width:handle.width/2),y:y+handle.y+(side==='top'?0:side==='bottom'?handle.height:handle.height/2)};return{point,normal:normals[side]};
}
@@ -0,0 +1,67 @@
/**
* 按元素 ID 精确路由的只读查询源。一个上游 editor 订阅服务所有元素桶;
* 普通提交用 changes 找受影响 ID,整份文档替换或加载覆盖变化则做必要的保守复查。
*/
import type {Commit,Id} from '../canvas-core/types.ts';
import type {ElementLookup} from '../canvas-core/queries.ts';
import type {EngineSnapshot} from '../canvas-runtime/contracts.ts';
import {equal} from '../canvas-core/values.ts';
import type {ExternalSource} from './selectors.ts';
/** 注入根读取、发布订阅与元素查询,绑定层不另建可以编辑的元素表。 */
export type ElementSourcePorts={
getSnapshot():EngineSnapshot;
subscribe(listener:(commit:Commit,snapshot:EngineSnapshot)=>void):()=>void;
lookup(snapshot:EngineSnapshot,id:Id):ElementLookup;
};
/** One upstream subscription, ID buckets and cached lookup values, never another document.
* Explicit changes use ID routing; replacement/coverage cuts retain a checked fallback. */
/** 创建惰性订阅中心;每个 forElement 源缓存自身读取值,同 ID 的监听共享上游桶。 */
export function createElementSources(ports:ElementSourcePorts){
// 失败标记使下次成功查询即使值等价也通知恢复;不把异常缓存成一条虚假元素记录。
type Bucket={value:ElementLookup;listeners:Set<()=>void>;failed?:boolean};
const buckets=new Map<Id,Bucket>();let stop:(()=>void)|undefined,previous:EngineSnapshot|undefined;
/** 先更新全部命中桶再通知,逐个隔离监听器错误,最终重抛首个错误给上游边界。 */
function changed(commit:Commit,next:EngineSnapshot){
// The editor snapshots its listeners; this callback can already be unsubscribed.
if(!previous||!buckets.size)return;
const before=previous!;previous=next;
const ids=new Set<Id>();
if(before.document!==next.document){
// 显式元素/字段改动走 ID 路由;无 changes 的文档替换无法精确定位,复查全部已订阅元素。
if(commit.changes.length){for(const change of commit.changes)if(change.kind==='field')ids.add(change.target.id);else if(change.kind==='element')ids.add(change.id);}
else for(const id of buckets.keys())ids.add(id);
}
// loading 的已知范围改变会把 absent/unloaded 变成可查询;原先非 present 的桶必须复查。
const coverageChanged=before.runtime.loading!==next.runtime.loading||!!next.runtime.loading&&(before.runtime.loadingRead?.document??before.runtime.acknowledged)!==(next.runtime.loadingRead?.document??next.runtime.acknowledged);
if(coverageChanged)for(const[id,bucket]of buckets)if(bucket.value.status!=='present')ids.add(id);
const notifications=new Set<()=>void>();
for(const id of ids){
const bucket=buckets.get(id);if(!bucket)continue;
try{const value=ports.lookup(next,id);if(!bucket.failed&&equal(bucket.value,value))continue;bucket.value=value;bucket.failed=false;}catch{bucket.failed=true;/* Snapshot readers retry/raise through their own boundary. */}
for(const notify of bucket.listeners)notifications.add(notify);
}
// All bucket values advance before observers run; a failing observer cannot starve others.
let failed=false,error:unknown;
for(const notify of notifications)try{notify();}catch(caught){if(!failed){failed=true;error=caught;}}
if(failed)throw error;
}
/** 创建单元素外部源;单纯创建源不注册上游,只有 subscribe 才建立观察关系。 */
return Object.freeze({forElement(id:Id):ExternalSource<ElementLookup>{
let value:ElementLookup|undefined;
return Object.freeze({
/** 每次从当前根查询并按值保留稳定引用,不能只依赖通知时机猜测最新数据。 */
getSnapshot(){const next=ports.lookup(ports.getSnapshot(),id);if(!value||!equal(value,next))value=next;return value;},
/** 每个订阅包装独立 listener,即使调用方传同一个函数也能分别释放。 */
subscribe(notify:()=>void){
let bucket=buckets.get(id);if(!bucket){bucket={value:ports.lookup(ports.getSnapshot(),id),listeners:new Set()};buckets.set(id,bucket);}
// Distinct subscriptions may intentionally share the same callback identity.
const listener=()=>notify();bucket.listeners.add(listener);
// 首次连上游抛错时撤销这次 listener/空桶,避免半注册泄漏。
if(!stop){previous=ports.getSnapshot();try{stop=ports.subscribe(changed);}catch(error){bucket.listeners.delete(listener);if(!bucket.listeners.size)buckets.delete(id);if(!buckets.size)previous=undefined;throw error;}}
// 最后一个桶清空才退订上游,释放 previous 根引用;其它元素仍有监听时继续共享。
let active=true;return()=>{if(!active)return;active=false;bucket!.listeners.delete(listener);if(!bucket!.listeners.size)buckets.delete(id);if(!buckets.size){stop?.();stop=undefined;previous=undefined;}};
},
});
}});
}
+35
View File
@@ -0,0 +1,35 @@
/**
* 显示层的帧合并队列。把多次“需要重画”合并成去重 key 集合,不延迟命令提交、历史或网络任务。
* 队列只拥有调度句柄和失效索引;真实数据随时从上游快照读取,dispose 时丢弃未发布通知。
*/
import type {ExternalSource} from './selectors.ts';
/** Presentation invalidations only. Commands, history and network work never wait here. */
/** 宿主注入帧请求/取消能力,可接 requestAnimationFrame 或测试时钟;handle 不保存业务身份。 */
export type FrameClock={request(callback:()=>void):unknown;cancel(handle:unknown):void};
/** 创建一条可手动 flush 的通知队列;同一 key 在一次批次中只出现一次。 */
export function createFrameQueue<K>(clock:FrameClock,publish:(dirty:ReadonlySet<K>)=>void){
let dirty=new Set<K>(),scheduled:{handle?:unknown}|undefined,disposed=false;
/** 同步排空已有脏项,先换新 Set 再回调,回调中的新失效留给下一批,不会被本批 clear 擦掉。 */
function flush(){if(disposed)return;const pending=scheduled;scheduled=undefined;if(pending)clock.cancel(pending.handle);if(!dirty.size)return;const batch=dirty;dirty=new Set();publish(batch);}
return Object.freeze({
/** 合并 keys 并最多安排一个帧回调;pending 对象身份防止已取消的旧回调误刷后来的批次。 */
invalidate(keys:Iterable<K>){if(disposed)return;for(const key of keys)dirty.add(key);if(!dirty.size||scheduled)return;const pending: {handle?:unknown}={};scheduled=pending;try{pending.handle=clock.request(()=>{if(scheduled!==pending)return;scheduled=undefined;flush();});}catch(error){scheduled=undefined;throw error;}},
flush,
/** 幂等关闭并撤销帧句柄,关闭后失效通知无效果;不会替上游关闭 editor。 */
dispose(){if(disposed)return;disposed=true;if(scheduled)clock.cancel(scheduled.handle);scheduled=undefined;dirty.clear();},
});
}
/** Coalesce presentation notifications; immediate invalidations flush pending work synchronously. */
/** 给普通外部数据源加通知节流:读取始终透传当前快照,每个订阅独有可释放的帧队列。
* 上游标 immediate 时同步 flush,常用于不能等下一帧才反映的状态边界。 */
export function createFrameSource<S>(source:{getSnapshot():S;subscribe(notify:(immediate?:boolean)=>void):()=>void},clock:FrameClock):ExternalSource<S>{
return Object.freeze({getSnapshot:()=>source.getSnapshot(),subscribe(notify:()=>void){
const queue=createFrameQueue<number>(clock,()=>notify());
let stop:()=>void;
// 注册抛错也要撤销可能已经安排的帧,避免一个未建立成功的订阅仍在稍后通知。
try{stop=source.subscribe(immediate=>{queue.invalidate([0]);if(immediate)queue.flush();});}catch(error){queue.dispose();throw error;}
// 先退订上游,finally 保证上游清理抛错时仍释放本订阅的显示队列。
return()=>{try{stop();}finally{queue.dispose();}};
}});
}
+36
View File
@@ -0,0 +1,36 @@
/**
* 视图局部的数值/路径投影缓存。按元素代次、几何和变换值复用计算,既不缓存可编辑记录,也不决定可见性。
* 每次 begin/end 标记本帧访问项,剔除不再出现的条目;切换 scope/project/canvas 时整体清空。
*/
import type {Geometry,Id,Json} from '../canvas-core/types.ts';
import {childCanvasTransform,toWorld,transformPath,type Transform} from '../canvas-core/coordinates.ts';
import {equal,clone,freeze} from '../canvas-core/values.ts';
/** 仅比较投影使用的四个变换量,普通对象引用变化不必导致重新计算。 */
const sameTransform=(a:Transform,b:Transform)=>a.x===b.x&&a.y===b.y&&a.scale===b.scale&&a.rotation===b.rotation;
/** 节点几何的全部数值字段都参与缓存条件。 */
const sameGeometry=(a:Geometry,b:Geometry)=>a.x===b.x&&a.y===b.y&&a.width===b.width&&a.height===b.height&&a.rotation===b.rotation;
/** 子画布变换只依赖 presentation 中 contentScale/unitScale,忽略无关样式字段。 */
const units=(value:Json|undefined)=>value&&typeof value==='object'&&!Array.isArray(value)?[value.contentScale,value.unitScale]:[];
/** View-local numeric/path projection only. No editable records, history, visibility or task facts. */
/** 缓存实例应归一个显示上下文;计数器累计实际计算次数供性能诊断,不是模型版本。 */
export function createGeometryProjectionCache(){
let owner:object|undefined,project:Id|undefined,canvas:Id|undefined,nodesComputed=0,childrenComputed=0,edgesComputed=0;
type NodeResult={position:{x:number;y:number};width:number;height:number;rotation:number};
const nodes=new Map<Id,{incarnation:number;geometry:Geometry;transform:Transform;value:NodeResult}>(),children=new Map<Id,{incarnation:number;geometry:Geometry;transform:Transform;units:unknown;value:Transform}>(),edges=new Map<Id,{incarnation:number;path:Json;transform:Transform;value:Json}>();let seenNodes=new Set<Id>(),seenChildren=new Set<Id>(),seenEdges=new Set<Id>();
return Object.freeze({
/** 开始一轮投影并重置 seen;owner 身份隔离不同 renderer/editor,即使 projectId 相同也不能混用。 */
begin(scope:object,projectId:Id,canvasId:Id){if(owner!==scope||project!==projectId||canvas!==canvasId){nodes.clear();children.clear();edges.clear();owner=scope;project=projectId;canvas=canvasId;}seenNodes=new Set();seenChildren=new Set();seenEdges=new Set();},
/** 将节点中心转到场景,按变换比例缩放宽高并合成旋转;缓存复制输入数值防止外部修改污染键。 */
node(id:Id,incarnation:number,g:Geometry,t:Transform):NodeResult{seenNodes.add(id);const before=nodes.get(id);if(before&&before.incarnation===incarnation&&sameGeometry(before.geometry,g)&&sameTransform(before.transform,t))return before.value;const center=toWorld(t,{x:g.x+g.width/2,y:g.y+g.height/2}),width=g.width*t.scale,height=g.height*t.scale,value=freeze({position:{x:center.x-width/2,y:center.y-height/2},width,height,rotation:(g.rotation+t.rotation)%360});nodes.set(id,{incarnation,geometry:{...g},transform:{...t},value});nodesComputed++;return value;},
/** 计算分区内部坐标到场景的变换,单位比例变化也会使缓存失效。 */
child(id:Id,incarnation:number,g:Geometry,t:Transform,presentation:Json|undefined):Transform{seenChildren.add(id);const before=children.get(id),currentUnits=units(presentation);if(before&&before.incarnation===incarnation&&sameGeometry(before.geometry,g)&&sameTransform(before.transform,t)&&equal(before.units,currentUnits))return before.value;const value=freeze(childCanvasTransform(t,g,presentation));children.set(id,{incarnation,geometry:{...g},transform:{...t},units:currentUnits,value});childrenComputed++;return value;},
/** JSON 路径按值比较;先复制/冻结输入再投影,不持有调用方可变路径作为历史缓存。 */
edge(id:Id,incarnation:number,path:Json,t:Transform):Json{seenEdges.add(id);const before=edges.get(id);if(before&&before.incarnation===incarnation&&equal(before.path,path)&&sameTransform(before.transform,t))return before.value;const captured=freeze(clone(path)),value=freeze(transformPath(captured,t));edges.set(id,{incarnation,path:captured,transform:{...t},value});edgesComputed++;return value;},
/** 删除本轮未访问的缓存项,避免浏览多个分区后无限保留旧节点和路径。 */
end(){for(const id of nodes.keys())if(!seenNodes.has(id))nodes.delete(id);for(const id of children.keys())if(!seenChildren.has(id))children.delete(id);for(const id of edges.keys())if(!seenEdges.has(id))edges.delete(id);},
/** 返回当前缓存大小和累计计算量的冻结快照;begin 不重置累计计数。 */
stats:()=>Object.freeze({nodesComputed,childrenComputed,edgesComputed,nodes:nodes.size,children:children.size,edges:edges.size}),
});
}
/** 由工厂推导的缓存接口,供投影器注入复用。 */
export type GeometryProjectionCache=ReturnType<typeof createGeometryProjectionCache>;
+71
View File
@@ -0,0 +1,71 @@
/**
* 对已捕获显示投影执行命中查询,而不是直接扫描请求队列或另建选择模型。
* 节点用组件 geometry 协议精测,连线用实际显示锚点/曲线近似求距;像素容差按 zoom 转为场景单位。
* 预算或几何解释不足时保留 diagnostics/possible,调用方不能把不完整结果当成确定的“没有命中”。
*/
import type {DocumentQueries} from '../canvas-core/queries.ts';
import type {ElementRecord,Geometry,Id,Point,Registry,Target} from '../canvas-core/types.ts';
import type {Connector,Cubic} from '../canvas-core/geometry.ts';
import {rectangleGeometry} from '../canvas-core/geometry.ts';
import {hitCubic} from '../canvas-core/curve-hit.ts';
import {toLocal,type Transform} from '../canvas-core/coordinates.ts';
import {fault,freeze} from '../canvas-core/values.ts';
import {compatConnector} from './connectors.ts';
import type {projectCanvas,ProjectionNode,ProjectionEdge} from './projection.ts';
/** 查询消费 projectCanvas 的同一帧结果,包含显示节点、边与 canvas→场景变换。 */
type Projection=ReturnType<typeof projectCanvas>;
/** 命中身份用真实元素 ID+代次;displayPath 说明嵌套显示路径,不改变 ownerCanvasId。
* 连线另保留屏幕显示端点身份,可能是投影暴露的上级入口,而不是底层真实端点。 */
export type SceneHit={readonly target:Target;readonly kind:'node'|'connection';readonly ownerCanvasId:Id;readonly displayPath:{readonly viewSessionId:Id;readonly canvasIds:readonly Id[];readonly entryIds:readonly Id[]};readonly layer:number;readonly certainty:'confirmed'|'possible';readonly distanceScene?:number;readonly errorScene?:number;readonly displayedEndpoints?:{readonly source:Id;readonly target:Id}};
/** renderer 可提供实际锚点、曲线、线宽与层级,使命中跟绘制一致;previewIds 补充移动预览候选。 */
export type HitServices={readonly anchor?:(node:ProjectionNode,handle:string|null|undefined,end:'source'|'target',edge:ProjectionEdge)=>Connector|undefined;readonly edgeGeometry?:(edge:ProjectionEdge,source:Connector,target:Connector)=>readonly Cubic[]|undefined;readonly strokeWidth?:(edge:ProjectionEdge)=>number|undefined;readonly layer?:(id:Id,kind:'node'|'connection')=>number|undefined;readonly previewIds?:ReadonlySet<Id>};
/** 候选数、曲线步数/深度、每边段数与像素误差上限,限制一次交互查询的工作量。 */
export type HitBudget={readonly maxCandidates?:number;readonly maxCurveSteps?:number;readonly maxCurveDepth?:number;readonly maxSegments?:number;readonly errorPx?:number};
/** scene 点直接附缩放;screen 点还带容器 origin 和 viewport 平移,避免混用窗口/画布坐标。 */
export type HitPoint=({readonly space:'scene';readonly point:Point;readonly zoom:number}|{readonly space:'screen';readonly point:Point;readonly viewport:{readonly x:number;readonly y:number;readonly zoom:number;readonly origin:Point}})&{readonly tolerancePx?:number};
/** 外部几何服务的点必须由有限数值组成,不能让 NaN/Infinity 流入曲线计算。 */
const finitePoint=(point:Point)=>!!point&&Number.isFinite(point.x)&&Number.isFinite(point.y);
/** 把显示节点的位置/尺寸/旋转反算回所属 canvas 局部几何,包含当前预览后的显示结果。 */
function geometry(node:ProjectionNode,t:Transform):Geometry{const width=node.width!/t.scale,height=node.height!/t.scale,center=toLocal(t,{x:node.position.x+node.width!/2,y:node.position.y+node.height!/2});return{x:center.x-width/2,y:center.y-height/2,width,height,rotation:((node.data.rotation??0)-t.rotation+360)%360};}
/** Query the captured display frame. It neither edits nor replaces the native input router. */
/** 必须使用投影对应的同一 document 和 registry。先取空间候选再精测,返回有序命中及完整性诊断;
* 此函数不触发编辑、不消费原生事件,也不替宿主决定是否认领一次输入。 */
export function hitProjectedCanvas(projection:Projection,queries:DocumentQueries,registry:Registry,viewSessionId:Id,query:HitPoint,services:HitServices={},budget:HitBudget={}){
// 强制快照/registry 身份一致;旧投影搭配新索引可能产生看得见却点不中的结果,直接拒绝。
if(projection.document!==queries.document||!viewSessionId)throw fault('stale_query_snapshot');if(queries.registry!==registry)throw fault('query_registry_changed');const zoom=query.space==='scene'?query.zoom:query.viewport.zoom,tolerancePx=query.tolerancePx??6,errorPx=budget.errorPx??.25,maxCandidates=budget.maxCandidates??4096,maxSteps=budget.maxCurveSteps??8192,maxDepth=budget.maxCurveDepth??20,maxSegments=budget.maxSegments??4096;
if(!finitePoint(query.point)||!Number.isFinite(zoom)||zoom<=0||!Number.isFinite(tolerancePx)||tolerancePx<0||!Number.isFinite(errorPx)||errorPx<=0||![maxCandidates,maxSteps,maxDepth,maxSegments].every(n=>Number.isSafeInteger(n)&&n>=1&&n<=1000000)||maxDepth>40)throw fault('invalid_hit_query');
if(query.space==='screen'&&(!finitePoint(query.viewport.origin)||!Number.isFinite(query.viewport.x)||!Number.isFinite(query.viewport.y)))throw fault('invalid_hit_query');
// 屏幕位置减容器 origin 与 viewport 平移后除 zoom;容差同样换算,不随缩放改变视觉点击范围。
const point=query.space==='scene'?query.point:{x:(query.point.x-query.viewport.origin.x-query.viewport.x)/zoom,y:(query.point.y-query.viewport.origin.y-query.viewport.y)/zoom},tolerance=tolerancePx/zoom,document=projection.document,nodes=new Map(projection.nodes.map(node=>[node.id,node])),rank=new Map(queries.orderedElements([...nodes.keys(),...projection.edges.map(edge=>edge.id)]).map((id,index)=>[id,index])),candidates=new Set<Id>(services.previewIds??[]),hits:SceneHit[]=[],diagnostics:{target?:Id;code:string}[]=[];let work=0,remaining=maxSteps;
if(!finitePoint(point)||!Number.isFinite(tolerance)||!Number.isFinite(errorPx/zoom))throw fault('invalid_hit_query');
// 分别把场景点与容差变回各 canvas,查询本地节点索引;previewIds 可补上索引尚未覆盖的预览位置。
for(const[id,t]of projection.transforms){if(!Number.isFinite(t.scale)||t.scale<=0)throw fault('invalid_hit_transform');const p=toLocal(t,point),r=tolerance/t.scale;for(const candidate of queries.candidateNodes(id,{x:p.x-r,y:p.y-r,width:2*r,height:2*r}))candidates.add(candidate);}
/** 由真实 owner 向当前显示根回溯,生成 canvas/入口路径;检测循环和无效父关系。 */
function path(owner:Id){const canvases:Id[]=[],entries:Id[]=[],seen=new Set<Id>();let id=owner;while(id!==projection.canvasId){if(seen.has(id))throw fault('canvas_cycle');seen.add(id);const parent=document.canvases.get(id)?.parent;if(!parent)throw fault('invalid_display_path');canvases.push(id);entries.push(parent.entryElementId);id=parent.canvasId;}return{viewSessionId,canvasIds:[projection.canvasId,...canvases.reverse()],entryIds:entries.reverse()};}
/** 自定义层级必须有限;否则记入对应目标诊断,而不是用无效数值打乱排序。 */
const layer=(id:Id,kind:'node'|'connection',fallback:number)=>{const value=services.layer?.(id,kind)??fallback;if(!Number.isFinite(value))throw fault('invalid_hit_layer');return value;};
// 节点精测只处理投影中实际存在的候选;未知/降级组件使用矩形协议,单元素协议失败隔离为诊断。
for(const id of candidates){const node=nodes.get(id);if(!node)continue;if(++work>maxCandidates){diagnostics.push({code:'geometry_query_budget'});break;}try{const record=document.elements.get(id);if(!record||record.structure!=='node')throw fault('invalid_hit_projection');const t=projection.transforms.get(record.ownerCanvasId);if(!t)throw fault('invalid_hit_transform');const g=geometry(node,t),resolved=!node.data.__headlessFallback&&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'});}}
// 优先采用 renderer 锚点。原生 headless 记录不能用旧卡片的兼容锚点猜位置,缺少服务就给出诊断。
const anchor=(node:ProjectionNode,handle:string|null|undefined,end:'source'|'target',edge:ProjectionEdge)=>services.anchor?services.anchor(node,handle,end,edge):node.data.__headlessRecord?undefined:compatConnector(node,handle,end==='source'?'right':'left');
// 连线与节点共用候选预算;逐边解析真实显示端点和画笔,失败不影响其它边的查询。
for(const edge of projection.edges){if(++work>maxCandidates){diagnostics.push({code:'geometry_query_budget'});break;}try{const record=document.elements.get(edge.id);if(!record||record.structure!=='connection')throw fault('invalid_hit_projection');const source=nodes.get(edge.source),target=nodes.get(edge.target);if(!source||!target)throw fault('geometry_anchor_unavailable');const from=anchor(source,edge.sourceHandle,'source',edge),to=anchor(target,edge.targetHandle,'target',edge);if(!from||!to||![from.point,from.normal,to.point,to.normal].every(finitePoint))throw fault('geometry_anchor_unavailable');
const transform=projection.transforms.get(record.ownerCanvasId);if(!transform)throw fault('invalid_hit_transform');const protocol=edge.type!=='unsupported'&&registry.resolve(record)==='known'?registry.components.get(record.componentId)?.geometry:undefined,context={element:record,source:from,target:to,displayPath:edge.data?.__headlessRecord?undefined:edge.data?.path??null,transform};
// renderer 曲线优先,随后才用组件协议;验证段数及每段四个有限点,避免异常几何耗尽查询。
const segments=services.edgeGeometry?.(edge,from,to)??(protocol?.kind==='connection'?protocol.segments(context):undefined);if(!segments)throw fault('geometry_unavailable');if(!Array.isArray(segments)||segments.length>maxSegments||segments.some(segment=>!Array.isArray(segment)||segment.length!==4||!segment.every(finitePoint)))throw fault('invalid_geometry_response');
// 有效点击半径 = 场景容差 + 实际线宽/2;不把只点中心线误当成整条可见线的命中范围。
const width=services.strokeWidth?.(edge)??(protocol?.kind==='connection'?protocol.strokeWidth({element:record,transform}):undefined);if(typeof width!=='number'||!Number.isFinite(width)||width<0)throw fault('invalid_geometry_paint');let possible:ReturnType<typeof hitCubic>|undefined,confirmed:ReturnType<typeof hitCubic>|undefined;
// 曲线细分步数由所有边共享剩余预算;无法继续证明时保守返回 possible,不伪造 confirmed。
for(const segment of segments){if(remaining<=0){possible??={hit:true,certainty:'possible',steps:0,distance:Infinity,error:Infinity,budget:true};break;}const result=hitCubic(segment,point,tolerance+width/2,{error:errorPx/zoom,maxDepth,maxSteps:remaining});remaining-=result.steps;if(result.hit){if(result.certainty==='confirmed'){confirmed=result;break;}possible=result;}}
// 同一边任一段确定命中即可 confirmed;只有不确定命中则同时说明精度或预算诊断。
const result=confirmed??possible;if(result?.hit){if(!confirmed)diagnostics.push({target:edge.id,code:result.budget?'geometry_query_budget':'geometry_precision'});hits.push({target:{id:edge.id,incarnation:record.incarnation},kind:'connection',ownerCanvasId:record.ownerCanvasId,displayPath:path(record.ownerCanvasId),displayedEndpoints:{source:edge.source,target:edge.target},layer:layer(edge.id,'connection',edge.zIndex??0),certainty:confirmed?'confirmed':'possible',...(Number.isFinite(result.distance)?{distanceScene:result.distance}:{}),...(Number.isFinite(result.error)?{errorScene:result.error}:{})});}
}catch(error){diagnostics.push({target:edge.id,code:(error as{code?:string}).code??'geometry_handler_failed'});}}
// 尚未完整加载的显示分区可能漏掉未收到元素,命中列表存在也不代表整场景查询完整。
if([...projection.transforms.keys()].some(id=>!queries.coverage.completeCanvasIds.includes(id)))diagnostics.push({code:'model_scope_incomplete'});
// 排序按显示层级从高到低,同层节点优先于边,再按文档层序倒序;只返回结果,不直接改变选择。
hits.sort((a,b)=>b.layer-a.layer||(a.kind===b.kind?0:a.kind==='node'?-1:1)||(rank.get(b.target.id)??0)-(rank.get(a.target.id)??0));return freeze({projectId:document.projectId,revision:queries.revision,viewSessionId,pointScene:{...point},toleranceScene:tolerance,hits,complete:diagnostics.length===0,diagnostics});
}
+34
View File
@@ -0,0 +1,34 @@
/**
* 可移植 bindings 的公共入口,集中导出投影、查询、输入与显示租约工具。
* 不导入 React/React Flow;框架 hooks、组件注册与命令 Context 通过 react-flow.ts 明确选择。
*/
/** Portable scene projection and hit queries: no React or React Flow imports. */
// 模型到场景投影、几何缓存和受预算控制的命中查询。
export * from './projection.ts';
export * from './geometry-cache.ts';
export * from './projection-cache.ts';
export * from './hit-query.ts';
// 框架无关 renderer 描述、锚点约定与资源视图租约。
export * from './renderers.ts';
export * from './connectors.ts';
export * from './resource-view.ts';
export type * from './compat-types.ts';
// 外部快照选择与输入优先级;这些工具不建立第二份业务状态。
export {createSelectorStore} from './selectors.ts';
export type {ExternalSource} from './selectors.ts';
export {routeCanvasKey,canvasKeyContext} from './input-router.ts';
export type {CanvasKeyInput,InputClaim,KeyHandler} from './input-router.ts';
// 仅显示失效通知的帧合并和绘制范围索引,业务命令不在这里排队。
export {createFrameQueue,createFrameSource} from './frame-queue.ts';
export type {FrameClock} from './frame-queue.ts';
export {createSessionFrameSource} from './session-frames.ts';
export {createPaintIndex} from './paint-index.ts';
export type {PaintRegion} from './paint-index.ts';
export {projectedPaintOverflow,createProjectedPaintIndex} from './projected-paint.ts';
export {projectedConnectionPaintOverflow,createConnectionPaintIndex} from './connection-paint.ts';
// 视图局部浮层与 DOM 指针捕获的生命周期工具。
export {createOverlayIndex} from './overlays.ts';
export type {OverlayEntry,OverlayIndex} from './overlays.ts';
export {ownsPointerCapture,acquirePointerCapture,releasePointerCapture} from './pointer-capture.ts';
export type {PointerCapturePort} from './pointer-capture.ts';
+38
View File
@@ -0,0 +1,38 @@
/**
* 归一化键盘事件的消费顺序:文本编辑 → 浮层 → 手势 → 工具 → 快捷键。
* 输入来源由宿主传入,路由器只报告获胜层,不直接 preventDefault、执行命令或访问浏览器全局。
*/
/** Host normalizes DOM/native input; only the winning layer consumes an event. */
/** 将 DOM/native 键盘上下文压成只读值;composing 保护输入法组合,modifier 供具体处理器解释。 */
export type CanvasKeyInput={readonly key:string;readonly composing?:boolean;readonly textEditing?:boolean;readonly overlay?:boolean;readonly defaultPrevented?:boolean;readonly ctrl?:boolean;readonly meta?:boolean;readonly alt?:boolean;readonly shift?:boolean};
/** 只有一个层获得消费权;unclaimed 表示宿主不应据此拦截事件。 */
export type InputClaim='text'|'overlay'|'gesture'|'tool'|'shortcut'|'unclaimed';
/** 返回 true 即获得事件;后续低优先级处理器不会再运行。 */
export type KeyHandler=(input:CanvasKeyInput)=>boolean;
// 标准文本控件、可编辑内容和 textbox 角色均视为正在输入,不让画布快捷键抢走按键。
const textTarget = 'input,textarea,select,[contenteditable]:not([contenteditable="false"]),[role="textbox"]';
// 画布浮层标记和交互 ARIA 角色优先于全局画布命令。
const overlayTarget = '[data-canvas-overlay-view],[role="dialog"],[role="menu"],[role="listbox"],[role="combobox"]';
/** Inspect the composed path as well as the retargeted host (portals/shadow DOM).
* This uses a structural DOM port so pure consumers need no browser globals. */
/** 检查 target 和 composedPath 上所有可 closest 的节点,识别 shadow DOM 重定向后的输入上下文。
* portal 需要自身携带浮层标记/角色;这里只检查实际路径,不推断不存在于 DOM 路径的 React 父关系。 */
export function canvasKeyContext(event:{readonly target?:unknown;composedPath?:()=>readonly unknown[]}):Pick<CanvasKeyInput,'textEditing'|'overlay'>{
let textEditing=false,overlay=false;
for(const target of [event.target,...(event.composedPath?.()??[])]){
// 使用结构能力判断,跳过 Window、文本节点等无 closest 的路径项,无需 instanceof Element。
if(!target||typeof (target as {closest?:unknown}).closest!=='function')continue;
const element=target as {closest(selector:string):unknown};
textEditing ||= !!element.closest(textTarget);
overlay ||= !!element.closest(overlayTarget);
}
return {textEditing,overlay};
}
/** 已被宿主处理的事件不再认领;输入法/文本与浮层直接截住,随后按固定优先级短路调用处理器。 */
export function routeCanvasKey(input:CanvasKeyInput,handlers:{gesture?:KeyHandler;tool?:KeyHandler;shortcut?:KeyHandler}):InputClaim{
if(input.defaultPrevented)return 'unclaimed';
if(input.composing||input.textEditing)return 'text';
if(input.overlay)return 'overlay';
for(const layer of ['gesture','tool','shortcut'] as const)if(handlers[layer]?.(input))return layer;
return 'unclaimed';
}
+26
View File
@@ -0,0 +1,26 @@
/**
* 把节点端口/尺寸变化的测量请求合并给 renderer,避免同一轮重复调用更新。
* owner token 区分同 ID 的不同挂载实例;卸载实例的迟到失效不能触发后来新实例的测量。
*/
/** One renderer store's transient measurement requests. Not model or history state. */
/** 每个 renderer store 独立创建;默认微任务调度,也可注入测试或宿主 defer。 */
export function createNodeInternalsBatch(update:(ids:string[])=>void,defer:(flush:()=>void)=>void=queueMicrotask){
const owners=new Map<symbol,string>(),dirty=new Set<symbol>();let queued=false;
/** 执行时才解析仍有效的 owner,按节点 ID 去重;先清 dirty 再回调,让重入失效进入下一轮。 */
function flush(){
queued=false;const ids=new Set<string>();for(const owner of dirty){const id=owners.get(owner);if(id!==undefined)ids.add(id);}dirty.clear();
if(ids.size)update([...ids]);
}
return Object.freeze({
/** 每次挂载取得独立租约,即使节点 ID 相同也能单独 dispose。 */
acquire(id:string){
const owner=Symbol(id);owners.set(owner,id);
return Object.freeze({
/** 仅活跃租约可以变脏,同一批最多安排一次延后 flush。 */
invalidate(){if(!owners.has(owner))return;dirty.add(owner);if(!queued){queued=true;defer(flush);}},
/** 卸载同时移除 owner 和其未执行请求;已排微任务可自然执行为空,不取得新实例权限。 */
dispose(){owners.delete(owner);dirty.delete(owner);},
});
},
});
}
+31
View File
@@ -0,0 +1,31 @@
/**
* 视图局部的屏幕空间浮层索引。保存位置、目标代次与焦点/输入法状态,独立于业务文档和撤销历史。
* 同 ID 浮层重新注册时获得新 token,旧组件迟到清理不能删掉新浮层。
*/
import type {Target} from '../canvas-core/types.ts';
import type {Rect} from '../canvas-core/geometry.ts';
import {createPaintIndex} from './paint-index.ts';
import {fault} from '../canvas-core/values.ts';
/** bounds 使用屏幕空间;target 仍是真实元素 ID+代次,viewId 限定所属交互视图。 */
export type OverlayEntry={readonly id:string;readonly viewId:string;readonly placement:string;readonly target:Target;readonly bounds:Rect;readonly focused:boolean;readonly composing:boolean};
/** Ephemeral screen-space placements. A lease prevents late cleanup from removing a replacement. */
/** 为一个有效 viewId 创建租约表、冻结快照和可重建 paint 网格。 */
export function createOverlayIndex(viewId:string){
if(typeof viewId!=='string'||!viewId)throw fault('view_session_required');let serial=0,snapshot:readonly OverlayEntry[]=Object.freeze([]);const entries=new Map<string,{token:number;entry:OverlayEntry}>(),listeners=new Set<()=>void>(),index=createPaintIndex();
/** 从当前条目重建快照和空间索引后通知;这里只同步位置事实,不决定浮层 DOM 的挂载。 */
function publish(){snapshot=Object.freeze([...entries.values()].map(row=>row.entry));index.sync(snapshot.map(entry=>({id:entry.id,bounds:entry.bounds})));for(const listener of [...listeners])listener();}
/** 复制并冻结目标/矩形,验证身份和位置;复用 paint 索引校验矩形,拒绝不合法的外部边界。 */
function capture(id:string,value:Omit<OverlayEntry,'id'|'viewId'>):OverlayEntry{if(typeof id!=='string'||!id||typeof value.placement!=='string'||!value.placement||typeof value.target.id!=='string'||!value.target.id||!Number.isSafeInteger(value.target.incarnation)||value.target.incarnation<1)throw fault('invalid_overlay');const entry=Object.freeze({...value,id,viewId,target:Object.freeze({...value.target}),bounds:Object.freeze({...value.bounds})});const check=createPaintIndex();check.sync([{id,bounds:entry.bounds}]);return entry;}
// 订阅只关注本视图位置变化,调用方需用返回函数解除监听;getSnapshot 返回稳定的最近发布值。
return Object.freeze({viewId,getSnapshot:()=>snapshot,subscribe(listener:()=>void){listeners.add(listener);return()=>{listeners.delete(listener);};},
/** 注册或替换一个浮层,返回只能操作本 token 的 update/release。
* update 先完整校验,新旧序列化值相同时不发布;过期租约的更新/释放均无效果。 */
register(id:string,value:Omit<OverlayEntry,'id'|'viewId'>){const token=++serial,entry=capture(id,value);entries.set(id,{token,entry});publish();return Object.freeze({update(value:Omit<OverlayEntry,'id'|'viewId'>){if(entries.get(id)?.token!==token)return;const entry=capture(id,value),old=entries.get(id)!.entry;if(JSON.stringify(old)===JSON.stringify(entry))return;entries.set(id,{token,entry});publish();},release(){if(entries.get(id)?.token!==token)return;entries.delete(id);publish();}});},
/** 正常浮层按视口交集筛选;已聚焦或输入法组合中的浮层保留,但目标必须仍有效。 */
visible(bounds:Rect,current:(target:Target)=>boolean){const ids=index.query(bounds);return snapshot.filter(entry=>current(entry.target)&&(entry.focused||entry.composing||ids.has(entry.id)));},
/** 清空所有当前浮层并发布空快照;不关闭监听服务,后续仍可注册新租约。 */
clear(){entries.clear();publish();},
});
}
/** 由工厂推导接口,调用方无权直接操作内部 token/Map。 */
export type OverlayIndex=ReturnType<typeof createOverlayIndex>;
+31
View File
@@ -0,0 +1,31 @@
/**
* 显示空间的可重建均匀网格,用于额外绘制范围的可见性查询。
* 大区域、大查询或超出安全格坐标时用保守回退,避免为了空间索引速度漏掉可见元素。
*/
import type {Id} from '../canvas-core/types.ts';
import type {Rect} from '../canvas-core/geometry.ts';
import {fault} from '../canvas-core/values.ts';
/** 区域 ID 与场景轴对齐矩形;不保存节点内容、选择或交互状态。 */
export type PaintRegion={readonly id:Id;readonly bounds:Rect};
/** 同时校验端点,防止有限 x/width 相加后溢出;允许零面积边界。 */
const valid=(r:Rect)=>r&&[r.x,r.y,r.width,r.height,r.x+r.width,r.y+r.height].every(Number.isFinite)&&r.width>=0&&r.height>=0;
/** 包含边界接触的保守交集,与 nativeRectVisible 的正面积判定用途不同。 */
const intersects=(a:Rect,b:Rect)=>a.x<=b.x+b.width&&b.x<=a.x+a.width&&a.y<=b.y+b.height&&b.y<=a.y+a.height;
/** Rebuildable view-space grid. Oversized regions/queries fall back without false negatives. */
/** cellSize 决定网格尺度,maxCells 限制单区域/查询展开的格数,默认512和256。 */
export function createPaintIndex(cellSize=512,maxCells=256){
if(!Number.isFinite(cellSize)||cellSize<=0||!Number.isSafeInteger(maxCells)||maxCells<1)throw fault('invalid_paint_index');
const rows=new Map<Id,Rect>(),cells=new Map<string,Set<Id>>(),members=new Map<Id,readonly string[]>(),overflow=new Set<Id>();
/** 算出覆盖格键;负坐标用 floor,过大或不安全的格范围返回 undefined 走保守路径。 */
function keys(r:Rect){const x=Math.floor(r.x/cellSize),y=Math.floor(r.y/cellSize),right=Math.floor((r.x+r.width)/cellSize),bottom=Math.floor((r.y+r.height)/cellSize);if(![x,y,right,bottom].every(Number.isSafeInteger)||(right-x+1)*(bottom-y+1)>maxCells)return;const result:string[]=[];for(let a=x;a<=right;a++)for(let b=y;b<=bottom;b++)result.push(`${a},${b}`);return result;}
/** 同时清除反向成员表、格桶与溢出集合,空桶立即释放。 */
function remove(id:Id){for(const key of members.get(id)??[]){const bucket=cells.get(key)!;bucket.delete(id);if(!bucket.size)cells.delete(key);}members.delete(id);overflow.delete(id);rows.delete(id);}
return Object.freeze({
/** 先验证完整输入的 ID 唯一性和矩形,再更新差异;超大区域归入 overflow,每次查询都检查。 */
sync(regions:readonly PaintRegion[]){const incoming=new Map<Id,Rect>();for(const region of regions){if(!region.id||incoming.has(region.id)||!valid(region.bounds))throw fault('invalid_paint_region');incoming.set(region.id,{...region.bounds});}for(const id of rows.keys())if(!incoming.has(id))remove(id);for(const [id,r]of incoming){const old=rows.get(id);if(old&&old.x===r.x&&old.y===r.y&&old.width===r.width&&old.height===r.height)continue;remove(id);rows.set(id,r);const list=keys(r);if(!list){overflow.add(id);continue;}members.set(id,list);for(const key of list){let bucket=cells.get(key);if(!bucket)cells.set(key,bucket=new Set());bucket.add(id);}}},
/** 普通查询查覆盖格加 overflow,大查询扫描全部行,最后统一按矩形相交过滤。 */
query(region:Rect):ReadonlySet<Id>{if(!valid(region))throw fault('invalid_paint_region');const list=keys(region),candidates=list?new Set([...overflow,...list.flatMap(key=>[...cells.get(key)??[]])]):new Set(rows.keys());return new Set([...candidates].filter(id=>intersects(rows.get(id)!,region)));},
/** 释放所有可重建空间表,不保留跨场景的区域引用。 */
clear(){rows.clear();cells.clear();members.clear();overflow.clear();},
});
}
@@ -0,0 +1,14 @@
/**
* 浏览器指针捕获的容错边界。这里只处理 DOM 事件传输所有权;手势 token、预览和几何仍归 editor/session。
* DOM 捕获丢失不等于任意事件都能取消当前手势,必须匹配指针与捕获目标。
*/
/** Transport ownership only: document geometry and gesture tokens stay in the editor. */
/** 最小结构化 DOM 端口,让纯测试无需真实 HTMLElement。 */
export type PointerCapturePort={setPointerCapture(id:number):void;hasPointerCapture(id:number):boolean;releasePointerCapture(id:number):void};
/** 普通事件匹配 pointerId 即可;lostpointercapture 还要匹配原捕获目标且确认它已失去捕获。
* 如果同目标已重新捕获该指针,旧 lost 事件不拥有新手势;脱离 DOM 导致查询抛错则允许匹配目标清理。 */
export function ownsPointerCapture(owner:{pointerId:number;capture:unknown}|null|undefined,input:{pointerId:number;type?:string;target?:unknown}){if(!owner||owner.pointerId!==input.pointerId)return false;if(input.type!=='lostpointercapture')return true;if(input.target!==owner.capture)return false;const capture=owner.capture as Partial<PointerCapturePort>|undefined;try{if(capture?.hasPointerCapture?.(owner.pointerId))return false;}catch{/* A detached matching target may still need cancellation. */}return true;}
/** 平台可能因指针失效/节点卸载拒绝捕获,以布尔结果交给宿主决定是否继续手势。 */
export function acquirePointerCapture(target:PointerCapturePort,id:number){try{target.setPointerCapture(id);return true;}catch{return false;}}
/** 尽力释放且吞掉平台清理异常;DOM 释放失败不能取得 editor 的提交或取消权限。 */
export function releasePointerCapture(target:PointerCapturePort,id:number){try{if(target.hasPointerCapture(id))target.releasePointerCapture(id);}catch{/* Detached DOM or an already released platform capture has no editor authority. */}}
@@ -0,0 +1,81 @@
/**
* 节点额外绘制范围的保守索引。原生 renderer 的未旋转矩形不一定包含旋转/自定义组件的全部笔画,
* 因此用 geometry.paint bounds 补充超出的范围;无法解释的节点标 unknown,不能当作已证明不可见。
*/
import {clone,equal} from '../canvas-core/values.ts';
import {rectangleGeometry} from '../canvas-core/geometry.ts';
import {toLocal,toWorld,type Transform} from '../canvas-core/coordinates.ts';
import type {ElementRecord,Geometry,GeometryProtocol,Registry} from '../canvas-core/types.ts';
import type {projectCanvas,ProjectionNode} from './projection.ts';
import {createPaintIndex,type PaintRegion} from './paint-index.ts';
/** Extra node paint extents beyond native unrotated React Flow bounds. */
/** 对每个显示节点还原局部几何,调用组件 paint 边界,再把四角变到场景取轴对齐包围盒。
* 只记录超出原生矩形的节点;单节点协议失败记 unknown,其余节点继续处理。 */
export function projectedPaintOverflow(projection:ReturnType<typeof projectCanvas>,registry:Registry){
const regions:PaintRegion[]=[],unknown:string[]=[];
for(const node of projection.nodes)try{
const element=projection.document.elements.get(node.id),transform=element&&projection.transforms.get(element.ownerCanvasId);if(!element||element.structure!=='node'||!transform)throw Error('missing_geometry');
const width=node.width!/transform.scale,height=node.height!/transform.scale,center=toLocal(transform,{x:node.position.x+node.width!/2,y:node.position.y+node.height!/2}),geometry={x:center.x-width/2,y:center.y-height/2,width,height,rotation:((node.data.rotation??0)-transform.rotation+360)%360};
// 未知/降级节点用标准矩形边界;有效组件可声明更宽的笔画或旋转后的范围。
const definition=!node.data.__headlessFallback&&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;
/** 变换值比较避免无关根发布反复计算 paint 边界。 */
const sameTransform=(a:Transform|undefined,b:Transform|undefined)=>a===undefined?b===undefined:b!==undefined&&a.x===b.x&&a.y===b.y&&a.scale===b.scale&&a.rotation===b.rotation;
/** 验证缓存输入是否等价;协议未声明 dataDependencies 时保守比较整个 element.data,
* 显式声明后只比较列出的字段,声明遗漏会由协议实现者负责,不能由缓存自动推导。 */
function sameSignature(old:PaintSignature,node:ProjectionNode,element:ElementRecord|undefined,t:Transform|undefined,protocol:NodeProtocol){
if(old.incarnation!==element?.incarnation||old.owner!==element?.ownerCanvasId||old.component!==element?.componentId||old.version!==element?.dataVersion||old.protocol!==protocol||!sameGeometry(old.geometry,element?.structure==='node'?element.geometry:undefined)||!sameTransform(old.transform,t)||old.x!==node.position.x||old.y!==node.position.y||old.width!==node.width||old.height!==node.height||old.rotation!==node.data.rotation)return false;
const dependencies=protocol.dataDependencies;
if(dependencies===undefined)return equal(old.data,element?.data);
const values=old.data as unknown[];
for(let i=0;i<dependencies.length;i++)if(!equal(values[i],element?.data[dependencies[i]]))return false;
return true;
}
/** 复制实际依赖值而非持有调用方可变子对象;后续比较用捕获值发现内容变化。 */
function captureSignature(node:ProjectionNode,element:ElementRecord|undefined,t:Transform|undefined,protocol:NodeProtocol):PaintSignature{
const dependencies=protocol.dataDependencies;
return{incarnation:element?.incarnation,owner:element?.ownerCanvasId,component:element?.componentId,version:element?.dataVersion,geometry:element?.structure==='node'?{...element.geometry}:undefined,transform:t?{...t}:undefined,x:node.position.x,y:node.position.y,width:node.width,height:node.height,rotation:node.data.rotation,protocol,data:dependencies===undefined?clone(element?.data):dependencies.map(key=>clone(element?.data[key]))};
}
/** View-owned incremental paint grid; unchanged rows retain their captured dependencies. */
/** 视图级增量 paint 网格,每个节点缓存边界结果,只重新计算签名改变的条目。 */
export function createProjectedPaintIndex(){
let registryOwner:Registry|undefined,canvas:string|undefined,project:string|undefined,computed=0,captured=0;
type Projection=ReturnType<typeof projectCanvas>;
type Entry={signature:PaintSignature;result:ReturnType<typeof projectedPaintOverflow>};
const entries=new Map<string,Entry>(),index=createPaintIndex();
return Object.freeze({
/** 切 registry/project/canvas 清空缓存;逐节点收集额外范围与 unknown,再同步网格。 */
sync(projection:Projection,registry:Registry){
if(registryOwner!==registry||canvas!==projection.canvasId||project!==projection.document.projectId){entries.clear();index.clear();registryOwner=registry;canvas=projection.canvasId;project=projection.document.projectId;}
const seen=new Set<string>(),regions:PaintRegion[]=[],unknown:string[]=[];
for(const node of projection.nodes){seen.add(node.id);const element=projection.document.elements.get(node.id),t=element&&projection.transforms.get(element.ownerCanvasId),old=entries.get(node.id),declared=element&&!node.data.__headlessFallback&&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);
}
// 删除已不在场景中的缓存,返回可查询索引与冻结的未知列表;调用方须保守处理 unknown。
for(const id of entries.keys())if(!seen.has(id))entries.delete(id);index.sync(regions);return Object.freeze({query:index.query,unknown:Object.freeze(unknown)});
},
/** 累计实际计算/捕获次数和当前项数,仅供诊断,不是帧耗时或文档 revision。 */
stats:()=>Object.freeze({computed,captured,entries:entries.size}),
/** 解除场景与 registry 身份引用并清空网格;累计诊断计数仍保留。 */
clear(){entries.clear();index.clear();registryOwner=undefined;canvas=undefined;project=undefined;},
});
}
@@ -0,0 +1,55 @@
/**
* React Flow 输入对象的结构共享缓存,与计算场景的 scene-projection 分工不同。
* 按元素代次与 owner 判定同一生命周期,复用值相等的 data/style/position 和整个数组,减少显示刷新。
* 缓存属于一个 owner/project/视图 canvas,既不是业务文档,也不是跨项目身份仓库。
*/
import type {Document,Id} from '../canvas-core/types.ts';
import {equal,fault} from '../canvas-core/values.ts';
import type {projectCanvas} from './projection.ts';
/** 只处理已经生成的投影,不在这里解释元素组件或计算坐标。 */
type Projection=ReturnType<typeof projectCanvas>;
/** 原对象的生命周期证据;同 ID 的重建或移动 owner 后不能直接继承旧显示对象。 */
type Retained<T>={incarnation:number;ownerCanvasId:Id;value:T};
/** View-owned rendering cache, never a document store. Keep unchanged React Flow input identities. */
/** 创建视图专属结构共享器;reuse 会就地替换传入投影数组中的等价条目,调用方应提供派生数组。 */
export function createProjectionCache(){
let scope:object|undefined,projectId:Id|undefined,canvasId:Id|undefined;
let nodeArray:Projection['nodes']|undefined,edgeArray:Projection['edges']|undefined;
/** 数组长度与逐项引用完全一致时沿用旧数组,顺序改变也会产生新数组身份。 */
const stable=<T>(previous:T[]|undefined,next:T[])=>previous?.length===next.length&&next.every((value,index)=>value===previous[index])?previous:next;
let nodes=new Map<Id,Retained<Projection['nodes'][number]>>(),edges=new Map<Id,Retained<Projection['edges'][number]>>();
/** 对每个显示 ID 核验模型身份,再逐层共享等价字段;返回仅包含本帧元素的新缓存表。 */
function retain<T extends{id:Id}>(document:Document,values:T[],previous:Map<Id,Retained<T>>){
const next=new Map<Id,Retained<T>>();
for(let index=0;index<values.length;index++){
const value=values[index],record=document.elements.get(value.id);if(!record)throw fault('projection_identity_missing',[value.id]);
const old=previous.get(value.id),sameLifetime=old&&old.incarnation===record.incarnation&&old.ownerCanvasId===record.ownerCanvasId;
if(sameLifetime&&old.value===value){next.set(value.id,old);continue;}
let shared=value;
// 常见嵌套字段独立共享,使其它显示属性变化时也能保留未变化的 data/style/position。
if(sameLifetime)for(const key of ['data','style','position'] as const){const incoming=(shared as Record<string,unknown>)[key],before=(old.value as Record<string,unknown>)[key];if(Object.hasOwn(shared,key)&&Object.hasOwn(old.value,key)&&!Object.is(incoming,before)&&equal(incoming,before))shared={...shared,[key]:before};}
const retained=sameLifetime&&equal(old.value,shared)?old.value:shared;
values[index]=retained;next.set(value.id,{incarnation:record.incarnation,ownerCanvasId:record.ownerCanvasId,value:retained});
}
return next;
}
return Object.freeze({
/** 范围改变先整体清空;保存规范化后的对象表及稳定数组,返回包装后的投影。 */
reuse(owner:object,document:Document,viewCanvasId:Id,projection:Projection):Projection{
if(scope!==owner||projectId!==document.projectId||canvasId!==viewCanvasId){nodes.clear();edges.clear();nodeArray=undefined;edgeArray=undefined;scope=owner;projectId=document.projectId;canvasId=viewCanvasId;}
const nextNodes=retain(document,projection.nodes,nodes),nextEdges=retain(document,projection.edges,edges);
nodes=nextNodes;edges=nextEdges;nodeArray=stable(nodeArray,projection.nodes);edgeArray=stable(edgeArray,projection.edges);
return{...projection,nodes:nodeArray,edges:edgeArray};
},
/** 读取本轮保留的节点显示对象,供 renderer 测量等显示流程使用。 */
getNode(id:Id){return nodes.get(id)?.value;},
/** 只有上下文、生命周期、renderer type、尺寸和 data 均兼容才可沿用旧测量,不能只比较 ID。 */
canRetainMeasurement(owner:object,document:Document,viewCanvasId:Id,value:Projection['nodes'][number]){
const old=nodes.get(value.id),record=document.elements.get(value.id);
return scope===owner&&projectId===document.projectId&&canvasId===viewCanvasId&&!!old&&!!record&&old.incarnation===record.incarnation&&old.ownerCanvasId===record.ownerCanvasId&&old.value.type===value.type&&old.value.width===value.width&&old.value.height===value.height&&equal(old.value.data,value.data);
},
/** 清空对象/数组和范围身份,调用方无需也不应据此清除业务文档。 */
clear(){nodes.clear();edges.clear();nodeArray=undefined;edgeArray=undefined;scope=undefined;projectId=undefined;canvasId=undefined;},
});
}

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