@eventcatalog/core 5.0.0-beta.8 → 5.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (467) hide show
  1. package/LICENSE +234 -134
  2. package/README.md +5 -5
  3. package/dist/analytics/analytics.cjs +18 -3
  4. package/dist/analytics/analytics.d.cts +1 -1
  5. package/dist/analytics/analytics.d.ts +1 -1
  6. package/dist/analytics/analytics.js +3 -2
  7. package/dist/analytics/log-build.cjs +157 -76
  8. package/dist/analytics/log-build.d.cts +2 -2
  9. package/dist/analytics/log-build.d.ts +2 -2
  10. package/dist/analytics/log-build.js +7 -5
  11. package/dist/analytics/studio-usage.cjs +90 -0
  12. package/dist/analytics/studio-usage.d.cts +5 -0
  13. package/dist/analytics/studio-usage.d.ts +5 -0
  14. package/dist/analytics/studio-usage.js +10 -0
  15. package/dist/analytics/telemetry.cjs +42 -0
  16. package/dist/analytics/telemetry.d.cts +20 -0
  17. package/dist/analytics/telemetry.d.ts +20 -0
  18. package/dist/analytics/telemetry.js +8 -0
  19. package/dist/catalog-runtime.cjs +1 -1
  20. package/dist/{chunk-RFIXKATC.js → chunk-4G7FSRPN.js} +35 -42
  21. package/dist/{chunk-AWOO63ME.js → chunk-AD7CL772.js} +11 -4
  22. package/dist/chunk-CA6WCQ2T.js +54 -0
  23. package/dist/chunk-CPW6G62V.js +17 -0
  24. package/dist/{chunk-PU25TOOJ.js → chunk-JZN4RSMF.js} +1 -1
  25. package/dist/{chunk-4W55ZOOS.js → chunk-NEAVAFSJ.js} +66 -35
  26. package/dist/{chunk-C6T2AAFR.js → chunk-NIVST4WY.js} +9 -3
  27. package/dist/{chunk-ENAG6UH7.js → chunk-NKNOXQ62.js} +1 -6
  28. package/dist/{chunk-AYNZXUB5.js → chunk-OX6TSVHY.js} +5 -5
  29. package/dist/{chunk-36RYTYT5.js → chunk-PD7KTSYN.js} +1 -1
  30. package/dist/{chunk-RDIZJ7UT.js → chunk-RE7UTPWH.js} +1 -1
  31. package/dist/chunk-S5GWAUG5.js +29 -0
  32. package/dist/{chunk-T4G3VVZ4.js → chunk-YQGRMDRB.js} +4 -2
  33. package/dist/constants.cjs +1 -1
  34. package/dist/constants.js +1 -1
  35. package/dist/docs/api/02-config.md +57 -7
  36. package/dist/docs/development/00-why-eventcatalog.md +11 -11
  37. package/dist/docs/development/01-fundamentals.md +20 -13
  38. package/dist/docs/development/01-getting-started/configuration-overview.md +2 -2
  39. package/dist/docs/development/01-getting-started/installation.md +9 -1
  40. package/dist/docs/development/_getting-started.mdx +1 -1
  41. package/dist/docs/development/ask-your-architecture/01-intro.md +5 -5
  42. package/dist/docs/development/ask-your-architecture/02-eventcatalog-assistant/01-what-is-eventcatalog-assistant.md +0 -4
  43. package/dist/docs/development/ask-your-architecture/02-eventcatalog-assistant/02-configuration.md +15 -3
  44. package/dist/docs/development/ask-your-architecture/02-eventcatalog-assistant/03-bring-your-own-tools.md +0 -3
  45. package/dist/docs/development/ask-your-architecture/02-eventcatalog-assistant/_category_.json +1 -1
  46. package/dist/docs/development/ask-your-architecture/02-llms.txt.md +1 -1
  47. package/dist/docs/development/ask-your-architecture/{05-skills/01-introduction.md → 03-install-skills.md} +57 -7
  48. package/dist/docs/development/ask-your-architecture/03-mcp-server/_category_.json +1 -1
  49. package/dist/docs/development/ask-your-architecture/03-mcp-server/getting-started.md +86 -33
  50. package/dist/docs/development/ask-your-architecture/03-mcp-server/introduction.md +14 -4
  51. package/dist/docs/development/ask-your-architecture/03-schemas.txt.md +1 -1
  52. package/dist/docs/development/ask-your-architecture/04-agents/_category_.json +1 -1
  53. package/dist/docs/development/authentication/01-introduction.md +2 -23
  54. package/dist/docs/development/authentication/02-enabling-authentication.md +0 -20
  55. package/dist/docs/development/authentication/07-rbac-middleware.md +0 -2
  56. package/dist/docs/development/authentication/providers/03-setting-up-github.md +0 -2
  57. package/dist/docs/development/authentication/providers/03a-setting-up-google.md +0 -2
  58. package/dist/docs/development/authentication/providers/04-setting-up-azure-ad.md +0 -2
  59. package/dist/docs/development/authentication/providers/05-setting-up-okta.md +0 -3
  60. package/dist/docs/development/authentication/providers/06-setting-up-auth0.md +0 -2
  61. package/dist/docs/development/bring-your-own-documentation/01-introduction.md +0 -4
  62. package/dist/docs/development/bring-your-own-documentation/custom-pages/01-introduction.md +0 -3
  63. package/dist/docs/development/bring-your-own-documentation/custom-pages/02-adding-custom-docs.md +0 -17
  64. package/dist/docs/development/bring-your-own-documentation/custom-pages/03-components.md +0 -4
  65. package/dist/docs/development/bring-your-own-documentation/custom-pages/04-owners.md +0 -2
  66. package/dist/docs/development/bring-your-own-documentation/resource-docs/01-introduction.md +1 -3
  67. package/dist/docs/development/bring-your-own-documentation/resource-docs/02-adding-resource-docs.md +1 -4
  68. package/dist/docs/development/bring-your-own-documentation/resource-docs/03-categories.md +0 -2
  69. package/dist/docs/development/bring-your-own-documentation/resource-docs/04-versioning.md +1 -3
  70. package/dist/docs/development/components/components/11-flow.md +17 -0
  71. package/dist/docs/development/components/components/15-nodegraph.md +43 -10
  72. package/dist/docs/development/components/components/18-remote-schema.md +1 -4
  73. package/dist/docs/development/components/custom-components/07-reference.md +14 -0
  74. package/dist/docs/development/customization/02-themes.md +0 -5
  75. package/dist/docs/development/customization/03-search.md +1 -1
  76. package/dist/docs/development/customization/05-custom-pages-and-api-routes/01-introduction.md +0 -2
  77. package/dist/docs/development/customization/05-custom-pages-and-api-routes/04-reference.md +7 -1
  78. package/dist/docs/development/customization/05-resource-sidebar.md +1 -1
  79. package/dist/docs/development/customization/09-embed-eventcatalog.md +20 -25
  80. package/dist/docs/development/deployment/hosting-options.md +7 -2
  81. package/dist/docs/development/developer-tools/eventcatalog-linter/how-to/05-fix-common-problems.md +16 -0
  82. package/dist/docs/development/developer-tools/eventcatalog-linter/reference/03-rules.md +31 -1
  83. package/dist/docs/development/developer-tools/eventcatalog-linter/reference/05-supported-resources.md +1 -1
  84. package/dist/docs/development/governance/architecture-change-detection/01-introduction.md +0 -10
  85. package/dist/docs/development/governance/architecture-change-detection/06-pipeline-gates.md +0 -4
  86. package/dist/docs/development/guides/97-architecture-graph.md +1 -1
  87. package/dist/docs/development/guides/99-adding-analytics.md +0 -7
  88. package/dist/docs/development/guides/domains/03-model-subdomains.md +2 -2
  89. package/dist/docs/development/guides/domains/04-add-resources-to-domains/01-add-systems-to-domains.md +3 -1
  90. package/dist/docs/development/guides/domains/04-add-resources-to-domains/02-add-services-to-domains.md +5 -1
  91. package/dist/docs/development/guides/domains/04-add-resources-to-domains/03-add-messages-to-domains.md +2 -2
  92. package/dist/docs/development/guides/domains/04-add-resources-to-domains/05-add-entities-to-domains.md +1 -1
  93. package/dist/docs/development/guides/domains/04-add-resources-to-domains/06-domain-resources-page.md +40 -0
  94. package/dist/docs/development/guides/domains/06-visualize-domains/01-domain-diagram.md +89 -0
  95. package/dist/docs/development/guides/domains/06-visualize-domains/_category_.json +1 -2
  96. package/dist/docs/development/guides/domains/09-reference.md +4 -4
  97. package/dist/docs/development/guides/owners/automated-teams-and-users/create-your-own-connector.md +0 -2
  98. package/dist/docs/development/guides/owners/automated-teams-and-users/sync-from-github.md +0 -3
  99. package/dist/docs/development/guides/owners/automated-teams-and-users/sync-from-microsoft-entra.md +0 -2
  100. package/dist/docs/development/guides/resources/adrs/03-linking-resources.md +8 -0
  101. package/dist/docs/development/guides/resources/adrs/06-reference.md +4 -4
  102. package/dist/docs/development/guides/resources/agents/11-reference.md +1 -1
  103. package/dist/docs/development/guides/resources/data/04-reference.md +1 -1
  104. package/dist/docs/development/guides/resources/data-products/07-reference.md +1 -1
  105. package/dist/docs/development/guides/resources/entities/05-entity-maps.md +1 -1
  106. package/dist/docs/development/guides/resources/entities/06-reference.md +2 -2
  107. package/dist/docs/development/guides/resources/flows/01-introduction.md +11 -1
  108. package/dist/docs/development/guides/resources/flows/03-model-flow-steps.md +65 -6
  109. package/dist/docs/development/guides/resources/flows/05-explore-flows.md +112 -0
  110. package/dist/docs/development/guides/resources/flows/06-reference.md +25 -4
  111. package/dist/docs/development/guides/resources/messages/01-what-are-messages.md +19 -0
  112. package/dist/docs/development/guides/resources/messages/04-message-channels/01-introduction.md +16 -0
  113. package/dist/docs/development/guides/resources/messages/04-message-channels/05-reference.md +2 -1
  114. package/dist/docs/development/guides/resources/messages/10-reference.md +6 -3
  115. package/dist/docs/development/guides/resources/schemas/04-explore-schemas/01-schema-explorer.md +2 -2
  116. package/dist/docs/development/guides/resources/schemas/04-explore-schemas/02-fields-explorer.md +0 -9
  117. package/dist/docs/development/guides/resources/schemas/05-schema-api.md +0 -3
  118. package/dist/docs/development/guides/resources/schemas/06-schema-mcp.md +11 -3
  119. package/dist/docs/development/guides/resources/services/04-add-resources-to-services/03-add-entities-to-services.md +1 -1
  120. package/dist/docs/development/guides/resources/services/05-resources-page.md +50 -0
  121. package/dist/docs/development/guides/resources/services/08-reference.md +2 -1
  122. package/dist/docs/development/guides/systems/01-introduction.md +1 -1
  123. package/dist/docs/development/guides/systems/02-create-system.md +2 -2
  124. package/dist/docs/development/guides/systems/03-add-resources-to-systems.md +35 -9
  125. package/dist/docs/development/guides/systems/04-model-relationships-and-actors.md +5 -3
  126. package/dist/docs/development/guides/systems/05-system-context-maps.md +39 -22
  127. package/dist/docs/development/guides/systems/06-reference.md +4 -4
  128. package/dist/docs/development/license/01-license-file.md +151 -0
  129. package/dist/docs/development/license/_category_.json +12 -0
  130. package/dist/docs/development/license-keys/_category_.json +3 -3
  131. package/dist/docs/development/license-keys/integrations.md +7 -37
  132. package/dist/docs/development/license-keys/license-validation.md +4 -0
  133. package/dist/docs/development/license-keys/overview.md +5 -1
  134. package/dist/docs/development/license-keys/plans.md +4 -2
  135. package/dist/docs/development/studio/01-introduction.md +60 -0
  136. package/dist/docs/development/studio/02-create-a-canvas.md +103 -0
  137. package/dist/docs/development/studio/03-set-up-persistent-storage.md +59 -0
  138. package/dist/docs/development/studio/04-collaborate-on-a-canvas.md +78 -0
  139. package/dist/docs/development/studio/05-connect-your-agent.md +102 -0
  140. package/dist/docs/development/studio/06-run-studio-in-production.md +54 -0
  141. package/dist/docs/development/studio/07-reference.md +294 -0
  142. package/dist/docs/development/studio/_category_.json +12 -0
  143. package/dist/docs/development/upgrading/v5.md +345 -0
  144. package/dist/docs/federation/00-overview.md +2 -8
  145. package/dist/docs/federation/01-first-federation.md +6 -18
  146. package/dist/docs/federation/explanation/how-it-works.md +0 -4
  147. package/dist/docs/federation/explanation/lockfile-and-cache.md +0 -4
  148. package/dist/docs/federation/explanation/mvp-status-and-feedback.md +0 -4
  149. package/dist/docs/federation/explanation/ownership-and-references.md +0 -4
  150. package/dist/docs/federation/how-to/configure-github-sources.md +6 -5
  151. package/dist/docs/federation/how-to/configure-validation-rules.md +0 -4
  152. package/dist/docs/federation/how-to/run-in-ci.md +2 -9
  153. package/dist/docs/federation/how-to/share-assets-and-components.md +0 -4
  154. package/dist/docs/federation/how-to/use-local-sources.md +0 -4
  155. package/dist/docs/federation/legacy-federation/00-introduction.md +4 -22
  156. package/dist/docs/federation/legacy-federation/01-installation.md +7 -58
  157. package/dist/docs/federation/legacy-federation/02-plugin-configuration.md +3 -21
  158. package/dist/docs/federation/legacy-federation/03-examples.md +3 -5
  159. package/dist/docs/federation/legacy-federation/04-configuration.md +3 -8
  160. package/dist/docs/federation/legacy-federation/05-setup-team-catalog.md +3 -6
  161. package/dist/docs/federation/reference/cli.md +2 -8
  162. package/dist/docs/federation/reference/configuration.md +0 -4
  163. package/dist/docs/federation/reference/diagnostic-rules.md +0 -5
  164. package/dist/docs/federation/reference/generated-output.md +0 -4
  165. package/dist/docs/federation/reference/supported-content.md +0 -4
  166. package/dist/docs/federation/reference/troubleshooting.md +0 -14
  167. package/dist/docs/plugins/02-generators.md +1 -1
  168. package/dist/docs/plugins/amazon-apigateway/00-intro.md +3 -28
  169. package/dist/docs/plugins/amazon-apigateway/01-installation.md +4 -56
  170. package/dist/docs/plugins/amazon-apigateway/02-plugin-configuration.md +1 -6
  171. package/dist/docs/plugins/apicurio/00-intro.md +2 -31
  172. package/dist/docs/plugins/apicurio/01-installation.md +2 -26
  173. package/dist/docs/plugins/apicurio/02-plugin-configuration.md +5 -13
  174. package/dist/docs/plugins/apicurio/03-features.md +1 -3
  175. package/dist/docs/plugins/asyncapi/00-intro.md +2 -28
  176. package/dist/docs/plugins/asyncapi/01-installation.md +3 -54
  177. package/dist/docs/plugins/asyncapi/02-plugin-configuration.md +2 -1
  178. package/dist/docs/plugins/aws-glue-registry/00-intro.md +4 -32
  179. package/dist/docs/plugins/aws-glue-registry/00a-installation.md +3 -53
  180. package/dist/docs/plugins/azure-schema-registry/00-intro.md +4 -32
  181. package/dist/docs/plugins/azure-schema-registry/01-installation.md +6 -33
  182. package/dist/docs/plugins/azure-schema-registry/02-plugin-configuration.md +1 -14
  183. package/dist/docs/plugins/azure-schema-registry/04-examples.md +0 -2
  184. package/dist/docs/plugins/backstage/00-overview.md +1 -7
  185. package/dist/docs/plugins/backstage/01-get-started.md +6 -15
  186. package/dist/docs/plugins/backstage/06-configuration.md +2 -8
  187. package/dist/docs/plugins/confluent-schema-registry/00-intro.md +2 -29
  188. package/dist/docs/plugins/confluent-schema-registry/01-installation.md +2 -26
  189. package/dist/docs/plugins/eventbridge/00-intro.md +0 -4
  190. package/dist/docs/plugins/eventbridge/00a-installation.md +1 -87
  191. package/dist/docs/plugins/eventbridge/03-api.md +2 -20
  192. package/dist/docs/plugins/github/00-intro.md +0 -31
  193. package/dist/docs/plugins/github/01-installation.md +2 -34
  194. package/dist/docs/plugins/graphql/00-intro.md +0 -30
  195. package/dist/docs/plugins/graphql/01-installation.md +1 -53
  196. package/dist/docs/plugins/graphql/02-plugin-configuration.md +0 -1
  197. package/dist/docs/plugins/hookdeck/01-intro.md +1 -1
  198. package/dist/docs/plugins/openapi/00-intro.md +0 -30
  199. package/dist/docs/plugins/openapi/01-installation.md +1 -57
  200. package/dist/docs/plugins/openapi/02-plugin-configuration.md +0 -5
  201. package/dist/docs/plugins/openapi/03-features.md +0 -4
  202. package/dist/eventcatalog-config-file-utils.cjs +34 -41
  203. package/dist/eventcatalog-config-file-utils.d.cts +56 -58
  204. package/dist/eventcatalog-config-file-utils.d.ts +56 -58
  205. package/dist/eventcatalog-config-file-utils.js +1 -1
  206. package/dist/eventcatalog.cjs +408 -281
  207. package/dist/eventcatalog.config.d.cts +26 -15
  208. package/dist/eventcatalog.config.d.ts +26 -15
  209. package/dist/eventcatalog.js +46 -27
  210. package/dist/features.cjs +36 -49
  211. package/dist/features.d.cts +1 -3
  212. package/dist/features.d.ts +1 -3
  213. package/dist/features.js +4 -6
  214. package/dist/federation/federate.cjs +37 -44
  215. package/dist/federation/federate.js +5 -5
  216. package/dist/federation/source-provider.js +2 -2
  217. package/dist/generate.cjs +37 -42
  218. package/dist/generate.js +4 -4
  219. package/dist/resolve-catalog-dependencies.cjs +34 -41
  220. package/dist/resolve-catalog-dependencies.js +2 -2
  221. package/dist/runtime-dependencies/@eventcatalog__visualiser__studio-fonts.css.mjs +3 -0
  222. package/dist/runtime-dependencies/@eventcatalog__visualiser__studio.css.mjs +3 -0
  223. package/dist/runtime-dependencies/@eventcatalog__visualiser__studio.mjs +3 -0
  224. package/dist/runtime-dependencies/htmlparser2.mjs +3 -0
  225. package/dist/runtime-dependencies/manifest.json +5 -1
  226. package/dist/runtime-dependencies/react-syntax-highlighter__dist__cjs__create-element.js.mjs +3 -0
  227. package/dist/utils/child-process-signals.cjs +53 -0
  228. package/dist/utils/child-process-signals.d.cts +19 -0
  229. package/dist/utils/child-process-signals.d.ts +19 -0
  230. package/dist/utils/child-process-signals.js +6 -0
  231. package/dist/utils/cli-logger.cjs +1 -1
  232. package/dist/utils/cli-logger.js +2 -2
  233. package/dist/utils/license-status.cjs +11 -4
  234. package/dist/utils/license-status.d.cts +3 -2
  235. package/dist/utils/license-status.d.ts +3 -2
  236. package/dist/utils/license-status.js +1 -1
  237. package/eventcatalog/astro.config.mjs +21 -1
  238. package/eventcatalog/integrations/config-loader.mjs +56 -58
  239. package/eventcatalog/integrations/skip-startup-reload.mjs +31 -0
  240. package/eventcatalog/integrations/stable-dependency-order.mjs +29 -0
  241. package/eventcatalog/integrations/storage.mjs +20 -0
  242. package/eventcatalog/integrations/studio-server.mjs +4 -1
  243. package/eventcatalog/src/components/CatalogGraph/CatalogForceGraph.tsx +39 -0
  244. package/eventcatalog/src/components/ChatPanel/ChatPanel.tsx +2 -0
  245. package/eventcatalog/src/components/Homepage/CatalogDashboard.astro +342 -0
  246. package/eventcatalog/src/components/MDX/NodeGraph/AstroNodeGraph.tsx +60 -4
  247. package/eventcatalog/src/components/MDX/NodeGraph/NodeGraph.astro +5 -1
  248. package/eventcatalog/src/components/NavigationProgress.astro +7 -0
  249. package/eventcatalog/src/components/SchemaExplorer/ApiContentViewer.tsx +2 -0
  250. package/eventcatalog/src/components/SchemaExplorer/ExamplesViewer.tsx +2 -0
  251. package/eventcatalog/src/components/SchemaExplorer/SchemaCodeModal.tsx +2 -0
  252. package/eventcatalog/src/components/SchemaExplorer/SchemaContentViewer.tsx +2 -0
  253. package/eventcatalog/src/components/Search/SearchDataLoader.astro +16 -28
  254. package/eventcatalog/src/components/Search/SearchModal.tsx +2 -10
  255. package/eventcatalog/src/components/Search/search-utils.ts +26 -0
  256. package/eventcatalog/src/components/Seo.astro +5 -1
  257. package/eventcatalog/src/components/Settings/AssistantSettingsForm.tsx +53 -48
  258. package/eventcatalog/src/components/Settings/GeneralSettingsForm.tsx +183 -178
  259. package/eventcatalog/src/components/Settings/LicenseSettings.tsx +140 -31
  260. package/eventcatalog/src/components/Settings/LlmAccessSettingsForm.tsx +40 -35
  261. package/eventcatalog/src/components/Settings/LogoUpload.tsx +1 -1
  262. package/eventcatalog/src/components/Settings/McpSettingsForm.tsx +9 -8
  263. package/eventcatalog/src/components/Settings/Row.tsx +7 -4
  264. package/eventcatalog/src/components/Settings/SettingsShared.tsx +3 -3
  265. package/eventcatalog/src/components/SideNav/NestedSideBar/index.tsx +37 -76
  266. package/eventcatalog/src/components/SideNav/NestedSideBar/storage.ts +2 -0
  267. package/eventcatalog/src/components/SideNav/NestedSideBar/utils.ts +122 -1
  268. package/eventcatalog/src/components/SideNav/SideNav.astro +9 -1
  269. package/eventcatalog/src/components/TrialBadge.astro +20 -9
  270. package/eventcatalog/src/content.config.ts +19 -2
  271. package/eventcatalog/src/features/auth/error.astro +4 -2
  272. package/eventcatalog/src/features/auth/login.astro +2 -1
  273. package/eventcatalog/src/features/auth/middleware/middleware-auth.ts +21 -1
  274. package/eventcatalog/src/features/auth/unauthorized.astro +1 -1
  275. package/eventcatalog/src/features/fields/pages/fields.astro +1 -1
  276. package/eventcatalog/src/features/integrations/eventcatalog-features.ts +13 -7
  277. package/eventcatalog/src/features/mcp/apps/architecture-diagram/node-chat.tsx +6 -45
  278. package/eventcatalog/src/features/mcp/apps/canvas/shared.ts +3 -3
  279. package/eventcatalog/src/features/mcp/apps/canvas/styles.css +3 -1
  280. package/eventcatalog/src/features/mcp/apps/canvas/view.tsx +7 -5
  281. package/eventcatalog/src/features/mcp/apps/generated/canvas.html +123 -98
  282. package/eventcatalog/src/features/mcp/apps/generated/viewer.html +118 -101
  283. package/eventcatalog/src/features/mcp/mcp-server.ts +20 -4
  284. package/eventcatalog/src/features/server/start.ts +25 -0
  285. package/eventcatalog/src/features/storage/database.ts +88 -0
  286. package/eventcatalog/src/features/storage/migrations.ts +85 -0
  287. package/eventcatalog/src/features/storage/sql/database.sql +5 -0
  288. package/eventcatalog/src/features/storage/sql/migrations/0001-studio-canvases.sql +12 -0
  289. package/eventcatalog/src/features/storage/sql/migrations.sql +15 -0
  290. package/eventcatalog/src/features/storage/sql.ts +28 -0
  291. package/eventcatalog/src/features/studio/agent-choreography.ts +13 -10
  292. package/eventcatalog/src/features/studio/agents.ts +28 -0
  293. package/eventcatalog/src/features/studio/api/canvases-api.ts +219 -0
  294. package/eventcatalog/src/features/studio/api/canvases.ts +25 -0
  295. package/eventcatalog/src/features/studio/api/client.ts +40 -0
  296. package/eventcatalog/src/features/studio/canvas-actions.ts +160 -34
  297. package/eventcatalog/src/features/studio/canvas-preview.ts +1 -2
  298. package/eventcatalog/src/features/studio/catalog.ts +16 -14
  299. package/eventcatalog/src/features/studio/components/CanvasHeader.tsx +274 -105
  300. package/eventcatalog/src/features/studio/components/CanvasStatusMenu.tsx +15 -14
  301. package/eventcatalog/src/features/studio/components/ConnectAgentDialog.tsx +272 -119
  302. package/eventcatalog/src/features/studio/components/ConnectionsChooser.tsx +1 -2
  303. package/eventcatalog/src/features/studio/components/DeleteCanvasDialog.tsx +65 -0
  304. package/eventcatalog/src/features/studio/components/Dialog.tsx +60 -0
  305. package/eventcatalog/src/features/studio/components/JoinForm.tsx +101 -26
  306. package/eventcatalog/src/features/studio/components/LeftPanel.tsx +122 -230
  307. package/eventcatalog/src/features/studio/components/NodeDetails.tsx +183 -0
  308. package/eventcatalog/src/features/studio/components/OpenInStudioDialog.tsx +79 -0
  309. package/eventcatalog/src/features/studio/components/Presence.tsx +52 -45
  310. package/eventcatalog/src/features/studio/components/ShareDialog.tsx +277 -141
  311. package/eventcatalog/src/features/studio/components/ShortcutsDialog.tsx +41 -0
  312. package/eventcatalog/src/features/studio/components/StudioDesigner.tsx +1314 -385
  313. package/eventcatalog/src/features/studio/components/StudioNotice.astro +24 -0
  314. package/eventcatalog/src/features/studio/document-name.ts +7 -0
  315. package/eventcatalog/src/features/studio/from-visualiser.ts +138 -0
  316. package/eventcatalog/src/features/studio/hooks/presence-sender.ts +19 -2
  317. package/eventcatalog/src/features/studio/hooks/use-canvas-webmcp.ts +9 -3
  318. package/eventcatalog/src/features/studio/hooks/use-comments.ts +2 -2
  319. package/eventcatalog/src/features/studio/hooks/use-follow-camera.ts +8 -3
  320. package/eventcatalog/src/features/studio/hooks/use-studio-flow.ts +244 -32
  321. package/eventcatalog/src/features/studio/identity.ts +25 -1
  322. package/eventcatalog/src/features/studio/limits.ts +2 -0
  323. package/eventcatalog/src/features/studio/pages/[id].astro +42 -16
  324. package/eventcatalog/src/features/studio/pages/index.astro +596 -373
  325. package/eventcatalog/src/features/studio/pages/webmcp-relay.ts +12 -15
  326. package/eventcatalog/src/features/studio/server/canvas-mcp.ts +8 -23
  327. package/eventcatalog/src/features/studio/server/runtime.ts +150 -46
  328. package/eventcatalog/src/features/studio/server/sql/storage.sql +18 -0
  329. package/eventcatalog/src/features/studio/server/storage.ts +53 -73
  330. package/eventcatalog/src/features/studio/webmcp-relay/LICENSE +21 -0
  331. package/eventcatalog/src/features/studio/webmcp-relay/README.md +5 -0
  332. package/eventcatalog/src/features/studio/webmcp-relay/embed.js +2 -0
  333. package/eventcatalog/src/features/studio/webmcp-relay/widget.html +10 -0
  334. package/eventcatalog/src/features/studio/webmcp-relay/widget.js +1 -0
  335. package/eventcatalog/src/features/tools/catalog-tools.ts +3 -1
  336. package/eventcatalog/src/layouts/BaseLayout.astro +4 -4
  337. package/eventcatalog/src/layouts/SettingsLayout.astro +2 -2
  338. package/eventcatalog/src/layouts/VerticalSideBarLayout.astro +3 -2
  339. package/eventcatalog/src/pages/_index.astro +5 -342
  340. package/eventcatalog/src/pages/architecture/[type]/[id]/[version]/_index.data.spec.ts +2 -3
  341. package/eventcatalog/src/pages/architecture/[type]/[id]/[version]/_index.data.ts +0 -7
  342. package/eventcatalog/src/pages/architecture/[type]/[id]/[version]/index.astro +3 -1
  343. package/eventcatalog/src/pages/diagrams/[id]/[version]/_index.data.ts +0 -7
  344. package/eventcatalog/src/pages/diagrams/[id]/[version]/embed.astro +1 -0
  345. package/eventcatalog/src/pages/diagrams/[id]/[version]/index.astro +1 -0
  346. package/eventcatalog/src/pages/directory/[type]/_index.data.ts +5 -7
  347. package/eventcatalog/src/pages/directory/[type]/index.astro +3 -1
  348. package/eventcatalog/src/pages/discover/[type]/_index.data.ts +5 -7
  349. package/eventcatalog/src/pages/discover/[type]/index.astro +3 -1
  350. package/eventcatalog/src/pages/docs/[type]/[id]/[version]/[docType]/[docId]/[docVersion]/_index.data.ts +0 -7
  351. package/eventcatalog/src/pages/docs/[type]/[id]/[version]/[docType]/[docId]/[docVersion]/index.astro +1 -0
  352. package/eventcatalog/src/pages/docs/[type]/[id]/[version]/[docType]/[docId]/_index.data.ts +0 -7
  353. package/eventcatalog/src/pages/docs/[type]/[id]/[version]/[docType]/[docId]/index.astro +1 -0
  354. package/eventcatalog/src/pages/docs/[type]/[id]/[version]/_index.data.ts +0 -7
  355. package/eventcatalog/src/pages/docs/[type]/[id]/[version]/asyncapi/[filename].astro +3 -1
  356. package/eventcatalog/src/pages/docs/[type]/[id]/[version]/asyncapi/_[filename].data.ts +0 -7
  357. package/eventcatalog/src/pages/docs/[type]/[id]/[version]/changelog/_index.data.ts +0 -7
  358. package/eventcatalog/src/pages/docs/[type]/[id]/[version]/changelog/index.astro +1 -0
  359. package/eventcatalog/src/pages/docs/[type]/[id]/[version]/examples/[...filename].astro +1 -0
  360. package/eventcatalog/src/pages/docs/[type]/[id]/[version]/examples/_[...filename].data.ts +0 -4
  361. package/eventcatalog/src/pages/docs/[type]/[id]/[version]/field-lineage/_index.data.ts +0 -7
  362. package/eventcatalog/src/pages/docs/[type]/[id]/[version]/field-lineage/index.astro +1 -0
  363. package/eventcatalog/src/pages/docs/[type]/[id]/[version]/graphql/[filename].astro +3 -1
  364. package/eventcatalog/src/pages/docs/[type]/[id]/[version]/graphql/_[filename].data.ts +0 -7
  365. package/eventcatalog/src/pages/docs/[type]/[id]/[version]/index.astro +1 -0
  366. package/eventcatalog/src/pages/docs/[type]/[id]/[version]/resources/_index.data.spec.ts +2 -3
  367. package/eventcatalog/src/pages/docs/[type]/[id]/[version]/resources/_index.data.ts +0 -7
  368. package/eventcatalog/src/pages/docs/[type]/[id]/[version]/resources/index.astro +1 -0
  369. package/eventcatalog/src/pages/docs/[type]/[id]/[version]/spec/[filename].astro +1 -0
  370. package/eventcatalog/src/pages/docs/[type]/[id]/[version]/spec/_[filename].data.ts +0 -7
  371. package/eventcatalog/src/pages/docs/[type]/[id]/_index.data.ts +0 -7
  372. package/eventcatalog/src/pages/docs/[type]/[id]/index.astro +2 -1
  373. package/eventcatalog/src/pages/docs/[type]/[id]/language/[dictionaryId]/_index.data.ts +0 -7
  374. package/eventcatalog/src/pages/docs/[type]/[id]/language/[dictionaryId]/index.astro +1 -0
  375. package/eventcatalog/src/pages/docs/[type]/[id]/language/_index.data.ts +0 -7
  376. package/eventcatalog/src/pages/docs/[type]/[id]/language/index.astro +1 -0
  377. package/eventcatalog/src/pages/docs/custom/[...path]/_index.data.ts +0 -7
  378. package/eventcatalog/src/pages/docs/custom/[...path]/index.astro +1 -0
  379. package/eventcatalog/src/pages/docs/services/[id]/[docType]/[docId]/index.astro +1 -1
  380. package/eventcatalog/src/pages/docs/services/[id]/[version]/resources/index.astro +1 -0
  381. package/eventcatalog/src/pages/docs/services/[id]/asyncapi/[filename].astro +1 -1
  382. package/eventcatalog/src/pages/docs/services/[id]/changelog/index.astro +1 -1
  383. package/eventcatalog/src/pages/docs/services/[id]/graphql/[filename].astro +1 -1
  384. package/eventcatalog/src/pages/docs/services/[id]/spec/[filename].astro +1 -1
  385. package/eventcatalog/src/pages/docs/teams/[id]/_index.data.ts +0 -7
  386. package/eventcatalog/src/pages/docs/teams/[id]/index.astro +1 -0
  387. package/eventcatalog/src/pages/docs/users/[id]/_index.data.ts +0 -7
  388. package/eventcatalog/src/pages/docs/users/[id]/index.astro +1 -0
  389. package/eventcatalog/src/pages/index.astro +4 -2
  390. package/eventcatalog/src/pages/schemas/[type]/[id]/[version]/_index.data.ts +0 -7
  391. package/eventcatalog/src/pages/schemas/[type]/[id]/[version]/index.astro +9 -1
  392. package/eventcatalog/src/pages/schemas/explorer/_index.data.ts +0 -7
  393. package/eventcatalog/src/pages/schemas/explorer/index.astro +4 -2
  394. package/eventcatalog/src/pages/settings/license.astro +9 -1
  395. package/eventcatalog/src/pages/triggers/[type]/[id]/[version]/_index.data.ts +0 -7
  396. package/eventcatalog/src/pages/triggers/[type]/[id]/[version]/index.astro +1 -0
  397. package/eventcatalog/src/pages/visualiser/[type]/[id]/[version]/_index.data.ts +0 -7
  398. package/eventcatalog/src/pages/visualiser/[type]/[id]/[version]/data/_index.data.ts +0 -7
  399. package/eventcatalog/src/pages/visualiser/[type]/[id]/[version]/data/index.astro +1 -0
  400. package/eventcatalog/src/pages/visualiser/[type]/[id]/[version]/entity-map/_index.data.ts +0 -7
  401. package/eventcatalog/src/pages/visualiser/[type]/[id]/[version]/entity-map/index.astro +1 -0
  402. package/eventcatalog/src/pages/visualiser/[type]/[id]/[version]/index.astro +1 -0
  403. package/eventcatalog/src/pages/visualiser/[type]/[id]/_index.data.ts +38 -0
  404. package/eventcatalog/src/pages/visualiser/[type]/[id]/index.astro +18 -41
  405. package/eventcatalog/src/pages/visualiser/designs/[id]/_index.data.ts +0 -7
  406. package/eventcatalog/src/pages/visualiser/designs/[id]/index.astro +2 -1
  407. package/eventcatalog/src/pages/visualiser/domains/[id]/[version]/entity-map/_index.data.ts +0 -7
  408. package/eventcatalog/src/pages/visualiser/domains/[id]/[version]/entity-map/index.astro +1 -0
  409. package/eventcatalog/src/pages/visualiser/domains/[id]/[version]/index.astro +1 -0
  410. package/eventcatalog/src/pages/visualiser/domains/[id]/[version]/systems-context/_index.data.ts +0 -7
  411. package/eventcatalog/src/pages/visualiser/domains/[id]/[version]/systems-context/index.astro +1 -0
  412. package/eventcatalog/src/pages/visualiser/systems/[id]/[version]/context/_index.data.ts +0 -7
  413. package/eventcatalog/src/pages/visualiser/systems/[id]/[version]/context/index.astro +1 -0
  414. package/eventcatalog/src/pages/visualiser/systems/[id]/[version]/index.astro +1 -0
  415. package/eventcatalog/src/types/react-syntax-highlighter.d.ts +10 -0
  416. package/eventcatalog/src/utils/collections/flows.ts +20 -7
  417. package/eventcatalog/src/utils/collections/resource-owners.ts +8 -2
  418. package/eventcatalog/src/utils/collections/resources.ts +2 -0
  419. package/eventcatalog/src/utils/collections/util.ts +8 -4
  420. package/eventcatalog/src/utils/git.ts +11 -0
  421. package/eventcatalog/src/utils/homepage-dashboard.ts +143 -0
  422. package/eventcatalog/src/utils/license.ts +1 -1
  423. package/eventcatalog/src/utils/link-validation.ts +25 -19
  424. package/eventcatalog/src/utils/node-graphs/domain-entity-map.ts +3 -1
  425. package/eventcatalog/src/utils/node-graphs/domain-levels-node-graph.ts +3 -3
  426. package/eventcatalog/src/utils/node-graphs/field-node-graph.ts +4 -2
  427. package/eventcatalog/src/utils/node-graphs/layout-node-graph.ts +20 -1
  428. package/eventcatalog/src/utils/node-graphs/node-resource.ts +67 -0
  429. package/eventcatalog/src/utils/node-graphs/systems-node-graph.ts +1 -1
  430. package/eventcatalog/src/utils/page-loaders/hybrid-page.ts +4 -15
  431. package/eventcatalog/src/utils/page-title.ts +15 -0
  432. package/eventcatalog/src/utils/syntax-highlighter-renderer.ts +64 -0
  433. package/eventcatalog/src/utils/trial.ts +13 -0
  434. package/package.json +11 -11
  435. package/dist/docs/development/ask-your-architecture/05-skills/02-installation.md +0 -60
  436. package/dist/docs/development/ask-your-architecture/05-skills/_category_.json +0 -11
  437. package/dist/docs/development/ask-your-architecture/06-slack-integration/01-introduction.md +0 -63
  438. package/dist/docs/development/ask-your-architecture/06-slack-integration/02-slack-app-setup.md +0 -154
  439. package/dist/docs/development/ask-your-architecture/06-slack-integration/03-installation.md +0 -169
  440. package/dist/docs/development/ask-your-architecture/06-slack-integration/04-deployment.md +0 -236
  441. package/dist/docs/development/ask-your-architecture/06-slack-integration/05-usage.md +0 -140
  442. package/dist/docs/development/ask-your-architecture/06-slack-integration/06-troubleshooting.md +0 -268
  443. package/dist/docs/development/ask-your-architecture/06-slack-integration/_category_.json +0 -13
  444. package/dist/docs/development/guides/domains/06-visualize-domains/01-domain-integration-map.md +0 -40
  445. package/dist/runtime-dependencies/@mcp-b__webmcp-local-relay.mjs +0 -3
  446. package/eventcatalog/src/features/studio/canvas-doc.ts +0 -409
  447. package/eventcatalog/src/features/studio/clipboard.ts +0 -97
  448. package/eventcatalog/src/features/studio/components/CanvasControls.tsx +0 -154
  449. package/eventcatalog/src/features/studio/components/Comments.tsx +0 -770
  450. package/eventcatalog/src/features/studio/components/ContainerChooser.tsx +0 -145
  451. package/eventcatalog/src/features/studio/components/DragGhost.tsx +0 -115
  452. package/eventcatalog/src/features/studio/components/Picture.tsx +0 -21
  453. package/eventcatalog/src/features/studio/components/PropertiesPanel.tsx +0 -212
  454. package/eventcatalog/src/features/studio/components/canvas-nodes.tsx +0 -255
  455. package/eventcatalog/src/features/studio/components/icons.ts +0 -53
  456. package/eventcatalog/src/features/studio/components/status.ts +0 -51
  457. package/eventcatalog/src/features/studio/edges.ts +0 -56
  458. package/eventcatalog/src/features/studio/flow-state.ts +0 -92
  459. package/eventcatalog/src/features/studio/grouping.ts +0 -226
  460. package/eventcatalog/src/features/studio/layout.ts +0 -42
  461. package/eventcatalog/src/features/studio/levels.ts +0 -282
  462. package/eventcatalog/src/features/studio/node-types.ts +0 -205
  463. package/eventcatalog/src/features/studio/pages/new.astro +0 -24
  464. package/eventcatalog/src/features/studio/pages/wake.ts +0 -15
  465. package/eventcatalog/src/features/studio/placement.ts +0 -197
  466. package/eventcatalog/src/features/studio/studio.css +0 -273
  467. package/dist/{chunk-A2RZR3U4.js → chunk-6K7XZAYI.js} +3 -3
@@ -1,20 +1,104 @@
1
1
  import { useCallback, useEffect, useMemo, useRef, useState, type DragEvent, type MouseEvent as ReactMouseEvent } from 'react';
2
2
  import {
3
- Background,
3
+ applyNodeChanges,
4
4
  ConnectionLineType,
5
5
  ReactFlow,
6
6
  ReactFlowProvider,
7
7
  useReactFlow,
8
+ type Connection,
8
9
  type Edge,
9
10
  type Node,
11
+ type FitViewOptions,
10
12
  type NodeChange,
11
13
  type XYPosition,
12
14
  } from '@xyflow/react';
13
- import { edgeTypes } from '@eventcatalog/visualiser';
15
+ import { DiagramBackground, edgeTypes, getLegend, LegendPanel, pauseEdgeAnimations } from '@eventcatalog/visualiser';
14
16
  import { useHotkeys } from 'react-hotkeys-hook';
15
17
  import { getCatalogConnections, indexCatalog, planWithConnections, type ConnectionGroup } from '../canvas-actions';
16
- import { isDecided } from '../canvas-doc';
17
- import { CLIPBOARD_TYPE, copyNodes, pasteNodes, readClipboard, type ClipboardContent } from '../clipboard';
18
+ import {
19
+ getDiagramLevel,
20
+ getDiagramLevelUnavailable,
21
+ isDecided,
22
+ type LevelPlace,
23
+ CLIPBOARD_TYPE,
24
+ copyNodes,
25
+ pasteNodes,
26
+ readClipboard,
27
+ type ClipboardContent,
28
+ keepUnchanged,
29
+ LOCAL_EDGE_KEYS,
30
+ LOCAL_NODE_KEYS,
31
+ findDropTargets,
32
+ placeDropped,
33
+ containerFor,
34
+ findGroupAtPoint,
35
+ getAbsolutePosition,
36
+ getAbsoluteRect,
37
+ previewContainerGrowth,
38
+ sizeOf,
39
+ sortByHierarchy,
40
+ toRelativePosition,
41
+ getLayoutPositions,
42
+ layoutGraph,
43
+ getLevelGraph,
44
+ getLevelUnavailableReason,
45
+ LEVELS,
46
+ type Level,
47
+ arrangeLevel,
48
+ getLevelNodeSize,
49
+ levelPlaces,
50
+ withDiagramRoutes,
51
+ canGoInContainer,
52
+ getNodeDefinition,
53
+ getNodeLabel,
54
+ getNodeName,
55
+ getNoteLevel,
56
+ isOnLevel,
57
+ isShownOnLevel,
58
+ isWrittenOnCanvas,
59
+ getResource,
60
+ DropTargetContext,
61
+ nodeTypes,
62
+ UpdateNodeDataContext,
63
+ anchorAt,
64
+ ContextMenu,
65
+ canvasToModel,
66
+ modelToCanvas,
67
+ toDiagramFile,
68
+ readDiagramFile,
69
+ pickDiagramFile,
70
+ downloadFile,
71
+ fileNameFor,
72
+ DIAGRAM_FILE_EXTENSION,
73
+ canvasImage,
74
+ EmptyCanvas,
75
+ type ModelCanvasContent,
76
+ CommentLayer,
77
+ CommentList,
78
+ getAnchorPosition,
79
+ timeAgo,
80
+ type ContextMenuState,
81
+ CANVAS_STATUS_LOOK,
82
+ ContainerChooser,
83
+ type ContainerChoice,
84
+ CanvasControls,
85
+ ViewControls,
86
+ FIT_VIEW_OPTIONS,
87
+ EditButton,
88
+ LockedContext,
89
+ StartWritingContext,
90
+ EditingContext,
91
+ HotspotLayer,
92
+ HotspotList,
93
+ topSeverity,
94
+ isGroupType,
95
+ type Hotspot,
96
+ type HotspotDraft,
97
+ type HotspotFields,
98
+ type HotspotSeverity,
99
+ type HotspotSpot,
100
+ } from '@eventcatalog/visualiser/studio';
101
+ import { X } from 'lucide-react';
18
102
  import type { CatalogRelation, CatalogResource } from '../catalog-resources';
19
103
  import {
20
104
  buildContainer,
@@ -25,37 +109,23 @@ import {
25
109
  getContents,
26
110
  getRelatedEdges,
27
111
  } from '../catalog';
28
- import {
29
- findDropTarget,
30
- findGroupAtPoint,
31
- getAbsolutePosition,
32
- previewContainerGrowth,
33
- sizeOf,
34
- sortByHierarchy,
35
- toRelativePosition,
36
- } from '../grouping';
37
112
  import type { Peer } from '../hooks/presence-store';
38
113
  import { useCanvasWebMcp } from '../hooks/use-canvas-webmcp';
39
114
  import { useStudioFlow } from '../hooks/use-studio-flow';
40
115
  import { useComments, type CommentAnchor, type Thread } from '../hooks/use-comments';
41
- import { getLayoutPositions, layoutGraph } from '../layout';
42
- import { getLevelGraph, getLevelUnavailableReason, LEVELS, type Level } from '../levels';
43
- import { getNodeDefinition, getNodeLabel, getNodeName, getNoteLevel, isOnLevel, isWrittenOnCanvas } from '../node-types';
44
116
  import type { ToolSync } from '../tool-sync';
45
- import CanvasControls, { FIT_VIEW_OPTIONS } from './CanvasControls';
46
117
  import CanvasHeader from './CanvasHeader';
47
- import { DropTargetContext, nodeTypes, UpdateNodeDataContext } from './canvas-nodes';
48
- import { anchorAt, CanvasContextMenu, CommentLayer, getAnchorPosition, timeAgo, type ContextMenuState } from './Comments';
49
- import { CANVAS_STATUS_LOOK } from './status';
50
118
  import ConnectAgentDialog, { enableWebMcp, isRelayEnabled, isWebMcpEnabled, loadRelay } from './ConnectAgentDialog';
51
- import ContainerChooser, { type ContainerChoice } from './ContainerChooser';
52
119
  import ConnectionsChooser, { type ConnectionsChoice } from './ConnectionsChooser';
53
120
  import JoinForm from './JoinForm';
54
- import { colorForName, getStoredName, onStoredNameChange, storeName } from '../identity';
55
- import LeftPanel, { CATALOG_DRAG_TYPE, COMPONENT_DRAG_TYPE } from './LeftPanel';
121
+ import { colorForName, getStoredColor, getStoredName, onStoredNameChange, storeColor, storeName } from '../identity';
122
+ import LeftPanel, { CATALOG_DRAG_TYPE, COMPONENT_DRAG_TYPE, type LeftPanelTab } from './LeftPanel';
56
123
  import { AgentActivity, RemotePresence, ViewportSharer } from './Presence';
57
- import PropertiesPanel from './PropertiesPanel';
124
+ import NodeDetails, { type NodeConnection } from './NodeDetails';
58
125
  import ShareDialog from './ShareDialog';
126
+ import DeleteCanvasDialog from './DeleteCanvasDialog';
127
+ import { createCanvasThroughApi, deleteCanvasThroughApi } from '../api/client';
128
+ import ShortcutsDialog from './ShortcutsDialog';
59
129
 
60
130
  type CanvasProps = {
61
131
  canvasId: string;
@@ -64,22 +134,28 @@ type CanvasProps = {
64
134
  socketUrl?: string;
65
135
  /** Where "New canvas" goes (hidden when not given, e.g. inside a chat) */
66
136
  newCanvasUrl?: string;
137
+ /** The Studio page (where you go after deleting the canvas, or finding it deleted) */
138
+ studioUrl?: string;
139
+ /** The Studio API's canvases on this site: deleting the canvas is offered when given (not inside a chat) */
140
+ canvasesApiUrl?: string;
67
141
  /** The canvas's link, for Share (defaults to this page) */
68
142
  shareUrl?: string;
69
143
  /** The EventCatalog MCP server agents connect to (a path on this site, or a URL) */
70
144
  mcpUrl?: string;
71
145
  /** The WebMCP local relay's script, when this page can offer it (not inside a chat) */
72
146
  relayScriptPath?: string;
73
- /** Canvases are only kept in memory, until the server restarts (`studio.storage` is "memory") */
147
+ /** Canvases are only kept in memory, until the server restarts (`storage` is "memory") */
74
148
  keptInMemory?: boolean;
75
149
  /** Sync through MCP tool calls when the WebSocket can't connect (e.g. inside a chat's sandbox) */
76
150
  syncViaTools?: ToolSync;
77
151
  /** Follow agents' changes with the camera from the start (e.g. inside a chat, where you watch an agent work) */
78
152
  followChanges?: boolean;
153
+ /** Starts with the palette folded away (e.g. in a chat, where there's little room) */
154
+ paletteCollapsed?: boolean;
79
155
  /** Told what's selected (e.g. a chat attaches it to the conversation, so you can ask about it) */
80
156
  onSelectionChange?: (selected: SelectedNode[]) => void;
81
- /** Join with this name instead of asking for one (e.g. inside a chat, where the agent or host knows who it is) */
82
- defaultName?: string;
157
+ /** Join as this AI agent, with its name, colour and logo, instead of asking who you are (e.g. inside a chat, as the chat's agent) */
158
+ asAgent?: { name: string; color: string };
83
159
  /** Signed in with the catalog's sign-in (SSO): you're shown by this name and picture, and can't change them */
84
160
  signedInAs?: { name: string; picture?: string };
85
161
  resources: CatalogResource[];
@@ -90,8 +166,30 @@ export type SelectedNode = { id: string; name: string; type: string; catalogReso
90
166
 
91
167
  /** How long a level (L1, L2) waits for changes to settle before it's laid out again */
92
168
  const LEVEL_RELAYOUT_MS = 300;
169
+
170
+ /**
171
+ * Where something added goes. While L1 or L2 is shown it's added to the canvas people edit (L3), below what's on it
172
+ * or in the container it's dropped in, and kept on the level where it's dropped.
173
+ */
174
+ /** Where a node of this size goes to be centred on a point */
175
+ const centredOn = (center: XYPosition, size: { width: number; height: number }): LevelPlace => ({
176
+ x: center.x - size.width / 2,
177
+ y: center.y - size.height / 2,
178
+ });
179
+
180
+ type Landing = {
181
+ /** Where it was dropped (or added), in what's shown */
182
+ point: XYPosition;
183
+ /** Where it's centred on the canvas, and the container it goes in there (else the one at that point, if any) */
184
+ center: XYPosition;
185
+ inside?: string;
186
+ /**
187
+ * Where it's centred on the level it was dropped on (in the container's coordinates when it's in one), and the
188
+ * size it's drawn at there before it's measured
189
+ */
190
+ onLevel?: { level: 1 | 2; center: XYPosition; size: { width: number; height: number } };
191
+ };
93
192
  const DELETE_KEYS = ['Backspace', 'Delete'];
94
- const LEVEL_FIT_VIEW_OPTIONS = { ...FIT_VIEW_OPTIONS, maxZoom: 1 };
95
193
  const NO_THREADS: ReadonlySet<string> = new Set();
96
194
  // React Flow's warnings, except that a handle has no node: the left panel's drag previews draw nodes off the canvas
97
195
  const onFlowError = (code: string, message: string) => {
@@ -136,8 +234,58 @@ const asLevelNote = (node: Node) => {
136
234
 
137
235
  const structureOf = (nodes: Node[]) => nodes.map((node) => `${node.id}:${node.type}:${node.parentId ?? ''}`).join('|');
138
236
 
139
- export default function StudioDesigner({ defaultName, signedInAs, ...props }: CanvasProps) {
140
- const [name, setName] = useState(() => getStoredName() ?? defaultName ?? null);
237
+ // A node as shown with what it says about it (hidden by the legend, glowing with a hotspot, lit as a drop target):
238
+ // one copy per node and look, so a node keeps its identity while others move (React Flow re-renders a node whose
239
+ // object is new: copying them on every drag frame re-rendered every node with a hotspot)
240
+ const shownAs = new WeakMap<Node, Map<string, Node>>();
241
+ const asShown = (node: Node, hidden: boolean, className: string) => {
242
+ const key = `${hidden}|${className}`;
243
+ let looks = shownAs.get(node);
244
+ if (!looks) shownAs.set(node, (looks = new Map()));
245
+ let shown = looks.get(key);
246
+ if (!shown) {
247
+ if (looks.size > 4) looks.clear();
248
+ looks.set(key, (shown = { ...node, ...(hidden && { hidden: true }), ...(className && { className }) }));
249
+ }
250
+ return shown;
251
+ };
252
+
253
+ /**
254
+ * The drawing tools, as Studio on the web's: selecting (V), the hand (H) to move around, and what's put where the
255
+ * canvas is next clicked: a sticky note (N), text (T), a comment (C) or a hotspot (A)
256
+ */
257
+ type CanvasTool = 'select' | 'hand' | 'text' | 'note' | 'comment' | 'hotspot';
258
+
259
+ /**
260
+ * A diagram file opened into a new canvas: kept in this tab until the new canvas has loaded (empty), then put on it.
261
+ * By the new canvas's id.
262
+ */
263
+ const openedKey = (canvasId: string) => `eventcatalog-studio-opened:${canvasId}`;
264
+ const keepOpened = (canvasId: string, { nodes, edges, threads, hotspots, levelLayouts }: ModelCanvasContent) => {
265
+ try {
266
+ sessionStorage.setItem(
267
+ openedKey(canvasId),
268
+ JSON.stringify({ nodes, edges, threads, hotspots, levelLayouts: { 1: [...levelLayouts[1]], 2: [...levelLayouts[2]] } })
269
+ );
270
+ return true;
271
+ } catch {
272
+ return false;
273
+ }
274
+ };
275
+ const takeOpened = (canvasId: string): ModelCanvasContent | undefined => {
276
+ try {
277
+ const raw = sessionStorage.getItem(openedKey(canvasId));
278
+ if (!raw) return undefined;
279
+ sessionStorage.removeItem(openedKey(canvasId));
280
+ const saved = JSON.parse(raw);
281
+ return { ...saved, levelLayouts: { 1: new Map(saved.levelLayouts[1]), 2: new Map(saved.levelLayouts[2]) } };
282
+ } catch {
283
+ return undefined;
284
+ }
285
+ };
286
+
287
+ export default function StudioDesigner({ asAgent, signedInAs, ...props }: CanvasProps) {
288
+ const [name, setName] = useState(getStoredName);
141
289
 
142
290
  const saveName = useCallback((next: string) => {
143
291
  setName(next);
@@ -145,19 +293,41 @@ export default function StudioDesigner({ defaultName, signedInAs, ...props }: Ca
145
293
  }, []);
146
294
  // Renamed on the Studio page or another canvas in this browser
147
295
  useEffect(() => onStoredNameChange((next) => next && setName(next)), []);
296
+ // The colour picked (kept in this browser), else the one that follows from the name
297
+ const [chosenColor, setChosenColor] = useState(getStoredColor);
298
+ const recolor = useCallback((next: string) => {
299
+ setChosenColor(next);
300
+ storeColor(next);
301
+ }, []);
148
302
 
303
+ if (asAgent) {
304
+ return (
305
+ <ReactFlowProvider>
306
+ <Canvas {...props} name={asAgent.name} chosenColor={asAgent.color} agent onRecolor={() => {}} />
307
+ </ReactFlowProvider>
308
+ );
309
+ }
149
310
  if (signedInAs) {
150
311
  return (
151
312
  <ReactFlowProvider>
152
- <Canvas {...props} name={signedInAs.name} picture={signedInAs.picture} />
313
+ <Canvas {...props} name={signedInAs.name} picture={signedInAs.picture} chosenColor={chosenColor} onRecolor={recolor} />
153
314
  </ReactFlowProvider>
154
315
  );
155
316
  }
156
- if (!name) return <JoinForm onJoin={saveName} />;
317
+ if (!name)
318
+ return (
319
+ <JoinForm
320
+ color={chosenColor}
321
+ onJoin={(me) => {
322
+ recolor(me.color);
323
+ saveName(me.name);
324
+ }}
325
+ />
326
+ );
157
327
 
158
328
  return (
159
329
  <ReactFlowProvider>
160
- <Canvas {...props} name={name} onRename={saveName} />
330
+ <Canvas {...props} name={name} onRename={saveName} chosenColor={chosenColor} onRecolor={recolor} />
161
331
  </ReactFlowProvider>
162
332
  );
163
333
  }
@@ -170,25 +340,36 @@ function Canvas({
170
340
  socketPath,
171
341
  socketUrl,
172
342
  newCanvasUrl,
343
+ studioUrl,
344
+ canvasesApiUrl,
173
345
  shareUrl,
174
346
  mcpUrl = '/docs/mcp',
175
347
  relayScriptPath,
176
348
  keptInMemory = false,
177
349
  syncViaTools,
178
350
  followChanges = false,
351
+ paletteCollapsed,
179
352
  onSelectionChange,
180
353
  resources,
181
354
  relations,
182
355
  name,
183
356
  picture,
357
+ agent,
184
358
  onRename,
185
- }: Omit<CanvasProps, 'defaultName' | 'signedInAs'> & {
359
+ chosenColor,
360
+ onRecolor,
361
+ }: Omit<CanvasProps, 'asAgent' | 'signedInAs'> & {
186
362
  name: string;
187
363
  picture?: string;
364
+ /** You're here as an AI agent (shown with its logo, and can't change who you are) */
365
+ agent?: boolean;
188
366
  /** Not given when you're signed in (your name is the one you signed in with) */
189
367
  onRename?: (name: string) => void;
368
+ /** The colour you picked (else it follows from your name) */
369
+ chosenColor?: string | null;
370
+ onRecolor: (color: string) => void;
190
371
  }) {
191
- const color = colorForName(name);
372
+ const color = chosenColor ?? colorForName(name);
192
373
  const canvasRef = useRef<HTMLDivElement>(null);
193
374
 
194
375
  // Nodes glide to their new place after a layout (anyone's) or a level change (not while editing: it would make dragging lag)
@@ -215,12 +396,25 @@ function Canvas({
215
396
  name,
216
397
  color,
217
398
  picture,
399
+ agent,
218
400
  syncViaTools,
219
401
  onLayout: glide,
220
402
  onContainerResize: animateContainers,
221
403
  });
222
- const author = useMemo(() => ({ name, color, ...(picture && { picture }) }), [name, color, picture]);
404
+ const author = useMemo(
405
+ () => ({ name, color, ...(picture && { picture }), ...(agent && { agent }) }),
406
+ [name, color, picture, agent]
407
+ );
223
408
  const comments = useComments(flow.doc, author);
409
+ // Locked (looked at, not changed): for everyone on it, by whoever locked it
410
+ const locked = !!flow.lockedBy;
411
+ const lockedRef = useRef(locked);
412
+ lockedRef.current = locked;
413
+ // Fitted clear of the palette on the left (shown unless the canvas is locked)
414
+ const fitOptions = useMemo<FitViewOptions>(
415
+ () => (locked ? FIT_VIEW_OPTIONS : { ...FIT_VIEW_OPTIONS, padding: { ...FIT_VIEW_OPTIONS.padding, left: '304px' } }),
416
+ [locked]
417
+ );
224
418
  const { screenToFlowPosition, flowToScreenPosition, setCenter, getZoom, fitView } = useReactFlow();
225
419
 
226
420
  const catalog = useMemo(() => indexCatalog(resources, relations), [resources, relations]);
@@ -230,6 +424,13 @@ function Canvas({
230
424
  const edgesRef = useRef(flow.edges);
231
425
  edgesRef.current = flow.edges;
232
426
  const lookupNodes = () => new Map(nodesRef.current.map((node) => [node.id, node]));
427
+ /** Selects just these nodes, changing only those whose selection changes (each change copies its node) */
428
+ const select = (ids: ReadonlySet<string>) => {
429
+ const changes: NodeChange[] = nodesRef.current.flatMap((node) =>
430
+ !!node.selected === ids.has(node.id) ? [] : [{ type: 'select' as const, id: node.id, selected: ids.has(node.id) }]
431
+ );
432
+ if (changes.length) flow.onNodesChange(changes);
433
+ };
233
434
 
234
435
  // Agents in the browser (WebMCP) work on this canvas through the page, as the user's agent. Browsers without
235
436
  // WebMCP of their own get it through the polyfill once someone turns it on (Connect agent).
@@ -246,8 +447,7 @@ function Canvas({
246
447
  resources,
247
448
  catalog,
248
449
  agent: { name: `${name}'s agent`, color, agent: true },
249
- select: (ids) =>
250
- flow.onNodesChange(nodesRef.current.map((node) => ({ type: 'select', id: node.id, selected: ids.includes(node.id) }))),
450
+ select: (ids) => select(new Set(ids)),
251
451
  fitView: (ids) => void fitView({ nodes: ids?.map((id) => ({ id })), duration: 400, padding: 0.3, maxZoom: 1.2 }),
252
452
  reorder: () => reorder(),
253
453
  });
@@ -271,27 +471,35 @@ function Canvas({
271
471
  // ---- Adding things ----
272
472
 
273
473
  // A domain or system dropped on the canvas: asked whether it's a card or a container first
274
- const [chooser, setChooser] = useState<{ resource: CatalogResource; center: XYPosition } | null>(null);
474
+ const [chooser, setChooser] = useState<{ resource: CatalogResource; at: Landing } | null>(null);
275
475
  // A service or message that connects to things not on the canvas yet: asked whether to bring them too
276
476
  const [connectionsChooser, setConnectionsChooser] = useState<{
277
477
  resource: CatalogResource;
278
- center: XYPosition;
478
+ at: Landing;
279
479
  groups: ConnectionGroup[];
280
480
  } | null>(null);
281
481
 
282
482
  // A catalog resource is added with edges to the related resources already on the canvas
283
- const addResource = useStableCallback((key: string, center: XYPosition = canvasCenter()) => {
483
+ const addResource = (key: string, point: XYPosition) => {
284
484
  const resource = catalog.resourcesByKey.get(key);
285
485
  if (!resource) return;
286
- if (canBeContainer(resource)) return setChooser({ resource, center });
486
+ const at = landingAt(point, resource.node.type);
487
+ if (canBeContainer(resource)) return setChooser({ resource, at });
287
488
  const groups = getCatalogConnections(resource, catalog, keysOnCanvas);
288
- if (groups.length > 0) return setConnectionsChooser({ resource, center, groups });
289
- addCard(resource, center);
290
- });
489
+ if (groups.length > 0) return setConnectionsChooser({ resource, at, groups });
490
+ addCard(resource, at);
491
+ };
291
492
 
292
- const addCard = (resource: CatalogResource, center: XYPosition) =>
293
- flow.insertNode(resource.node.type, catalogNodeData(resource), center, (id, existing) =>
294
- getRelatedEdges(resource, id, existing, catalog.relationsByKey)
493
+ const addCard = (resource: CatalogResource, at: Landing) =>
494
+ keepOnLevel(
495
+ at,
496
+ flow.insertNode(
497
+ resource.node.type,
498
+ catalogNodeData(resource),
499
+ at.center,
500
+ (id, existing) => getRelatedEdges(resource, id, existing, catalog.relationsByKey),
501
+ at.inside
502
+ )
295
503
  );
296
504
 
297
505
  // Nodes waiting to be shown before the canvas is laid out around them
@@ -307,38 +515,42 @@ function Canvas({
307
515
  }, [flow.nodes]);
308
516
 
309
517
  /** A domain or system as a container, with everything it holds in the catalog inside if asked */
310
- const addContainer = async (resource: CatalogResource, center: XYPosition, withContents: boolean) => {
518
+ const addContainer = async (resource: CatalogResource, at: Landing, withContents: boolean) => {
311
519
  const existing = nodesRef.current;
312
520
  const built = withContents
313
- ? await buildContainerWithContents(resource, center, catalog, existing)
314
- : { nodes: [buildContainer(resource, center)], edges: [] };
521
+ ? await buildContainerWithContents(resource, at.center, catalog, existing)
522
+ : { nodes: [buildContainer(resource, at.center)], edges: [] };
315
523
  // Dropped in another container (e.g. a system in its domain): it goes in it
316
- const outer = findGroupAtPoint(center, existing);
524
+ const outer = containerFor(at.center, existing, at.inside);
317
525
  const nodes = outer
318
526
  ? built.nodes.map((node) =>
319
527
  node.parentId ? node : { ...node, parentId: outer.id, position: toRelativePosition(node.position, outer.id, existing) }
320
528
  )
321
529
  : built.nodes;
322
- // With its contents, the canvas is laid out again to make room for it all
323
- if (withContents && existing.length > 0) reorderOnceShown.current = nodes.map((node) => node.id);
530
+ // With its contents, the canvas is laid out again to make room for it all (not when added on a level: it's
531
+ // below everything on the canvas, and nothing on the level moves)
532
+ if (withContents && existing.length > 0 && !at.onLevel) reorderOnceShown.current = nodes.map((node) => node.id);
324
533
  flow.insertNodes(nodes, built.edges);
534
+ keepOnLevel(at, built.nodes.find((node) => !node.parentId)?.id);
325
535
  };
326
536
 
327
537
  const choose = useStableCallback((choice: ContainerChoice) => {
328
538
  if (!chooser) return;
329
- if (choice.as === 'card') addCard(chooser.resource, chooser.center);
330
- else void addContainer(chooser.resource, chooser.center, choice.withContents);
539
+ if (choice.as === 'card') addCard(chooser.resource, chooser.at);
540
+ else void addContainer(chooser.resource, chooser.at, !!choice.withContents);
331
541
  setChooser(null);
332
542
  });
333
543
  const cancelChooser = useCallback(() => setChooser(null), []);
334
544
 
335
545
  const chooseConnections = useStableCallback((choice: ConnectionsChoice) => {
336
546
  if (!connectionsChooser) return;
337
- const { resource, center } = connectionsChooser;
338
- if (choice.as === 'card') addCard(resource, center);
547
+ const { resource, at } = connectionsChooser;
548
+ if (choice.as === 'card') addCard(resource, at);
339
549
  else {
340
- const { nodes, edges } = planWithConnections(nodesRef.current, resource, center, choice.include, catalog);
550
+ const { nodes, edges } = planWithConnections(nodesRef.current, resource, at.center, choice.include, catalog, at.inside);
341
551
  flow.insertNodes(nodes, edges);
552
+ // What it connects to is placed next to it on the level
553
+ keepOnLevel(at, nodes.find((node) => getCatalogLink(node)?.key === resource.key)?.id);
342
554
  }
343
555
  setConnectionsChooser(null);
344
556
  });
@@ -356,31 +568,71 @@ function Canvas({
356
568
  };
357
569
  }, [chooser, catalog, keysOnCanvas]);
358
570
 
359
- /** A sticky note on the level shown (it's only shown on that level) */
571
+ /** A sticky note on the level shown (it's only shown on that level), written on straight away */
360
572
  const addNoteAt = (center: XYPosition) => {
361
573
  const data = getNodeDefinition('note')!.createData();
362
- flow.insertNode('note', levelRef.current === 3 ? data : { ...data, level: levelRef.current }, center);
574
+ const id = flow.insertNode('note', levelRef.current === 3 ? data : { ...data, level: levelRef.current }, center);
575
+ if (id) setStartWriting(id);
576
+ };
577
+ // The note or text just put on the canvas: written on straight away (see canvas-nodes)
578
+ const [startWriting, setStartWriting] = useState<string | null>(null);
579
+ /** Text written on the canvas, its first line where it's put (on the level shown, as notes are) */
580
+ const writeTextAt = (point: XYPosition) => {
581
+ const size = getNodeDefinition('text')!.defaultSize!;
582
+ const data = levelRef.current === 3 ? { text: '' } : { text: '', level: levelRef.current };
583
+ const id = flow.insertNode('text', data, { x: point.x + size.width / 2 - 4, y: point.y + size.height / 2 - 16 });
584
+ if (id) setStartWriting(id);
363
585
  };
364
586
  /**
365
- * Where something added while L1 or L2 is shown goes: L1 and L2 are views of the canvas people edit (L3), so it's
366
- * added there (shown on L3), below what's on it
587
+ * Where something added at a point goes (see Landing). On L1 and L2 it's centred where it's dropped, in the
588
+ * container there (and in the same container on the canvas). Something the level doesn't show (a message on L1)
589
+ * goes to L3, where it's shown.
367
590
  */
368
- const centerOnCanvas = (center: XYPosition) => {
369
- if (levelRef.current === 3) return center;
370
- changeLevel(3);
371
- const roots = nodesRef.current.filter((node) => !node.parentId && isOnLevel(node, 3));
372
- if (roots.length === 0) return { x: 0, y: 0 };
373
- const left = Math.min(...roots.map((node) => node.position.x));
374
- const right = Math.max(...roots.map((node) => node.position.x + sizeOf(node).width));
375
- const bottom = Math.max(...roots.map((node) => node.position.y + sizeOf(node).height));
376
- return { x: (left + right) / 2, y: bottom + 160 };
591
+ const landingAt = (point: XYPosition, type: string | undefined): Landing => {
592
+ const level = levelRef.current;
593
+ if (level === 3) return { point, center: point };
594
+ const shownHere = isShownOnLevel(type, level);
595
+ if (!shownHere) changeLevel(3);
596
+ // The container it's dropped in on the level, which the canvas has too (levels keep the canvas's ids)
597
+ const shown = shownHere && levelGraphRef.current?.level === level ? levelGraphRef.current.nodes : [];
598
+ const container = canGoInContainer(type) ? findGroupAtPoint(point, shown) : undefined;
599
+ const canvas = nodesRef.current;
600
+ const inside = container && containerFor(point, canvas, container.id) ? container.id : undefined;
601
+ // On the canvas: below what's in that container, or below everything
602
+ const lookup = new Map(canvas.map((node) => [node.id, node]));
603
+ const beside = canvas.filter((node) => (inside ? node.parentId === inside : !node.parentId && isOnLevel(node, 3)));
604
+ const rects = (beside.length ? beside : inside ? [lookup.get(inside)!] : []).map((node) => getAbsoluteRect(node, lookup));
605
+ const center = rects.length
606
+ ? {
607
+ x: (Math.min(...rects.map((rect) => rect.x)) + Math.max(...rects.map((rect) => rect.x + rect.width))) / 2,
608
+ // Below them, or in the middle of an empty container
609
+ y: beside.length ? Math.max(...rects.map((rect) => rect.y + rect.height)) + 160 : rects[0].y + rects[0].height / 2,
610
+ }
611
+ : { x: 0, y: 0 };
612
+ if (!shownHere) return { point, center };
613
+ const origin =
614
+ container && inside ? getAbsolutePosition(container, new Map(shown.map((node) => [node.id, node]))) : { x: 0, y: 0 };
615
+ const size = getLevelNodeSize(type, level);
616
+ return { point, center, inside, onLevel: { level, center: { x: point.x - origin.x, y: point.y - origin.y }, size } };
617
+ };
618
+ // Added on a level, until they're measured, with the size they were centred at: centred again at their real size
619
+ const centring = useRef(new Map<string, { level: 1 | 2; size: { width: number; height: number } }>());
620
+ /** Keeps what was added where it was dropped on the level (undone with adding it) */
621
+ const keepOnLevel = (at: Landing, id: string | undefined) => {
622
+ if (!id || !at.onLevel) return;
623
+ const { level, center, size } = at.onLevel;
624
+ centring.current.set(id, { level, size });
625
+ flow.setLevelLayout(level, new Map([[id, centredOn(center, size)]]));
626
+ };
627
+ /** A component (its type, or its key in the components list: the kinds of data store share a type) */
628
+ const addComponentAt = (component: string, point: XYPosition) => {
629
+ if (component === 'note') return addNoteAt(point);
630
+ const at = landingAt(point, getNodeDefinition(component)?.type ?? component);
631
+ keepOnLevel(at, flow.addNode(component, at.center, at.inside));
377
632
  };
378
- const addComponentAt = (type: string, center: XYPosition) =>
379
- type === 'note' ? addNoteAt(center) : flow.addNode(type, centerOnCanvas(center));
380
633
 
381
634
  const addComponent = useStableCallback((type: string) => addComponentAt(type, canvasCenter()));
382
- const addNote = useStableCallback(() => addNoteAt(canvasCenter()));
383
- const addResourceFromPanel = useStableCallback((key: string) => addResource(key, centerOnCanvas(canvasCenter())));
635
+ const addResourceFromPanel = useStableCallback((key: string) => addResource(key, canvasCenter()));
384
636
 
385
637
  const onDrop = useStableCallback((event: DragEvent) => {
386
638
  event.preventDefault();
@@ -388,7 +640,7 @@ function Canvas({
388
640
  const type = event.dataTransfer.getData(COMPONENT_DRAG_TYPE);
389
641
  const resourceKey = event.dataTransfer.getData(CATALOG_DRAG_TYPE);
390
642
  if (type) addComponentAt(type, position);
391
- if (resourceKey) addResource(resourceKey, centerOnCanvas(position));
643
+ if (resourceKey) addResource(resourceKey, position);
392
644
  });
393
645
 
394
646
  // ---- Pointer, panning and dragging ----
@@ -478,8 +730,13 @@ function Canvas({
478
730
  // While the canvas is panned, zoomed or dragged, message edges stop animating (a class, not state, so nothing re-renders)
479
731
  const interactions = useRef({ moving: false, dragging: false });
480
732
  const setInteraction = useCallback((kind: 'moving' | 'dragging', on: boolean) => {
733
+ const wasActive = interactions.current.moving || interactions.current.dragging;
481
734
  interactions.current[kind] = on;
482
- canvasRef.current?.classList.toggle('ec-interaction-active', interactions.current.moving || interactions.current.dragging);
735
+ const active = interactions.current.moving || interactions.current.dragging;
736
+ if (active === wasActive) return;
737
+ canvasRef.current?.classList.toggle('ec-interaction-active', active);
738
+ // (hiding them doesn't stop the envelopes' SMIL)
739
+ pauseEdgeAnimations(canvasRef.current, active);
483
740
  }, []);
484
741
 
485
742
  // The camera follows agents unless people move it themselves (set by AgentActivity)
@@ -495,10 +752,6 @@ function Canvas({
495
752
 
496
753
  // Dragging something into a container puts it in it, out of one takes it out
497
754
  const [dropTargetId, setDropTargetId] = useState<string | null>(null);
498
- const dragRoots = (dragged: Node[]) => {
499
- const ids = new Set(dragged.map((node) => node.id));
500
- return dragged.filter((node) => !node.parentId || !ids.has(node.parentId));
501
- };
502
755
  // While something's dragged, the containers around it grow to fit it (see previewContainerGrowth): the canvas
503
756
  // when the drag started, and those containers, innermost first
504
757
  const dragStart = useRef<{ nodes: Map<string, Node>; containers: string[] } | null>(null);
@@ -545,15 +798,17 @@ function Canvas({
545
798
  dragged.forEach((node) => now.set(node.id, { ...now.get(node.id)!, position: node.position }));
546
799
  flow.showDragPreview(previewContainerGrowth(started.containers, new Set(dragged.map((node) => node.id)), started.nodes, now));
547
800
  };
548
- const onNodeDrag = useStableCallback((_: unknown, __: Node, dragged: Node[]) => {
801
+ const onNodeDrag = useStableCallback((_: unknown, held: Node, dragged: Node[]) => {
549
802
  growContainers(dragged);
550
803
  moveThreadsWith(dragged);
551
- const [root] = dragRoots(dragged);
552
- const current = root && nodesRef.current.find((node) => node.id === root.id);
553
- const target = root ? findDropTarget(root.id, nodesRef.current) : undefined;
554
- setDropTargetId(target && target.id !== current?.parentId ? target.id : null);
804
+ const moves = findDropTargets(
805
+ held.id,
806
+ dragged.map((node) => node.id),
807
+ nodesRef.current
808
+ );
809
+ setDropTargetId(moves.find(({ target }) => target)?.target?.id ?? null);
555
810
  });
556
- const onNodeDragStop = useStableCallback((_: unknown, __: Node, dragged: Node[]) => {
811
+ const onNodeDragStop = useStableCallback((_: unknown, held: Node, dragged: Node[]) => {
557
812
  setInteraction('dragging', false);
558
813
  setDropTargetId(null);
559
814
  dragStart.current = null;
@@ -571,56 +826,113 @@ function Canvas({
571
826
  }
572
827
  threadsDrag.current = null;
573
828
  setThreadsOffset(null);
574
- // The containers it grew are saved with the drop; dropped in another container (or out of one), it moves there
575
- for (const root of dragRoots(dragged)) {
576
- const current = nodesRef.current.find((node) => node.id === root.id);
577
- const target = findDropTarget(root.id, nodesRef.current);
578
- if (current && target?.id !== current.parentId) flow.setParent(root.id, target?.id);
579
- }
829
+ // The containers it grew are saved with the drop; dropped in another container (or out of one), it moves there,
830
+ // and what's selected with it goes too, into free space there (gliding off anything it was dropped on)
831
+ const moves = findDropTargets(
832
+ held.id,
833
+ dragged.map((node) => node.id),
834
+ nodesRef.current
835
+ );
836
+ if (!moves.length) return;
837
+ const { places, nudged } = placeDropped(nodesRef.current, moves);
838
+ if (nudged) glide();
839
+ flow.setParents(moves.map(({ id, target }) => ({ id, parentId: target?.id, at: places.get(id) })));
580
840
  });
581
841
 
582
842
  // ---- Levels of detail ----
583
843
 
584
- // Like EventCatalog's diagrams: L3 is the canvas people edit, L1 and L2 read only views of it
585
- const [level, setLevel] = useState<Level>(3);
844
+ // Like EventCatalog's diagrams: L3 is everything on the canvas, L1 and L2 less detailed views of it, arranged and
845
+ // edited on their own (what's done there changes the canvas: a system card is the system). A canvas opened
846
+ // from a diagram opens at the level it was shown at (?level=1 or 2), once: reloading opens the canvas (L3).
847
+ const [level, setLevel] = useState<Level>(() => {
848
+ const url = new URL(window.location.href);
849
+ const asked = Number(url.searchParams.get('level'));
850
+ if (!url.searchParams.has('level')) return 3;
851
+ url.searchParams.delete('level');
852
+ window.history.replaceState(window.history.state, '', url);
853
+ return asked === 1 || asked === 2 ? asked : 3;
854
+ });
586
855
  const levelRef = useRef(level);
587
856
  levelRef.current = level;
857
+ // L3 is the canvas itself: comments and other people's pointers are there (L1 and L2 are arranged on their own)
588
858
  const editable = level === 3;
589
- const [levelGraph, setLevelGraph] = useState<{ level: Level; nodes: Node[]; edges: Edge[] } | null>(null);
859
+ const [levelGraph, setLevelGraphState] = useState<{ level: Level; nodes: Node[]; edges: Edge[] } | null>(null);
860
+ // The level shown as it is now (for changes to it, and saving where things are put)
861
+ const levelGraphRef = useRef(levelGraph);
862
+ /** Changes to the level shown (moved, selected, measured), as they happen */
863
+ const updateLevelGraph = useCallback((next: { level: Level; nodes: Node[]; edges: Edge[] }) => {
864
+ levelGraphRef.current = next;
865
+ setLevelGraphState(next);
866
+ }, []);
867
+ /**
868
+ * Shows a level (arranged again), keeping what hasn't changed on it as it's shown (so it doesn't render again), and
869
+ * what's selected and nodes' measured sizes (not measured again). Containers come before what's in them, as React
870
+ * Flow needs (the canvas's order can have them after).
871
+ */
872
+ const setLevelGraph = useCallback((next: { level: Level; nodes: Node[]; edges: Edge[] } | null) => {
873
+ const previous = levelGraphRef.current;
874
+ const same = previous && next && previous.level === next.level ? previous : null;
875
+ const shown = next && {
876
+ ...next,
877
+ nodes: sortByHierarchy(same ? keepUnchanged(same.nodes, next.nodes, LOCAL_NODE_KEYS) : next.nodes),
878
+ edges: same ? keepUnchanged(same.edges, next.edges, LOCAL_EDGE_KEYS) : next.edges,
879
+ };
880
+ levelGraphRef.current = shown;
881
+ setLevelGraphState(shown);
882
+ }, []);
590
883
 
591
884
  // What a level shows depends on what's on the canvas and how it's connected, and names (not positions). Notes
592
885
  // aren't laid out with it: adding, moving or writing one doesn't lay the level out again.
593
- const levelKey =
594
- level === 3
595
- ? ''
596
- : `${flow.nodes
597
- .filter((node) => node.type !== 'note')
598
- .map((node) => `${node.id}:${node.type}:${node.parentId ?? ''}:${getNodeName(node.type, node.data)}`)
599
- .join('|')}#${flow.edges.map((edge) => `${edge.source}>${edge.target}:${String(edge.label ?? '')}`).join('|')}`;
886
+ // Also whether a canvas opened from a diagram still has the diagram's levels: only worked out when needed
887
+ const levelKey = level === 3 ? '' : flow.structureKey;
600
888
  const shownLevelRef = useRef<Level | undefined>(undefined);
601
889
  shownLevelRef.current = levelGraph?.level;
890
+ const levelLayout = level === 3 ? undefined : flow.levelLayouts[level];
602
891
  useEffect(() => {
603
892
  if (level === 3) return setLevelGraph(null);
604
893
  let cancelled = false;
605
- // Straight away when switching levels; after changes settle while one is shown
606
- const timer = setTimeout(
607
- () => {
608
- const collapsed = getLevelGraph(nodesRef.current, edgesRef.current, level);
609
- void layoutGraph(
610
- collapsed.nodes.map((node) => ({ ...node, selected: false })),
611
- collapsed.edges
612
- ).then((laidOut) => !cancelled && setLevelGraph({ level, ...laidOut }));
613
- },
614
- shownLevelRef.current === level ? LEVEL_RELAYOUT_MS : 0
615
- );
894
+ const show = () => {
895
+ // A canvas opened from a diagram shows the diagram's levels as it did, until what's on the canvas changes
896
+ const fromDiagram = getDiagramLevel(flow.diagramLevels, level, flow.structureKey);
897
+ const diagram = flow.diagramLevels?.[level];
898
+ const derived = fromDiagram ? undefined : getLevelGraph(nodesRef.current, edgesRef.current, level);
899
+ const content = fromDiagram ?? { ...derived!, edges: withDiagramRoutes(derived!.edges, diagram?.edges) };
900
+ // Where things are: as arranged on the level, else as the diagram had them, and anything new next to what it
901
+ // connects to (kept there, so nothing moves later)
902
+ const arranged = arrangeLevel(content, flow.levelLayouts[level], diagram && levelPlaces(diagram.nodes));
903
+ if (arranged) {
904
+ setLevelGraph({ level, ...arranged.graph });
905
+ if (arranged.placed.size) flow.setLevelLayout(level, arranged.placed, 'level');
906
+ return;
907
+ }
908
+ // Nothing on it has a place yet: laid out with the visualiser's layout once, and kept
909
+ void layoutGraph(
910
+ content.nodes.map((node) => ({ ...node, selected: false })),
911
+ content.edges
912
+ ).then((laidOut) => {
913
+ if (cancelled) return;
914
+ setLevelGraph({ level, ...laidOut });
915
+ flow.setLevelLayout(level, levelPlaces(laidOut.nodes), 'level');
916
+ // (with sizes guessed from the nodes' types: laid out again once they're drawn, see below)
917
+ if (!flow.presence?.getPeers().length) relayoutWhenMeasured.current = level;
918
+ });
919
+ };
920
+ // Straight away when switching levels (or from the diagram); after changes settle while one is shown
921
+ if (shownLevelRef.current !== level || getDiagramLevel(flow.diagramLevels, level, flow.structureKey)) {
922
+ show();
923
+ return () => void (cancelled = true);
924
+ }
925
+ const timer = setTimeout(show, LEVEL_RELAYOUT_MS);
616
926
  return () => {
617
927
  cancelled = true;
618
928
  clearTimeout(timer);
619
929
  };
620
- }, [level, levelKey]);
930
+ }, [level, levelKey, flow.diagramLevels, levelLayout]);
621
931
 
622
- // Comments: right click > Add comment, or C for comment mode then click (like Figma)
623
- const [commentMode, setCommentMode] = useState(false);
932
+ // The tool picked (see CanvasTool): comments are right click > Add comment, or C then click (like Figma)
933
+ const [tool, setTool] = useState<CanvasTool>('select');
934
+ const commentMode = tool === 'comment';
935
+ const placing = tool === 'text' || tool === 'note';
624
936
  const [draft, setDraft] = useState<CommentAnchor | null>(null);
625
937
  const [openThreadId, setOpenThreadId] = useState<string | null>(null);
626
938
  const [showResolved, setShowResolved] = useState(false);
@@ -632,10 +944,31 @@ function Canvas({
632
944
  const threadsShown = () => comments.threads.filter((thread) => showResolved || !thread.resolved);
633
945
  const [contextMenu, setContextMenu] = useState<ContextMenuState>(null);
634
946
 
947
+ // A level's first layout is worked out before its nodes have been drawn, so with sizes guessed from their types: a
948
+ // little off once they're drawn at their real sizes. Once they all have been (measured), it's laid out again with
949
+ // them, as Reorder would, gliding there. Only that first time, and not while others are on the canvas: it would
950
+ // move what they're looking at (the level stays as first laid out, and Reorder tidies it)
951
+ const relayoutWhenMeasured = useRef<1 | 2 | null>(null);
952
+ useEffect(() => {
953
+ const pending = relayoutWhenMeasured.current;
954
+ if (!pending || levelGraph?.level !== pending) return;
955
+ if (!levelGraph.nodes.every((node) => node.measured?.width && node.measured?.height)) return;
956
+ relayoutWhenMeasured.current = null;
957
+ void layoutGraph(
958
+ levelGraph.nodes.map((node) => ({ ...node, selected: false })),
959
+ levelGraph.edges
960
+ ).then((laidOut) => {
961
+ if (levelRef.current !== pending) return;
962
+ glide();
963
+ flow.setLevelLayout(pending, levelPlaces(laidOut.nodes), 'level');
964
+ setTimeout(() => void fitView({ ...fitOptions, duration: 450 }), 50);
965
+ });
966
+ }, [levelGraph]);
967
+
635
968
  const changeLevel = useStableCallback((next: Level) => {
636
969
  if (next === level) return;
637
970
  glide();
638
- setCommentMode(false);
971
+ setTool('select');
639
972
  setDraft(null);
640
973
  setOpenThreadId(null);
641
974
  // What's selected may not be on the next level (e.g. a note added on this one)
@@ -644,22 +977,41 @@ function Canvas({
644
977
  });
645
978
  const editInL3 = useCallback(() => changeLevel(3), [changeLevel]);
646
979
 
647
- // Fit the camera to a level once it's laid out (and to the canvas when going back to L3)
980
+ // Fit the camera to a level once it's laid out (and to the canvas when going back to L3). A level shown before the
981
+ // canvas has arrived (opened at ?level=) is laid out empty first: it's fitted once it has something to show.
648
982
  const shownLevel = level === 3 ? 3 : levelGraph?.level;
983
+ const levelHasNodes = !!levelGraph?.nodes.length;
649
984
  useEffect(() => {
650
- if (shownLevel === undefined) return;
651
- // Like the visualiser, a level isn't zoomed in past its real size
652
- const options = shownLevel === 3 ? FIT_VIEW_OPTIONS : LEVEL_FIT_VIEW_OPTIONS;
653
- const frame = requestAnimationFrame(() => void fitView({ ...options, duration: 450 }));
985
+ if (shownLevel === undefined || (shownLevel !== 3 && !levelHasNodes)) return;
986
+ const frame = requestAnimationFrame(() => void fitView({ ...fitOptions, duration: 450 }));
654
987
  return () => cancelAnimationFrame(frame);
655
- }, [shownLevel]);
988
+ }, [shownLevel, levelHasNodes]);
656
989
 
657
- /** Lays the canvas out with the visualiser's layout, for everyone on it (everyone's nodes glide there) */
990
+ /**
991
+ * Lays the canvas out with the visualiser's layout, for everyone on it (everyone's nodes glide there). On L1 or
992
+ * L2 it lays out just that level (one step to undo), leaving the canvas as it is.
993
+ */
658
994
  const reorder = useStableCallback(async () => {
659
- if (levelRef.current !== 3) setLevel(3);
995
+ const level = levelRef.current;
996
+ if (level !== 3) {
997
+ const content =
998
+ getDiagramLevel(flow.diagramLevels, level, flow.structureKey) ?? getLevelGraph(nodesRef.current, edgesRef.current, level);
999
+ // As they're drawn on the level (its cards and boxes aren't the canvas's), where they have been
1000
+ const drawn = new Map(
1001
+ (levelGraphRef.current?.level === level ? levelGraphRef.current.nodes : []).map((node) => [node.id, node.measured])
1002
+ );
1003
+ const laidOut = await layoutGraph(
1004
+ content.nodes.map((node) => ({ ...node, selected: false, ...(drawn.get(node.id) && { measured: drawn.get(node.id) }) })),
1005
+ content.edges
1006
+ );
1007
+ glide();
1008
+ flow.setLevelLayout(level, levelPlaces(laidOut.nodes));
1009
+ setTimeout(() => void fitView({ ...fitOptions, duration: 450 }), 50);
1010
+ return;
1011
+ }
660
1012
  const positions = await getLayoutPositions(nodesRef.current, edgesRef.current);
661
1013
  flow.applyPositions(positions);
662
- setTimeout(() => void fitView({ ...FIT_VIEW_OPTIONS, duration: 450 }), 50);
1014
+ setTimeout(() => void fitView({ ...fitOptions, duration: 450 }), 50);
663
1015
  });
664
1016
  const onReorder = useCallback(() => void reorder(), [reorder]);
665
1017
 
@@ -684,27 +1036,234 @@ function Canvas({
684
1036
  const notes = sortedNodes.filter((node) => getNoteLevel(node) === levelShown.level);
685
1037
  return notes.length ? [...levelShown.nodes, ...notes.map(asLevelNote)] : levelShown.nodes;
686
1038
  }, [levelShown, sortedNodes]);
687
- /** On L1 and L2, only changes to the level's notes go to the canvas (its other nodes are drawn from it) */
1039
+
1040
+ // The legend, as the visualiser's: how many of each kind of node is shown (not what's written on the canvas), and
1041
+ // clicking a kind hides or shows it. Hiding only changes what this person sees.
1042
+ const [hiddenLegendKeys, setHiddenLegendKeys] = useState<string[]>([]);
1043
+ const toggleLegendKey = useCallback(
1044
+ (key: string) => setHiddenLegendKeys((keys) => (keys.includes(key) ? keys.filter((other) => other !== key) : [...keys, key])),
1045
+ []
1046
+ );
1047
+ // What's shown only changes with what's on it (not as nodes are dragged, on any level)
1048
+ const shownStructure = levelShown ? structureOf(levelShown.nodes) : structure;
1049
+ const legend = useMemo(() => getLegend(shownNodes.filter((node) => !isWrittenOnCanvas(node.type))), [shownStructure]);
1050
+ const shownNodesRef = useRef(shownNodes);
1051
+ shownNodesRef.current = shownNodes;
1052
+ // The nodes shown now (on L1 and L2 they're the level's, where they are there)
1053
+ const shownLookup = () => new Map<string, Node>(shownNodesRef.current.map((node) => [node.id, node]));
1054
+
1055
+ // ---- Hotspots: what's flagged for people to know (risks, problems, the key parts), on nodes or places ----
1056
+
1057
+ const [hotspotDraft, setHotspotDraft] = useState<HotspotDraft | null>(null);
1058
+ const [openHotspot, setOpenHotspot] = useState<string | null>(null);
1059
+ const [listingHotspots, setListingHotspots] = useState(false);
1060
+ // The node a hotspot being dragged would go on: it lights up in the hotspot's colour
1061
+ const [hotspotDropTarget, setHotspotDropTarget] = useState<{ nodeId: string; severity: HotspotSeverity } | null>(null);
1062
+ // Hotspots where they show on this level: on L3 on their node (or at their place); on L1 and L2 on their node if
1063
+ // it's shown, else on what it's in that is (so they're found from the overview). From what's on the canvas and the
1064
+ // level, read through refs: the nodes arrays are new on every drag frame, and which node holds which doesn't
1065
+ // change while things move.
1066
+ const hotspotSpots = useMemo((): HotspotSpot[] => {
1067
+ const canvas = new Map(nodesRef.current.map((node) => [node.id, node]));
1068
+ const shown = new Set(shownNodesRef.current.map((node) => node.id));
1069
+ const onNodes = new Map<string, Hotspot[]>();
1070
+ const holding = new Set<string>();
1071
+ const places: HotspotSpot[] = [];
1072
+ // The node itself if it's shown, else the nearest container of it that is
1073
+ const shownAround = (id: string | undefined) => {
1074
+ while (id && !shown.has(id)) id = canvas.get(id)?.parentId;
1075
+ return id;
1076
+ };
1077
+ for (const hotspot of flow.hotspots) {
1078
+ let target = hotspot.nodeId && canvas.has(hotspot.nodeId) ? hotspot.nodeId : undefined;
1079
+ if (!target) {
1080
+ if ((hotspot.level ?? 3) === level)
1081
+ places.push({ key: `place:${hotspot.id}`, position: hotspot.position, hotspots: [hotspot] });
1082
+ continue;
1083
+ }
1084
+ const own = target;
1085
+ target = shownAround(own);
1086
+ // Not in anything shown (e.g. a message on L2): on what it's connected to (its sender first), or what that's in
1087
+ if (!target) {
1088
+ const linked = flow.edges
1089
+ .filter((edge) => edge.source === own || edge.target === own)
1090
+ .sort((a) => (a.target === own ? -1 : 1));
1091
+ target = linked.map((edge) => shownAround(edge.source === own ? edge.target : edge.source)).find(Boolean);
1092
+ }
1093
+ if (!target) continue;
1094
+ if (target !== hotspot.nodeId) holding.add(target);
1095
+ onNodes.set(target, [...(onNodes.get(target) ?? []), hotspot]);
1096
+ }
1097
+ return [
1098
+ ...[...onNodes].map(([nodeId, hotspots]) => ({ key: `node:${nodeId}`, nodeId, hotspots, inside: holding.has(nodeId) })),
1099
+ ...places,
1100
+ ];
1101
+ }, [flow.hotspots, structure, shownStructure, flow.edges, level]);
1102
+
1103
+ const visibleNodes = useMemo(() => {
1104
+ // A node with hotspots glows in the colour of the most important (see studio.css)
1105
+ const glowing = new Map(
1106
+ hotspotSpots.flatMap((spot) => (spot.nodeId ? [[spot.nodeId, topSeverity(spot.hotspots)] as const] : []))
1107
+ );
1108
+ const dropOn = hotspotDropTarget?.nodeId;
1109
+ if (!hiddenLegendKeys.length && !glowing.size && !dropOn) return shownNodes;
1110
+ return shownNodes.map((node) => {
1111
+ const hidden = hiddenLegendKeys.includes(node.type ?? '');
1112
+ const severity = glowing.get(node.id);
1113
+ const target = node.id === dropOn;
1114
+ if (!hidden && !severity && !target) return node;
1115
+ const classes = [
1116
+ node.className,
1117
+ severity && `studio-hotspot studio-hotspot-${severity}`,
1118
+ target && `studio-hotspot-drop studio-hotspot-drop-${hotspotDropTarget!.severity}`,
1119
+ ];
1120
+ return asShown(node, hidden, classes.filter(Boolean).join(' '));
1121
+ });
1122
+ }, [shownNodes, hiddenLegendKeys, hotspotSpots, hotspotDropTarget]);
1123
+
1124
+ const startHotspot = useStableCallback((at: HotspotDraft) => {
1125
+ setOpenHotspot(null);
1126
+ setDraft(null);
1127
+ setHotspotDraft(at);
1128
+ setTool('select');
1129
+ });
1130
+ const createHotspot = useStableCallback((fields: HotspotFields) => {
1131
+ if (!hotspotDraft) return;
1132
+ flow.createHotspot({
1133
+ ...fields,
1134
+ ...(hotspotDraft.nodeId ? { nodeId: hotspotDraft.nodeId } : hotspotDraft.level && { level: hotspotDraft.level }),
1135
+ position: hotspotDraft.position,
1136
+ author,
1137
+ });
1138
+ setHotspotDraft(null);
1139
+ });
1140
+ const cancelHotspot = useCallback(() => setHotspotDraft(null), []);
1141
+ const deleteHotspot = useStableCallback((id: string) => flow.deleteHotspot(id));
1142
+ // Dragged onto a node (they move with it), or to a place; open where it lands if it was open
1143
+ const moveHotspots = useStableCallback((ids: string[], to: { nodeId?: string; position: XYPosition }) => {
1144
+ const at = to.nodeId ? {} : placeLevel();
1145
+ ids.forEach((id) =>
1146
+ flow.updateHotspot(id, { nodeId: to.nodeId, position: to.position, level: 'level' in at ? at.level : undefined })
1147
+ );
1148
+ setOpenHotspot((open) => (open ? (to.nodeId ? `node:${to.nodeId}` : ids.length === 1 ? `place:${ids[0]}` : null) : open));
1149
+ });
1150
+ // From the list (or inside a node on L1 and L2): the canvas goes to it, and it opens
1151
+ const goToHotspot = useStableCallback((hotspot: Hotspot) => {
1152
+ // On a node shown here, it's here; on another, on L3; at a place, on its level
1153
+ const shownHere = hotspot.nodeId && shownLookup().has(hotspot.nodeId);
1154
+ const target = shownHere ? levelRef.current : hotspot.nodeId ? 3 : (hotspot.level ?? 3);
1155
+ if (target !== levelRef.current) changeLevel(target);
1156
+ const nodes = shownHere ? shownLookup() : lookupNodes();
1157
+ const node = hotspot.nodeId ? nodes.get(hotspot.nodeId) : undefined;
1158
+ const at = node ? getAbsolutePosition(node, nodes) : hotspot.position;
1159
+ const width = node ? (node.measured?.width ?? node.width ?? 0) : 0;
1160
+ void setCenter(at.x + width / 2 + 120, at.y + 80, { zoom: Math.max(getZoom(), 0.8), duration: 300 });
1161
+ setHotspotDraft(null);
1162
+ setOpenHotspot(node ? `node:${node.id}` : `place:${hotspot.id}`);
1163
+ setListingHotspots(false);
1164
+ });
1165
+ const nameOfNode = useStableCallback((id: string) => {
1166
+ const node = nodesRef.current.find((candidate) => candidate.id === id);
1167
+ return node ? getNodeName(node.type, node.data, id) : undefined;
1168
+ });
1169
+ const toggleHotspotList = useCallback(() => {
1170
+ setListingComments(false);
1171
+ setListingHotspots((shown) => !shown);
1172
+ }, []);
1173
+ const hotspotsCount = flow.hotspots.length;
1174
+ const hotspotsSeverity = topSeverity(flow.hotspots);
1175
+ // What the bottom left controls show (the same objects while that's the same: they're memoised)
1176
+ const hotspotsControl = useMemo(
1177
+ () => ({ count: hotspotsCount, severity: hotspotsSeverity, onToggle: toggleHotspotList, active: listingHotspots }),
1178
+ [hotspotsCount, hotspotsSeverity, toggleHotspotList, listingHotspots]
1179
+ );
1180
+
1181
+ // A node's details open from its Edit button (and by double clicking it): one selected node on the canvas
1182
+ const selectedNodes = visibleNodes.filter((node) => node.selected);
1183
+ const toEdit =
1184
+ selectedNodes.length === 1 &&
1185
+ !isWrittenOnCanvas(selectedNodes[0].type) &&
1186
+ nodesRef.current.some((node) => node.id === selectedNodes[0].id)
1187
+ ? selectedNodes[0]
1188
+ : undefined;
1189
+ const editSelected = useStableCallback(() => toEdit && setEditingId(toEdit.id));
1190
+ /**
1191
+ * Changes on L1 and L2. The level's notes are on the canvas, so changes to them go there; removing something
1192
+ * removes it from the canvas (the level is drawn from it); and moving or resizing something arranges the level,
1193
+ * kept when it's dropped.
1194
+ */
688
1195
  const onLevelNodesChange = useStableCallback((changes: NodeChange[]) => {
689
- const notes = new Set(nodesRef.current.filter((node) => getNoteLevel(node) === levelRef.current).map((node) => node.id));
690
- const ofNotes = changes.filter((change) => change.type !== 'add' && notes.has(change.id));
1196
+ const level = levelRef.current;
1197
+ const current = levelGraphRef.current;
1198
+ if (level === 3 || !current) return;
1199
+ const notes = new Set(nodesRef.current.filter((node) => getNoteLevel(node) === level).map((node) => node.id));
1200
+ const isNote = (change: NodeChange) => 'id' in change && notes.has(change.id);
1201
+ const ofNotes = changes.filter((change) => change.type !== 'add' && isNote(change));
691
1202
  if (ofNotes.length) flow.onNodesChange(ofNotes);
1203
+
1204
+ const ofLevel = changes.filter((change) => !isNote(change) && change.type !== 'add');
1205
+ const onCanvas = new Set(nodesRef.current.map((node) => node.id));
1206
+ const removed = ofLevel.flatMap((change) => (change.type === 'remove' && onCanvas.has(change.id) ? [change.id] : []));
1207
+ if (removed.length) flow.deleteNodes(removed);
1208
+ const arranging = ofLevel.filter((change) => change.type !== 'remove');
1209
+ if (!arranging.length) return;
1210
+ const nodes = applyNodeChanges(arranging, current.nodes);
1211
+ updateLevelGraph({ ...current, nodes });
1212
+ const done = new Set(
1213
+ arranging.flatMap((change) =>
1214
+ (change.type === 'position' && change.dragging === false) || (change.type === 'dimensions' && change.resizing === false)
1215
+ ? [change.id]
1216
+ : []
1217
+ )
1218
+ );
1219
+ if (done.size) flow.setLevelLayout(level, levelPlaces(nodes.filter((node) => done.has(node.id))));
1220
+ // Something just added, now it's measured: centred where it was dropped, from where it is now (a container it's in
1221
+ // may have grown around it), in one step to undo with adding it
1222
+ const measured = arranging.flatMap((change) => {
1223
+ const dropped = change.type === 'dimensions' && change.dimensions && centring.current.get(change.id);
1224
+ const node = dropped && nodes.find((candidate) => candidate.id === change.id);
1225
+ if (!dropped || !node) return [];
1226
+ centring.current.delete(change.id);
1227
+ const { width, height } = change.dimensions!;
1228
+ const { x, y } = node.position;
1229
+ const place = { x: x + (dropped.size.width - width) / 2, y: y + (dropped.size.height - height) / 2 };
1230
+ return dropped.level === level ? [[change.id, place] as const] : [];
1231
+ });
1232
+ if (measured.length) flow.setLevelLayout(level, new Map(measured));
1233
+ });
1234
+ /** Connecting two things on L1 or L2 connects them on the canvas (e.g. a relationship between two systems) */
1235
+ const onLevelConnect = useStableCallback((connection: Connection) => {
1236
+ const onCanvas = new Set(nodesRef.current.map((node) => node.id));
1237
+ if (onCanvas.has(connection.source) && onCanvas.has(connection.target)) flow.onConnect(connection);
692
1238
  });
693
- // Message edges' envelopes only move on what's selected (the edge, or a node at either end): any moving
694
- // envelope at all costs a style recalc and layout every frame, which an editing canvas can't afford at idle
1239
+ // Message edges' envelopes only move under the pointer (the connection, or a node at either end): any moving
1240
+ // envelope needs a main-thread frame on every frame (one selected node's cost ~200–300 ms of every 3 seconds and
1241
+ // dropped frames), which an editing canvas can't afford at idle
1242
+ // (on L1 and L2, the level's edges: the level itself changes on every frame something on it is dragged)
1243
+ const [hovered, setHovered] = useState<{ node?: string; edge?: string }>({});
1244
+ const onNodeMouseEnter = useCallback((_: ReactMouseEvent, node: Node) => setHovered({ node: node.id }), []);
1245
+ const onEdgeMouseEnter = useCallback((_: ReactMouseEvent, edge: Edge) => setHovered({ edge: edge.id }), []);
1246
+ const onHoverEnd = useCallback(() => setHovered({}), []);
1247
+ const levelEdges = levelShown?.edges;
695
1248
  const shownEdges = useMemo(() => {
696
- const edges = levelShown ? levelShown.edges : flow.edges;
697
- const focused = new Set(selectedKey ? selectedKey.split(',') : []);
1249
+ const edges = levelEdges ?? flow.edges;
698
1250
  return edges.map((edge) =>
699
- edge.type === 'animated' && !edge.selected && !focused.has(edge.source) && !focused.has(edge.target)
1251
+ edge.type === 'animated' && edge.id !== hovered.edge && edge.source !== hovered.node && edge.target !== hovered.node
700
1252
  ? withoutEnvelope(edge)
701
1253
  : edge
702
1254
  );
703
- }, [levelShown, flow.edges, selectedKey]);
704
- const unavailableLevels = useMemo(
705
- () => ({ 1: getLevelUnavailableReason(nodesRef.current, 1), 2: getLevelUnavailableReason(nodesRef.current, 2) }),
706
- [structure]
707
- );
1255
+ }, [levelEdges, flow.edges, hovered]);
1256
+ // A canvas opened from a diagram has the levels the diagram has (until what's on it changes)
1257
+ const unavailableLevels = useMemo(() => {
1258
+ const reason = (level: 1 | 2) => {
1259
+ if (getDiagramLevel(flow.diagramLevels, level, flow.structureKey)) return undefined;
1260
+ return (
1261
+ getDiagramLevelUnavailable(flow.diagramLevels, level, flow.structureKey) ??
1262
+ getLevelUnavailableReason(nodesRef.current, level)
1263
+ );
1264
+ };
1265
+ return { 1: reason(1), 2: reason(2) };
1266
+ }, [structure, flow.structureKey, flow.diagramLevels]);
708
1267
 
709
1268
  // ---- People and agents ----
710
1269
 
@@ -722,9 +1281,108 @@ function Canvas({
722
1281
  const [following, setFollowing] = useState(followChanges);
723
1282
  const toggleFollowing = useCallback(() => setFollowing((on) => !on), []);
724
1283
 
725
- const [shareOpen, setShareOpen] = useState(false);
726
- const openShare = useCallback(() => setShareOpen(true), []);
1284
+ const [shareOpen, setShareOpen] = useState<false | 'choose' | 'invite'>(false);
1285
+ const openShare = useCallback(() => setShareOpen('choose'), []);
1286
+ const openLiveCollaboration = useCallback(() => setShareOpen('invite'), []);
727
1287
  const closeShare = useCallback(() => setShareOpen(false), []);
1288
+ const [helpOpen, setHelpOpen] = useState(false);
1289
+ const openHelp = useCallback(() => setHelpOpen(true), []);
1290
+ const closeHelp = useCallback(() => setHelpOpen(false), []);
1291
+
1292
+ // ---- Files and images (as Studio on the web's: the same .eventcatalog files, so diagrams move between them) ----
1293
+
1294
+ // A short message at the top (a file that couldn't be opened or saved)
1295
+ const [notice, setNotice] = useState<string>();
1296
+ useEffect(() => {
1297
+ if (!notice) return;
1298
+ const timer = setTimeout(() => setNotice(undefined), 6000);
1299
+ return () => clearTimeout(timer);
1300
+ }, [notice]);
1301
+ /** The canvas as a diagram (the model a .eventcatalog file holds) */
1302
+ const canvasModel = () =>
1303
+ canvasToModel(
1304
+ nodesRef.current,
1305
+ edgesRef.current,
1306
+ flow.levelLayouts,
1307
+ { title: flow.title || 'Untitled canvas', resources: [], relations: [] },
1308
+ comments.threads,
1309
+ flow.hotspots
1310
+ );
1311
+ const saveToFile = useStableCallback(() => {
1312
+ const text = toDiagramFile(canvasModel());
1313
+ downloadFile({ name: fileNameFor(flow.title || 'canvas', DIAGRAM_FILE_EXTENSION), mimeType: 'application/json', text });
1314
+ });
1315
+ const exportImage = useStableCallback(async () => {
1316
+ const image = canvasRef.current && (await canvasImage(canvasRef.current));
1317
+ if (!image) return setNotice('Nothing to export yet: put something on the canvas first.');
1318
+ downloadFile({ name: fileNameFor(flow.title || 'canvas', '.png'), mimeType: 'image/png', base64: image.split(',')[1] });
1319
+ });
1320
+ /**
1321
+ * A .eventcatalog file (from Studio on the web, or saved here), opened on this canvas while it's empty, else as a
1322
+ * new canvas (this one stays as it is)
1323
+ */
1324
+ const openFile = useStableCallback(async () => {
1325
+ if (!canvasesApiUrl) return;
1326
+ const file = await pickDiagramFile();
1327
+ if (!file) return;
1328
+ const opened = await readDiagramFile(file);
1329
+ if ('error' in opened) return setNotice(opened.error);
1330
+ try {
1331
+ const content = await modelToCanvas(opened.model);
1332
+ if (!nodesRef.current.length && !comments.threads.length && !flow.hotspots.length) {
1333
+ flow.addContent(content);
1334
+ if (!flow.title || flow.title === 'Untitled canvas') flow.rename(opened.model.title);
1335
+ setTimeout(() => void fitView({ ...fitOptions, duration: 450 }), 100);
1336
+ return;
1337
+ }
1338
+ const created = await createCanvasThroughApi(canvasesApiUrl, { title: opened.model.title, createdBy: name });
1339
+ if (!keepOpened(created.id, content))
1340
+ return setNotice("Couldn't open it in a new canvas here (this browser's storage is blocked).");
1341
+ window.location.assign(created.url);
1342
+ } catch (error) {
1343
+ setNotice(error instanceof Error ? error.message : "Couldn't open that file.");
1344
+ }
1345
+ });
1346
+ // A file opened into this canvas (just made for it): put on it once it's loaded, if it's still empty
1347
+ useEffect(() => {
1348
+ if (!flow.synced) return;
1349
+ const content = takeOpened(canvasId);
1350
+ if (!content || nodesRef.current.length) return;
1351
+ flow.addContent(content);
1352
+ setTimeout(() => void fitView({ ...fitOptions, duration: 450 }), 100);
1353
+ }, [flow.synced, canvasId]);
1354
+ useHotkeys('mod+o', () => void openFile(), { preventDefault: true, enabled: !!canvasesApiUrl });
1355
+ useHotkeys('mod+s', () => saveToFile(), { preventDefault: true });
1356
+ useHotkeys('mod+shift+e', () => void exportImage(), { preventDefault: true });
1357
+ // ? for help (by the character typed, so it works on every keyboard layout)
1358
+ useEffect(() => {
1359
+ const onKeyDown = (event: KeyboardEvent) => {
1360
+ if ((event.target as HTMLElement)?.closest?.('input, textarea, [contenteditable]')) return;
1361
+ if (event.key === '?') {
1362
+ event.preventDefault();
1363
+ setHelpOpen(true);
1364
+ }
1365
+ };
1366
+ document.addEventListener('keydown', onKeyDown);
1367
+ return () => document.removeEventListener('keydown', onKeyDown);
1368
+ }, []);
1369
+ const [deleteOpen, setDeleteOpen] = useState(false);
1370
+ const openDelete = useCallback(() => setDeleteOpen(true), []);
1371
+ const closeDelete = useCallback(() => setDeleteOpen(false), []);
1372
+ // Deleted here: on the way to Studio. Set before asking, since the server closes the canvas before it answers
1373
+ // (the canvas closing as it's deleted isn't news)
1374
+ const [leaving, setLeaving] = useState(false);
1375
+ const deleteCanvas = useStableCallback(async () => {
1376
+ if (!canvasesApiUrl) return;
1377
+ setLeaving(true);
1378
+ try {
1379
+ await deleteCanvasThroughApi(canvasesApiUrl, canvasId);
1380
+ } catch (error) {
1381
+ setLeaving(false);
1382
+ throw error;
1383
+ }
1384
+ window.location.assign(studioUrl ?? '/');
1385
+ });
728
1386
  const [connectOpen, setConnectOpen] = useState(false);
729
1387
  const openConnect = useCallback(() => setConnectOpen(true), []);
730
1388
  const closeConnect = useCallback(() => setConnectOpen(false), []);
@@ -764,11 +1422,26 @@ function Canvas({
764
1422
  { preventDefault: true }
765
1423
  );
766
1424
  // On every level: notes are changed on L1 and L2 too (and the levels show what's undone on the canvas)
767
- useHotkeys('mod+z', () => flow.undo(), { preventDefault: true });
768
- useHotkeys(['mod+shift+z', 'mod+y'], () => flow.redo(), { preventDefault: true });
769
- useHotkeys('c', () => onCanvas() && setCommentMode((on) => !on));
1425
+ useHotkeys('mod+z', () => !lockedRef.current && flow.undo(), { preventDefault: true });
1426
+ useHotkeys(['mod+shift+z', 'mod+y'], () => !lockedRef.current && flow.redo(), { preventDefault: true });
1427
+ // Drawing tools' keys: V to select, H for the hand, N, T, C and A to put something down, 1-3 for the levels,
1428
+ // Shift+1 to fit
1429
+ useHotkeys('n', () => pickTool('note'));
1430
+ useHotkeys('t', () => pickTool('text'));
1431
+ useHotkeys('c', () => pickTool('comment'));
1432
+ // A for attention: a hotspot
1433
+ useHotkeys('a', () => pickTool('hotspot'));
1434
+ useHotkeys('v', () => setTool('select'));
1435
+ useHotkeys('h', () => setTool('hand'));
1436
+ useHotkeys(['1', '2', '3'], (event) => {
1437
+ const next = Number(event.code.replace('Digit', '')) as Level;
1438
+ if (next === 3 || !unavailableLevels[next]) changeLevel(next);
1439
+ });
1440
+ useHotkeys('shift+1', () => void fitView({ ...fitOptions, duration: 500 }), { useKey: false });
770
1441
  useHotkeys('escape', () => {
771
- setCommentMode(false);
1442
+ setHotspotDraft(null);
1443
+ setOpenHotspot(null);
1444
+ setTool('select');
772
1445
  setDraft(null);
773
1446
  setOpenThreadId(null);
774
1447
  clearSelectedThreads();
@@ -786,20 +1459,48 @@ function Canvas({
786
1459
  comments.deleteThreads([...selectedThreads]);
787
1460
  clearSelectedThreads();
788
1461
  });
789
- const toggleCommentMode = useCallback(() => setCommentMode((on) => !on), []);
1462
+ /** A tool that puts something down: picked, or put back to selecting if it was already */
1463
+ const pickTool = useStableCallback((next: 'text' | 'note' | 'comment' | 'hotspot') => {
1464
+ // (comments can be left on a locked canvas: reviewing it changes nothing on it)
1465
+ if (lockedRef.current && next !== 'comment') return;
1466
+ // Comments are pinned to the canvas people edit (L3)
1467
+ if (next === 'comment' && levelRef.current !== 3) changeLevel(3);
1468
+ setTool((current) => (current === next ? 'select' : next));
1469
+ });
1470
+ const toggleCommentMode = useCallback(() => pickTool('comment'), [pickTool]);
1471
+ const toggleNoteMode = useCallback(() => pickTool('note'), [pickTool]);
1472
+ const toggleTextMode = useCallback(() => pickTool('text'), [pickTool]);
1473
+ const toggleHotspotMode = useCallback(() => pickTool('hotspot'), [pickTool]);
1474
+ const selectTool = useCallback(() => setTool('select'), []);
1475
+ const handTool = useCallback(() => setTool('hand'), []);
1476
+ const toggleLock = useStableCallback(() => {
1477
+ setTool('select');
1478
+ flow.setLocked(lockedRef.current ? null : name);
1479
+ });
790
1480
 
791
1481
  const startComment = (anchor: CommentAnchor) => {
792
1482
  setOpenThreadId(null);
793
1483
  setDraft(anchor);
794
- setCommentMode(false);
1484
+ setTool('select');
795
1485
  };
796
1486
 
797
1487
  const flowPositionOf = (event: { clientX: number; clientY: number }) =>
798
1488
  screenToFlowPosition({ x: event.clientX, y: event.clientY });
799
1489
 
1490
+ // A place on L1 or L2 is on that level (they're laid out on their own): what's put there says so
1491
+ const placeLevel = () => (levelRef.current === 3 ? {} : { level: levelRef.current as 1 | 2 });
800
1492
  const onPaneClick = useStableCallback((event: ReactMouseEvent) => {
801
1493
  clearSelectedThreads();
1494
+ if (tool === 'hand') return;
802
1495
  if (commentMode && editable) return startComment(anchorAt(flowPositionOf(event)));
1496
+ // Flagging: a hotspot where it's clicked
1497
+ if (tool === 'hotspot') return startHotspot({ position: flowPositionOf(event), ...placeLevel() });
1498
+ // The text and note tools: put where it's clicked, then back to selecting
1499
+ if (placing) {
1500
+ setTool('select');
1501
+ if (tool === 'text') return writeTextAt(flowPositionOf(event));
1502
+ return addNoteAt(flowPositionOf(event));
1503
+ }
803
1504
  // A click inside a container (not on something in it) selects it: its body lets the pointer through so you can
804
1505
  // drag a selection box in it, so the click lands on the canvas
805
1506
  // (after React Flow clears the selection, which it does once this returns)
@@ -811,24 +1512,31 @@ function Canvas({
811
1512
 
812
1513
  // Double clicking a node edits its details (notes are edited on the canvas, by double clicking them too)
813
1514
  const onNodeDoubleClick = useStableCallback((_: ReactMouseEvent, node: Node) => {
814
- if (editable && !isWrittenOnCanvas(node.type)) setEditingId(node.id);
1515
+ // On L1 and L2 too: what's there is on the canvas (a system card is the system)
1516
+ if (!isWrittenOnCanvas(node.type) && nodesRef.current.some((other) => other.id === node.id)) setEditingId(node.id);
815
1517
  });
816
1518
  // ...and inside a container (not on something in it): its body lets the pointer through, so it's on the canvas
1519
+ // ...and anywhere else on the canvas writes there, as on Excalidraw's (and Studio on the web's)
817
1520
  const onCanvasDoubleClick = useStableCallback((event: ReactMouseEvent) => {
818
- if (!editable || !(event.target as Element).classList?.contains('react-flow__pane')) return;
819
- const container = findGroupAtPoint(flowPositionOf(event), nodesRef.current);
820
- if (container) setEditingId(container.id);
1521
+ if (!(event.target as Element).classList?.contains('react-flow__pane')) return;
1522
+ const container = editable ? findGroupAtPoint(flowPositionOf(event), nodesRef.current) : undefined;
1523
+ if (container) return setEditingId(container.id);
1524
+ if (!lockedRef.current) writeTextAt(flowPositionOf(event));
821
1525
  });
822
1526
 
823
1527
  const onNodeClick = useStableCallback((event: ReactMouseEvent, node: Node) => {
824
1528
  // Clicking one node selects just it (with shift or ⌘, it's added to what's selected)
825
1529
  if (!event.shiftKey && !event.metaKey) clearSelectedThreads();
1530
+ // Not on notes and text: they're written on, not what's being flagged or commented on
1531
+ if (isWrittenOnCanvas(node.type)) return;
1532
+ // A hotspot on it (it shows on the node, wherever it goes, on every level it's on)
1533
+ if (tool === 'hotspot') return startHotspot({ nodeId: node.id, position: flowPositionOf(event) });
826
1534
  if (commentMode && editable) startComment(anchorAt(flowPositionOf(event), node, lookupNodes()));
827
1535
  });
828
1536
 
829
1537
  const openContextMenu = useStableCallback((event: ReactMouseEvent | MouseEvent, node?: Node) => {
830
1538
  event.preventDefault();
831
- if (!editable) return;
1539
+ if (!editable || lockedRef.current) return;
832
1540
  setContextMenu({ x: event.clientX, y: event.clientY, flowPosition: flowPositionOf(event), node });
833
1541
  });
834
1542
  const onPaneContextMenu = useCallback((event: ReactMouseEvent | MouseEvent) => openContextMenu(event), [openContextMenu]);
@@ -849,6 +1557,26 @@ function Canvas({
849
1557
  if (contextMenu?.node) setEditingId(contextMenu.node.id);
850
1558
  setContextMenu(null);
851
1559
  });
1560
+ /** A sticky note where the menu was opened, or to the right of the node right clicked (about it) */
1561
+ const noteFromMenu = useStableCallback(() => {
1562
+ if (!contextMenu) return;
1563
+ const node = contextMenu.node && lookupNodes().get(contextMenu.node.id);
1564
+ if (node) {
1565
+ const at = getAbsolutePosition(node, lookupNodes());
1566
+ const width = node.measured?.width ?? node.width ?? 240;
1567
+ addNoteAt({ x: at.x + width + 140, y: at.y + 88 });
1568
+ } else addNoteAt(contextMenu.flowPosition);
1569
+ setContextMenu(null);
1570
+ });
1571
+ const hotspotFromMenu = useStableCallback(() => {
1572
+ if (!contextMenu) return;
1573
+ startHotspot({ ...(contextMenu.node ? { nodeId: contextMenu.node.id } : placeLevel()), position: contextMenu.flowPosition });
1574
+ setContextMenu(null);
1575
+ });
1576
+ const reorderFromMenu = useStableCallback(() => {
1577
+ setContextMenu(null);
1578
+ onReorder();
1579
+ });
852
1580
  const copyFromMenu = useStableCallback(() => {
853
1581
  // Through the copy event, so it's copied like ⌘C (to the system clipboard too)
854
1582
  copyOnly.current = menuTargets();
@@ -871,6 +1599,7 @@ function Canvas({
871
1599
  });
872
1600
 
873
1601
  const openThread = useStableCallback((thread: Thread) => {
1602
+ setListingComments(false);
874
1603
  const { x, y } = getAnchorPosition(thread, lookupNodes());
875
1604
  // Leave room for the thread, which opens to the right of its pin
876
1605
  void setCenter(x + 150, y, { zoom: getZoom(), duration: 300 });
@@ -878,6 +1607,18 @@ function Canvas({
878
1607
  setOpenThreadId(thread.id);
879
1608
  });
880
1609
  const toggleResolved = useCallback(() => setShowResolved((show) => !show), []);
1610
+ const [panelTab, setPanelTab] = useState<LeftPanelTab>('components');
1611
+ // All the comments, listed over the bottom left controls (as Studio on the web's): clicking one goes to it
1612
+ const [listingComments, setListingComments] = useState(false);
1613
+ const toggleCommentList = useCallback(() => {
1614
+ setListingHotspots(false);
1615
+ setListingComments((shown) => !shown);
1616
+ }, []);
1617
+ const openComments = comments.threads.filter((thread) => !thread.resolved).length;
1618
+ const commentsControl = useMemo(
1619
+ () => ({ open: openComments, onToggle: toggleCommentList, active: listingComments }),
1620
+ [openComments, toggleCommentList, listingComments]
1621
+ );
881
1622
  const openThreadById = useCallback((id: string | null) => {
882
1623
  setDraft(null);
883
1624
  setOpenThreadId(id);
@@ -909,16 +1650,37 @@ function Canvas({
909
1650
  })
910
1651
  );
911
1652
  }, [selectedKey]);
912
- // The node whose details are being edited in the panel: opened by double clicking it, or "Edit details" in its
913
- // menu (selecting a node doesn't open it). Notes are edited on the canvas.
1653
+ // The node whose details are being edited in its card: opened with the edit button over it, by double clicking it,
1654
+ // or "Edit details" in its menu (selecting a node doesn't open it). Notes are edited on the canvas.
914
1655
  const [editingId, setEditingId] = useState<string | null>(null);
915
- const editing =
916
- editable && editingId ? flow.nodes.find((node) => node.id === editingId && !isWrittenOnCanvas(node.type)) : undefined;
917
- // What the panel shows (not the node's position, so dragging it doesn't re-render the panel)
1656
+ const editing = editingId ? flow.nodes.find((node) => node.id === editingId && !isWrittenOnCanvas(node.type)) : undefined;
1657
+ // What the card shows (not the node's position, so dragging it doesn't re-render the card)
918
1658
  const inspected = useMemo(
919
1659
  () => (editing ? { id: editing.id, type: editing.type, data: editing.data } : null),
920
1660
  [editing?.id, editing?.type, editing?.data]
921
1661
  );
1662
+ // The domain or system it's in
1663
+ const parent = editing?.parentId ? flow.nodes.find((node) => node.id === editing.parentId) : undefined;
1664
+ const inspectedParent = parent ? String(getResource(parent.type, parent.data).name ?? '') || undefined : undefined;
1665
+ // What it's connected to, for its card
1666
+ const inspectedConnections = useMemo((): NodeConnection[] => {
1667
+ if (!editingId) return [];
1668
+ const byId = new Map(flow.nodes.map((node) => [node.id, node]));
1669
+ return flow.edges.flatMap((edge) => {
1670
+ const outgoing = edge.source === editingId;
1671
+ if (!outgoing && edge.target !== editingId) return [];
1672
+ const other = byId.get(outgoing ? edge.target : edge.source);
1673
+ if (!other || isWrittenOnCanvas(other.type)) return [];
1674
+ const name = String(getResource(other.type, other.data).name ?? '') || getNodeLabel(other.type);
1675
+ return [{ id: other.id, name, type: other.type, label: typeof edge.label === 'string' ? edge.label : undefined, outgoing }];
1676
+ });
1677
+ }, [editingId, flow.nodes, flow.edges]);
1678
+ // Goes to a node it's connected to, and shows its card
1679
+ const openConnected = useStableCallback((id: string) => {
1680
+ setEditingId(id);
1681
+ select(new Set([id]));
1682
+ void fitView({ nodes: [{ id }], duration: 400, padding: 0.3, maxZoom: 1.2 });
1683
+ });
922
1684
  const closePanel = useCallback(() => setEditingId(null), []);
923
1685
  // An accepted or rejected canvas, and when it was decided
924
1686
  const { status: canvasStatus, history: statusHistory } = flow.canvasStatus;
@@ -929,233 +1691,400 @@ function Canvas({
929
1691
  return (
930
1692
  <UpdateNodeDataContext.Provider value={flow.updateNodeData}>
931
1693
  <DropTargetContext.Provider value={dropTargetId}>
932
- <div className="flex h-full min-h-0 text-[rgb(var(--ec-page-text))]">
933
- <LeftPanel
934
- resources={resources}
935
- keysOnCanvas={keysOnCanvas}
936
- onAddComponent={addComponent}
937
- onAddResource={addResourceFromPanel}
938
- threads={comments.threads}
939
- showResolved={showResolved}
940
- onToggleResolved={toggleResolved}
941
- onOpenThread={openThread}
942
- />
943
-
944
- <div
945
- ref={canvasRef}
946
- className={`eventcatalog-visualizer studio-canvas relative flex-1 min-w-0 ${commentMode ? 'comment-mode' : ''} ${
947
- transitioning ? 'is-layout-transition' : ''
948
- }`}
949
- onPointerMove={trackPointer}
950
- onPointerLeave={clearPointer}
951
- onDoubleClick={onCanvasDoubleClick}
952
- >
953
- <ReactFlow
954
- nodes={shownNodes}
955
- edges={shownEdges}
956
- nodeTypes={nodeTypes}
957
- edgeTypes={edgeTypes}
958
- nodesDraggable={editable}
959
- nodesConnectable={editable}
960
- elementsSelectable={editable}
961
- onNodesChange={editable ? flow.onNodesChange : onLevelNodesChange}
962
- onEdgesChange={editable ? flow.onEdgesChange : undefined}
963
- onConnect={flow.onConnect}
964
- // Drag either end of a connection to another node (dropped anywhere else, it stays where it was)
965
- edgesReconnectable={editable}
966
- onReconnect={editable ? flow.onReconnect : undefined}
967
- reconnectRadius={RECONNECT_RADIUS}
968
- onDragOver={onDragOver}
969
- onMoveStart={onMoveStart}
970
- onMoveEnd={onMoveEnd}
971
- onNodeDragStart={onNodeDragStart}
972
- onNodeDrag={onNodeDrag}
973
- onNodeDragStop={onNodeDragStop}
974
- onDrop={onDrop}
975
- onPaneClick={onPaneClick}
976
- onNodeClick={onNodeClick}
977
- onNodeDoubleClick={onNodeDoubleClick}
978
- onError={onFlowError}
979
- onSelectionStart={clearSelectedThreads}
980
- onPaneContextMenu={onPaneContextMenu}
981
- onNodeContextMenu={onNodeContextMenu}
982
- connectionLineType={ConnectionLineType.SmoothStep}
983
- connectionRadius={40}
984
- deleteKeyCode={DELETE_KEYS}
985
- multiSelectionKeyCode={MULTI_SELECTION_KEYS}
986
- selectionOnDrag={editable}
987
- // Read only levels can't select: dragging pans them
988
- panOnDrag={editable ? PAN_BUTTONS : true}
989
- panOnScroll
990
- zoomActivationKeyCode={ZOOM_KEYS}
991
- zoomOnDoubleClick={false}
992
- minZoom={0.1}
993
- maxZoom={2}
994
- >
995
- <Background gap={10} bgColor="rgb(var(--ec-page-bg))" color="rgb(var(--ec-page-border))" />
996
- <ViewportSharer onChange={flow.setViewport} />
997
- {/* Pointers, selections and comments are on the canvas people edit (L3) */}
998
- {editable && <RemotePresence store={flow.presence} clientId={flow.clientId} />}
999
- {editable && (
1000
- <CommentLayer
1001
- threads={comments.threads}
1002
- author={author}
1003
- openThreadId={openThreadId}
1004
- draft={draft}
1005
- showResolved={showResolved}
1006
- onOpen={openThreadById}
1007
- onCreate={createThread}
1008
- onCancelDraft={cancelDraft}
1009
- onReply={comments.reply}
1010
- onResolve={comments.setResolved}
1011
- onMove={comments.moveThread}
1012
- onMoveDraft={setDraft}
1013
- onDelete={deleteThread}
1014
- selectedIds={selectedThreads}
1015
- selectionOffset={threadsOffset}
1016
- />
1017
- )}
1018
- </ReactFlow>
1019
-
1020
- <AgentActivity
1021
- store={flow.presence}
1022
- clientId={flow.clientId}
1023
- enabled={editable}
1024
- following={following}
1025
- onToggleFollowing={toggleFollowing}
1026
- pauseRef={pauseCamera}
1027
- />
1028
-
1029
- {!editable && (
1030
- <div className="absolute left-1/2 top-16 z-10 flex -translate-x-1/2 items-center gap-3 rounded-full border px-4 py-1.5 text-xs shadow-sm bg-[rgb(var(--ec-accent-subtle))] border-[rgb(var(--ec-accent)/0.3)] text-[rgb(var(--ec-page-text))]">
1031
- <span>
1032
- <span className="font-semibold">L{level}</span> · {LEVELS.find((entry) => entry.level === level)?.description} ·
1033
- read only, sticky notes can be added
1034
- </span>
1035
- <button onClick={editInL3} className="font-semibold text-[rgb(var(--ec-accent))] hover:underline">
1036
- Edit in L3
1037
- </button>
1038
- </div>
1039
- )}
1040
-
1041
- {/* Accepted or rejected: still editable, but changing it makes it a draft again (for everyone) */}
1042
- {editable && decided && (
1043
- <div
1044
- className={`absolute left-1/2 top-16 z-10 flex max-w-[calc(100%-2rem)] -translate-x-1/2 items-center gap-2 rounded-full border px-4 py-1.5 text-xs shadow-sm ${CANVAS_STATUS_LOOK[decided.status].pill}`}
1045
- >
1046
- <span className="truncate">
1047
- <span className="font-semibold">{CANVAS_STATUS_LOOK[decided.status].label}</span>
1048
- {decided.change && ` by ${decided.change.by.name} · ${timeAgo(decided.change.at)}`}
1049
- {decided.change?.note && ` · “${decided.change.note}”`} · changing it moves it back to Draft
1050
- </span>
1051
- </div>
1052
- )}
1053
-
1054
- <CanvasControls
1055
- level={level}
1056
- onLevelChange={changeLevel}
1057
- unavailableLevels={unavailableLevels}
1058
- editable={editable}
1059
- onReorder={onReorder}
1060
- canUndo={flow.canUndo}
1061
- canRedo={flow.canRedo}
1062
- onUndo={flow.undo}
1063
- onRedo={flow.redo}
1064
- commentMode={commentMode}
1065
- onToggleCommentMode={toggleCommentMode}
1066
- onAddNote={addNote}
1067
- />
1068
-
1069
- <CanvasContextMenu
1070
- menu={contextMenu}
1071
- onAddComment={commentFromMenu}
1072
- onEditDetails={editFromMenu}
1073
- onCopy={copyFromMenu}
1074
- onDuplicate={duplicateFromMenu}
1075
- onPaste={lastCopied ? pasteFromMenu : undefined}
1076
- onDeleteNode={deleteFromMenu}
1077
- onClose={closeContextMenu}
1078
- />
1079
-
1080
- <CanvasHeader
1081
- title={flow.title}
1082
- onRetitle={flow.rename}
1083
- status={flow.status}
1084
- transport={flow.transport}
1085
- presence={flow.presence}
1086
- clientId={flow.clientId}
1087
- onJumpTo={jumpTo}
1088
- onRename={onRename}
1089
- onConnectAgent={openConnect}
1090
- onShare={openShare}
1091
- webMcp={webMcp}
1092
- shareUrl={shareUrl}
1093
- newCanvasUrl={newCanvasUrl}
1094
- canvasStatus={flow.canvasStatus.status}
1095
- statusHistory={flow.canvasStatus.history}
1096
- onStatusChange={flow.changeStatus}
1097
- />
1098
-
1099
- {/* Only on an empty canvas (not over comments people have left on it) */}
1100
- {flow.status === 'connected' && flow.nodes.length === 0 && comments.threads.length === 0 && !draft && (
1101
- <div className="pointer-events-none absolute inset-0 flex items-center justify-center">
1102
- <p className="text-sm text-[rgb(var(--ec-page-text-muted))]">
1103
- Drag components or catalog resources from the left onto the canvas, then share the link to work on it together.
1104
- </p>
1694
+ <EditingContext.Provider value={flow.sendEditing}>
1695
+ <StartWritingContext.Provider value={startWriting}>
1696
+ <LockedContext.Provider value={locked}>
1697
+ <div className="flex h-full min-h-0 text-[rgb(var(--ec-page-text))]">
1698
+ <div
1699
+ ref={canvasRef}
1700
+ className={`eventcatalog-visualizer studio-canvas relative flex-1 min-w-0 ${commentMode ? 'comment-mode' : ''} ${
1701
+ transitioning ? 'is-layout-transition' : ''
1702
+ } ${placing || tool === 'comment' || tool === 'hotspot' ? 'is-placing' : ''} ${tool === 'hand' ? 'is-panning' : ''}`}
1703
+ onPointerMove={trackPointer}
1704
+ onPointerLeave={clearPointer}
1705
+ onDoubleClick={onCanvasDoubleClick}
1706
+ >
1707
+ <ReactFlow
1708
+ nodes={visibleNodes}
1709
+ edges={shownEdges}
1710
+ onNodeMouseEnter={onNodeMouseEnter}
1711
+ onNodeMouseLeave={onHoverEnd}
1712
+ onEdgeMouseEnter={onEdgeMouseEnter}
1713
+ onEdgeMouseLeave={onHoverEnd}
1714
+ nodeTypes={nodeTypes}
1715
+ edgeTypes={edgeTypes}
1716
+ nodesDraggable={!locked && tool !== 'hand'}
1717
+ nodesConnectable={!locked && tool !== 'hand'}
1718
+ elementsSelectable={tool !== 'hand'}
1719
+ onNodesChange={editable ? flow.onNodesChange : onLevelNodesChange}
1720
+ onEdgesChange={editable ? flow.onEdgesChange : undefined}
1721
+ onConnect={editable ? flow.onConnect : onLevelConnect}
1722
+ // Drag either end of a connection to another node (dropped anywhere else, it stays where it was)
1723
+ edgesReconnectable={editable && !locked}
1724
+ onReconnect={editable ? flow.onReconnect : undefined}
1725
+ reconnectRadius={RECONNECT_RADIUS}
1726
+ onDragOver={locked ? undefined : onDragOver}
1727
+ onMoveStart={onMoveStart}
1728
+ onMoveEnd={onMoveEnd}
1729
+ onNodeDragStart={onNodeDragStart}
1730
+ onNodeDrag={onNodeDrag}
1731
+ onNodeDragStop={onNodeDragStop}
1732
+ onDrop={locked ? undefined : onDrop}
1733
+ onPaneClick={onPaneClick}
1734
+ onNodeClick={onNodeClick}
1735
+ onNodeDoubleClick={onNodeDoubleClick}
1736
+ onError={onFlowError}
1737
+ onSelectionStart={clearSelectedThreads}
1738
+ onPaneContextMenu={onPaneContextMenu}
1739
+ onNodeContextMenu={onNodeContextMenu}
1740
+ connectionLineType={ConnectionLineType.SmoothStep}
1741
+ connectionRadius={40}
1742
+ deleteKeyCode={locked ? null : DELETE_KEYS}
1743
+ multiSelectionKeyCode={MULTI_SELECTION_KEYS}
1744
+ selectionOnDrag={tool !== 'hand'}
1745
+ // The hand drags the canvas around; otherwise dragging selects (space, the middle button or scrolling pan)
1746
+ panOnDrag={tool === 'hand' ? true : PAN_BUTTONS}
1747
+ panOnScroll
1748
+ zoomActivationKeyCode={ZOOM_KEYS}
1749
+ zoomOnDoubleClick={false}
1750
+ minZoom={0.1}
1751
+ maxZoom={2}
1752
+ >
1753
+ <DiagramBackground />
1754
+ {/* Above the selected node's top right corner: its details, to edit them */}
1755
+ {toEdit && <EditButton nodeId={toEdit.id} locked={locked} onClick={editSelected} />}
1756
+ <LegendPanel legend={legend} hiddenKeys={hiddenLegendKeys} onLegendClick={toggleLegendKey} />
1757
+ <ViewportSharer onChange={flow.setViewport} />
1758
+ {/* Pointers, selections and comments are on the canvas people edit (L3) */}
1759
+ {editable && <RemotePresence store={flow.presence} clientId={flow.clientId} />}
1760
+ {editable && (
1761
+ <CommentLayer
1762
+ threads={comments.threads}
1763
+ author={author}
1764
+ openThreadId={openThreadId}
1765
+ draft={draft}
1766
+ showResolved={showResolved}
1767
+ onOpen={openThreadById}
1768
+ onCreate={createThread}
1769
+ onCancelDraft={cancelDraft}
1770
+ onReply={comments.reply}
1771
+ onResolve={comments.setResolved}
1772
+ onMove={comments.moveThread}
1773
+ onMoveDraft={setDraft}
1774
+ onDelete={deleteThread}
1775
+ selectedIds={selectedThreads}
1776
+ selectionOffset={threadsOffset}
1777
+ />
1778
+ )}
1779
+ {/* Hotspots on every level (on L1 and L2, on what they're in) */}
1780
+ <HotspotLayer
1781
+ spots={hotspotSpots}
1782
+ openKey={openHotspot}
1783
+ draft={hotspotDraft}
1784
+ locked={locked}
1785
+ movable={!locked}
1786
+ selectedIds={null}
1787
+ onMove={moveHotspots}
1788
+ onDropTarget={setHotspotDropTarget}
1789
+ onOpen={setOpenHotspot}
1790
+ onCreate={createHotspot}
1791
+ onCancelDraft={cancelHotspot}
1792
+ onUpdate={flow.updateHotspot}
1793
+ onDelete={deleteHotspot}
1794
+ onGo={goToHotspot}
1795
+ />
1796
+ </ReactFlow>
1797
+
1798
+ <AgentActivity
1799
+ store={flow.presence}
1800
+ clientId={flow.clientId}
1801
+ enabled={editable}
1802
+ following={following}
1803
+ onToggleFollowing={toggleFollowing}
1804
+ pauseRef={pauseCamera}
1805
+ />
1806
+
1807
+ {!editable && (
1808
+ <div className="absolute left-1/2 top-16 z-10 flex -translate-x-1/2 items-center gap-3 rounded-full border px-4 py-1.5 text-xs shadow-sm bg-[rgb(var(--ec-accent-subtle))] border-[rgb(var(--ec-accent)/0.3)] text-[rgb(var(--ec-page-text))]">
1809
+ <span>
1810
+ <span className="font-semibold">L{level}</span> ·{' '}
1811
+ {LEVELS.find((entry) => entry.level === level)?.description}
1812
+ </span>
1813
+ <button onClick={editInL3} className="font-semibold text-[rgb(var(--ec-accent))] hover:underline">
1814
+ Everything is on L3
1815
+ </button>
1816
+ </div>
1817
+ )}
1818
+
1819
+ {/* Accepted or rejected: still editable, but changing it makes it a draft again (for everyone) */}
1820
+ {editable && decided && (
1821
+ <div
1822
+ className={`absolute left-1/2 top-16 z-10 flex max-w-[calc(100%-2rem)] -translate-x-1/2 items-center gap-2 rounded-full border px-4 py-1.5 text-xs shadow-sm ${CANVAS_STATUS_LOOK[decided.status].pill}`}
1823
+ >
1824
+ <span className="truncate">
1825
+ <span className="font-semibold">{CANVAS_STATUS_LOOK[decided.status].label}</span>
1826
+ {decided.change && ` by ${decided.change.by.name} · ${timeAgo(decided.change.at)}`}
1827
+ {decided.change?.note && ` · “${decided.change.note}”`} · changing it moves it back to Draft
1828
+ </span>
1829
+ </div>
1830
+ )}
1831
+
1832
+ {/* At the top, as drawing tools have their tools (and Studio on the web has them) */}
1833
+ <CanvasControls
1834
+ level={level}
1835
+ onLevelChange={changeLevel}
1836
+ unavailableLevels={unavailableLevels}
1837
+ editable={editable}
1838
+ locked={locked}
1839
+ onToggleLock={toggleLock}
1840
+ lockedBy={flow.lockedBy}
1841
+ selectMode={tool === 'select'}
1842
+ onSelectMode={selectTool}
1843
+ handMode={tool === 'hand'}
1844
+ onHandMode={handTool}
1845
+ noteMode={tool === 'note'}
1846
+ onToggleNoteMode={toggleNoteMode}
1847
+ textMode={tool === 'text'}
1848
+ onToggleTextMode={toggleTextMode}
1849
+ commentMode={commentMode}
1850
+ onToggleCommentMode={toggleCommentMode}
1851
+ hotspotMode={tool === 'hotspot'}
1852
+ onToggleHotspotMode={toggleHotspotMode}
1853
+ />
1854
+
1855
+ {/* The palette: components, the catalog and the comments, under the canvas's title */}
1856
+ {!locked && (
1857
+ <LeftPanel
1858
+ level={level}
1859
+ onShowAll={editInL3}
1860
+ resources={resources}
1861
+ keysOnCanvas={keysOnCanvas}
1862
+ onAddComponent={addComponent}
1863
+ onAddResource={addResourceFromPanel}
1864
+ tab={panelTab}
1865
+ onTabChange={setPanelTab}
1866
+ startCollapsed={paletteCollapsed}
1867
+ />
1868
+ )}
1869
+
1870
+ {/* All the hotspots, most important first, over the bottom left controls: clicking one goes to it */}
1871
+ {listingComments && (
1872
+ <div className="absolute bottom-[64px] left-3 z-20 flex max-h-[min(480px,calc(100%-140px))] w-80 flex-col overflow-hidden rounded-xl border border-[rgb(var(--ec-page-border))] bg-[rgb(var(--ec-card-bg))] shadow-xl">
1873
+ <div className="flex flex-none items-center justify-between border-b border-[rgb(var(--ec-page-border))] px-3 py-2 text-sm font-semibold">
1874
+ Comments
1875
+ <button
1876
+ type="button"
1877
+ aria-label="Close"
1878
+ onClick={toggleCommentList}
1879
+ className="rounded p-1 text-[rgb(var(--ec-page-text-muted))] hover:bg-[rgb(var(--ec-content-hover))]"
1880
+ >
1881
+ <X className="h-4 w-4" />
1882
+ </button>
1883
+ </div>
1884
+ <div className="flex min-h-0 flex-1 flex-col overflow-y-auto pt-2">
1885
+ <CommentList
1886
+ threads={comments.threads}
1887
+ showResolved={showResolved}
1888
+ onToggleResolved={toggleResolved}
1889
+ onOpen={openThread}
1890
+ />
1891
+ </div>
1892
+ </div>
1893
+ )}
1894
+ {listingHotspots && (
1895
+ <div className="absolute bottom-[64px] left-3 z-20 flex max-h-[min(480px,calc(100%-140px))] w-80 flex-col overflow-hidden rounded-xl border border-[rgb(var(--ec-page-border))] bg-[rgb(var(--ec-card-bg))] shadow-xl">
1896
+ <div className="flex flex-none items-center justify-between border-b border-[rgb(var(--ec-page-border))] px-3 py-2 text-sm font-semibold">
1897
+ Hotspots
1898
+ <button
1899
+ type="button"
1900
+ aria-label="Close"
1901
+ onClick={toggleHotspotList}
1902
+ className="rounded p-1 text-[rgb(var(--ec-page-text-muted))] hover:bg-[rgb(var(--ec-content-hover))]"
1903
+ >
1904
+ <X className="h-4 w-4" />
1905
+ </button>
1906
+ </div>
1907
+ <div className="min-h-0 flex-1 overflow-y-auto">
1908
+ <HotspotList hotspots={flow.hotspots} nameOf={nameOfNode} onOpen={goToHotspot} />
1909
+ </div>
1910
+ </div>
1911
+ )}
1912
+
1913
+ {/* Bottom left: zoom, undo and redo (as Excalidraw's), fitting and reordering, the comments and hotspots */}
1914
+ <div className="absolute bottom-3 left-3 z-10 flex items-center gap-2">
1915
+ <ViewControls
1916
+ canUndo={flow.canUndo && !locked}
1917
+ canRedo={flow.canRedo && !locked}
1918
+ onUndo={flow.undo}
1919
+ onRedo={flow.redo}
1920
+ onReorder={onReorder}
1921
+ reorderLabel={
1922
+ editable ? "Reorder diagram (the visualiser's layout)" : `Reorder L${level} (the visualiser's layout)`
1923
+ }
1924
+ locked={locked}
1925
+ fitOptions={fitOptions}
1926
+ comments={commentsControl}
1927
+ hotspots={hotspotsControl}
1928
+ />
1929
+ </div>
1930
+
1931
+ <ContextMenu
1932
+ menu={contextMenu}
1933
+ onAddComment={commentFromMenu}
1934
+ onAddHotspot={hotspotFromMenu}
1935
+ onAddNote={noteFromMenu}
1936
+ onEditDetails={editFromMenu}
1937
+ onCopy={copyFromMenu}
1938
+ onDuplicate={duplicateFromMenu}
1939
+ onPaste={lastCopied ? pasteFromMenu : undefined}
1940
+ onReorder={reorderFromMenu}
1941
+ onDeleteNode={deleteFromMenu}
1942
+ onClose={closeContextMenu}
1943
+ />
1944
+
1945
+ <CanvasHeader
1946
+ title={flow.title}
1947
+ onRetitle={flow.rename}
1948
+ status={flow.status}
1949
+ transport={flow.transport}
1950
+ presence={flow.presence}
1951
+ clientId={flow.clientId}
1952
+ onJumpTo={jumpTo}
1953
+ me={author}
1954
+ onRename={onRename}
1955
+ onRecolor={onRecolor}
1956
+ onConnectAgent={openConnect}
1957
+ onShare={openShare}
1958
+ onOpenFile={canvasesApiUrl ? openFile : undefined}
1959
+ onSaveToFile={saveToFile}
1960
+ onExportImage={exportImage}
1961
+ onLiveCollaboration={openLiveCollaboration}
1962
+ onHelp={openHelp}
1963
+ webMcp={webMcp}
1964
+ shareUrl={shareUrl}
1965
+ newCanvasUrl={newCanvasUrl}
1966
+ canvasStatus={flow.canvasStatus.status}
1967
+ statusHistory={flow.canvasStatus.history}
1968
+ onStatusChange={flow.changeStatus}
1969
+ onDelete={canvasesApiUrl ? openDelete : undefined}
1970
+ />
1971
+
1972
+ {flow.deleted && !leaving && (
1973
+ <div className="absolute inset-0 z-40 flex items-center justify-center bg-[rgb(var(--ec-page-bg)/0.85)] p-4">
1974
+ <div className="max-w-sm space-y-2 rounded-xl border p-5 text-center shadow-xl bg-[rgb(var(--ec-card-bg))] border-[rgb(var(--ec-page-border))]">
1975
+ <h2 className="text-base font-semibold">This canvas was deleted</h2>
1976
+ <p className="text-sm text-[rgb(var(--ec-page-text-muted))]">
1977
+ Someone deleted it, so it can't be opened or changed any more.
1978
+ </p>
1979
+ {studioUrl && (
1980
+ <a
1981
+ href={studioUrl}
1982
+ className="mt-2 inline-flex rounded-lg px-4 py-2 text-sm font-medium bg-[rgb(var(--ec-button-bg))] text-[rgb(var(--ec-button-text))] hover:bg-[rgb(var(--ec-button-bg-hover))]"
1983
+ >
1984
+ Back to Studio
1985
+ </a>
1986
+ )}
1987
+ </div>
1988
+ </div>
1989
+ )}
1990
+
1991
+ {/* Only on an empty canvas (not over comments people have left on it): what it's for, and what to do */}
1992
+ {flow.status === 'connected' &&
1993
+ flow.nodes.length === 0 &&
1994
+ comments.threads.length === 0 &&
1995
+ flow.hotspots.length === 0 &&
1996
+ !draft && (
1997
+ <EmptyCanvas
1998
+ // Just the hints to the palette, levels and Share (the catalog is around it, not a blank page)
1999
+ welcome={false}
2000
+ onOpen={canvasesApiUrl ? openFile : undefined}
2001
+ onHelp={openHelp}
2002
+ onConnect={openConnect}
2003
+ palette={!locked}
2004
+ live
2005
+ />
2006
+ )}
2007
+
2008
+ {/* The node being edited: its card, next to it (as Studio on the web's) */}
2009
+ {inspected && (
2010
+ <NodeDetails
2011
+ key={inspected.id}
2012
+ node={inspected}
2013
+ parentName={inspectedParent}
2014
+ connections={inspectedConnections}
2015
+ onOpenNode={openConnected}
2016
+ containerRef={canvasRef}
2017
+ locked={locked}
2018
+ onChange={flow.updateNodeData}
2019
+ onClose={closePanel}
2020
+ />
2021
+ )}
2022
+ </div>
2023
+
2024
+ {chooser && (
2025
+ <ContainerChooser
2026
+ kind={chooser.resource.collection === 'domains' ? 'domain' : 'system'}
2027
+ name={chooser.resource.name}
2028
+ contents={chooserContents}
2029
+ position={flowToScreenPosition(chooser.at.point)}
2030
+ onChoose={choose}
2031
+ onCancel={cancelChooser}
2032
+ />
2033
+ )}
2034
+ {connectionsChooser && (
2035
+ <ConnectionsChooser
2036
+ resource={connectionsChooser.resource}
2037
+ groups={connectionsChooser.groups}
2038
+ position={flowToScreenPosition(connectionsChooser.at.point)}
2039
+ onChoose={chooseConnections}
2040
+ onCancel={cancelConnectionsChooser}
2041
+ />
2042
+ )}
2043
+ {shareOpen && (
2044
+ <ShareDialog
2045
+ initialView={shareOpen}
2046
+ keptInMemory={keptInMemory}
2047
+ url={agentLink.canvasUrl}
2048
+ title={flow.title}
2049
+ presence={flow.presence}
2050
+ clientId={flow.clientId}
2051
+ onRename={onRename}
2052
+ onJumpTo={jumpTo}
2053
+ onConnectAgent={openConnect}
2054
+ onClose={closeShare}
2055
+ />
2056
+ )}
2057
+ {helpOpen && <ShortcutsDialog onClose={closeHelp} />}
2058
+ {notice && (
2059
+ <div
2060
+ role="status"
2061
+ className="absolute left-1/2 top-[72px] z-30 max-w-md -translate-x-1/2 rounded-lg border border-[rgb(var(--ec-page-border))] bg-[rgb(var(--ec-card-bg))] px-4 py-2 text-sm shadow-lg"
2062
+ >
2063
+ {notice}
2064
+ </div>
2065
+ )}
2066
+ {deleteOpen && (
2067
+ <DeleteCanvasDialog
2068
+ title={flow.title}
2069
+ presence={flow.presence}
2070
+ clientId={flow.clientId}
2071
+ onDelete={deleteCanvas}
2072
+ onClose={closeDelete}
2073
+ />
2074
+ )}
2075
+ {connectOpen && (
2076
+ <ConnectAgentDialog
2077
+ link={agentLink}
2078
+ webMcp={webMcp}
2079
+ relayScriptPath={relayScriptPath}
2080
+ onEnableWebMcp={turnOnWebMcp}
2081
+ onClose={closeConnect}
2082
+ />
2083
+ )}
1105
2084
  </div>
1106
- )}
1107
- </div>
1108
-
1109
- {chooser && (
1110
- <ContainerChooser
1111
- resource={chooser.resource}
1112
- contents={chooserContents}
1113
- position={flowToScreenPosition(chooser.center)}
1114
- onChoose={choose}
1115
- onCancel={cancelChooser}
1116
- />
1117
- )}
1118
- {connectionsChooser && (
1119
- <ConnectionsChooser
1120
- resource={connectionsChooser.resource}
1121
- groups={connectionsChooser.groups}
1122
- position={flowToScreenPosition(connectionsChooser.center)}
1123
- onChoose={chooseConnections}
1124
- onCancel={cancelConnectionsChooser}
1125
- />
1126
- )}
1127
- {shareOpen && (
1128
- <ShareDialog
1129
- keptInMemory={keptInMemory}
1130
- url={agentLink.canvasUrl}
1131
- title={flow.title}
1132
- presence={flow.presence}
1133
- clientId={flow.clientId}
1134
- onRename={onRename}
1135
- onJumpTo={jumpTo}
1136
- onConnectAgent={openConnect}
1137
- onClose={closeShare}
1138
- />
1139
- )}
1140
- {connectOpen && (
1141
- <ConnectAgentDialog
1142
- link={agentLink}
1143
- webMcp={webMcp}
1144
- relayScriptPath={relayScriptPath}
1145
- onEnableWebMcp={turnOnWebMcp}
1146
- onClose={closeConnect}
1147
- />
1148
- )}
1149
- {inspected && (
1150
- <PropertiesPanel
1151
- key={inspected.id}
1152
- node={inspected}
1153
- onChange={flow.updateNodeData}
1154
- onDelete={flow.deleteNodes}
1155
- onClose={closePanel}
1156
- />
1157
- )}
1158
- </div>
2085
+ </LockedContext.Provider>
2086
+ </StartWritingContext.Provider>
2087
+ </EditingContext.Provider>
1159
2088
  </DropTargetContext.Provider>
1160
2089
  </UpdateNodeDataContext.Provider>
1161
2090
  );