@eventcatalog/core 5.0.0-beta.9 → 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.
- package/LICENSE +13 -12
- package/README.md +5 -5
- package/dist/analytics/analytics.cjs +1 -1
- package/dist/analytics/analytics.js +2 -2
- package/dist/analytics/log-build.cjs +91 -44
- package/dist/analytics/log-build.d.cts +2 -2
- package/dist/analytics/log-build.d.ts +2 -2
- package/dist/analytics/log-build.js +6 -5
- package/dist/analytics/studio-usage.cjs +90 -0
- package/dist/analytics/studio-usage.d.cts +5 -0
- package/dist/analytics/studio-usage.d.ts +5 -0
- package/dist/analytics/studio-usage.js +10 -0
- package/dist/catalog-runtime.cjs +1 -1
- package/dist/{chunk-RFIXKATC.js → chunk-4G7FSRPN.js} +35 -42
- package/dist/{chunk-AWOO63ME.js → chunk-AD7CL772.js} +11 -4
- package/dist/chunk-CA6WCQ2T.js +54 -0
- package/dist/{chunk-GAHKCOQM.js → chunk-JZN4RSMF.js} +1 -1
- package/dist/{chunk-JQMYHZWI.js → chunk-NEAVAFSJ.js} +16 -8
- package/dist/{chunk-4CFZUHGG.js → chunk-NIVST4WY.js} +1 -1
- package/dist/{chunk-ENAG6UH7.js → chunk-NKNOXQ62.js} +1 -6
- package/dist/{chunk-ZEY2XUJN.js → chunk-OX6TSVHY.js} +6 -6
- package/dist/{chunk-36RYTYT5.js → chunk-PD7KTSYN.js} +1 -1
- package/dist/{chunk-EUNQJO3J.js → chunk-RE7UTPWH.js} +1 -1
- package/dist/chunk-S5GWAUG5.js +29 -0
- package/dist/{chunk-TZVDMWQX.js → chunk-YQGRMDRB.js} +4 -2
- package/dist/constants.cjs +1 -1
- package/dist/constants.js +1 -1
- package/dist/docs/api/02-config.md +57 -7
- package/dist/docs/development/00-why-eventcatalog.md +11 -11
- package/dist/docs/development/01-fundamentals.md +20 -13
- package/dist/docs/development/01-getting-started/configuration-overview.md +2 -2
- package/dist/docs/development/01-getting-started/installation.md +9 -1
- package/dist/docs/development/_getting-started.mdx +1 -1
- package/dist/docs/development/ask-your-architecture/01-intro.md +5 -5
- package/dist/docs/development/ask-your-architecture/02-eventcatalog-assistant/01-what-is-eventcatalog-assistant.md +0 -4
- package/dist/docs/development/ask-your-architecture/02-eventcatalog-assistant/02-configuration.md +15 -3
- package/dist/docs/development/ask-your-architecture/02-eventcatalog-assistant/03-bring-your-own-tools.md +0 -3
- package/dist/docs/development/ask-your-architecture/02-eventcatalog-assistant/_category_.json +1 -1
- package/dist/docs/development/ask-your-architecture/02-llms.txt.md +1 -1
- package/dist/docs/development/ask-your-architecture/{05-skills/01-introduction.md → 03-install-skills.md} +57 -7
- package/dist/docs/development/ask-your-architecture/03-mcp-server/_category_.json +1 -1
- package/dist/docs/development/ask-your-architecture/03-mcp-server/getting-started.md +86 -33
- package/dist/docs/development/ask-your-architecture/03-mcp-server/introduction.md +14 -4
- package/dist/docs/development/ask-your-architecture/03-schemas.txt.md +1 -1
- package/dist/docs/development/ask-your-architecture/04-agents/_category_.json +1 -1
- package/dist/docs/development/authentication/01-introduction.md +2 -23
- package/dist/docs/development/authentication/02-enabling-authentication.md +0 -20
- package/dist/docs/development/authentication/07-rbac-middleware.md +0 -2
- package/dist/docs/development/authentication/providers/03-setting-up-github.md +0 -2
- package/dist/docs/development/authentication/providers/03a-setting-up-google.md +0 -2
- package/dist/docs/development/authentication/providers/04-setting-up-azure-ad.md +0 -2
- package/dist/docs/development/authentication/providers/05-setting-up-okta.md +0 -3
- package/dist/docs/development/authentication/providers/06-setting-up-auth0.md +0 -2
- package/dist/docs/development/bring-your-own-documentation/01-introduction.md +0 -4
- package/dist/docs/development/bring-your-own-documentation/custom-pages/01-introduction.md +0 -3
- package/dist/docs/development/bring-your-own-documentation/custom-pages/02-adding-custom-docs.md +0 -17
- package/dist/docs/development/bring-your-own-documentation/custom-pages/03-components.md +0 -4
- package/dist/docs/development/bring-your-own-documentation/custom-pages/04-owners.md +0 -2
- package/dist/docs/development/bring-your-own-documentation/resource-docs/01-introduction.md +1 -3
- package/dist/docs/development/bring-your-own-documentation/resource-docs/02-adding-resource-docs.md +1 -4
- package/dist/docs/development/bring-your-own-documentation/resource-docs/03-categories.md +0 -2
- package/dist/docs/development/bring-your-own-documentation/resource-docs/04-versioning.md +1 -3
- package/dist/docs/development/components/components/11-flow.md +17 -0
- package/dist/docs/development/components/components/15-nodegraph.md +43 -10
- package/dist/docs/development/components/components/18-remote-schema.md +1 -4
- package/dist/docs/development/components/custom-components/07-reference.md +14 -0
- package/dist/docs/development/customization/02-themes.md +0 -5
- package/dist/docs/development/customization/03-search.md +1 -1
- package/dist/docs/development/customization/05-custom-pages-and-api-routes/01-introduction.md +0 -2
- package/dist/docs/development/customization/05-custom-pages-and-api-routes/04-reference.md +7 -1
- package/dist/docs/development/customization/05-resource-sidebar.md +1 -1
- package/dist/docs/development/customization/09-embed-eventcatalog.md +20 -25
- package/dist/docs/development/deployment/hosting-options.md +7 -2
- package/dist/docs/development/developer-tools/eventcatalog-linter/how-to/05-fix-common-problems.md +16 -0
- package/dist/docs/development/developer-tools/eventcatalog-linter/reference/03-rules.md +31 -1
- package/dist/docs/development/developer-tools/eventcatalog-linter/reference/05-supported-resources.md +1 -1
- package/dist/docs/development/governance/architecture-change-detection/01-introduction.md +0 -10
- package/dist/docs/development/governance/architecture-change-detection/06-pipeline-gates.md +0 -4
- package/dist/docs/development/guides/97-architecture-graph.md +1 -1
- package/dist/docs/development/guides/99-adding-analytics.md +0 -7
- package/dist/docs/development/guides/domains/03-model-subdomains.md +2 -2
- package/dist/docs/development/guides/domains/04-add-resources-to-domains/01-add-systems-to-domains.md +3 -1
- package/dist/docs/development/guides/domains/04-add-resources-to-domains/02-add-services-to-domains.md +5 -1
- package/dist/docs/development/guides/domains/04-add-resources-to-domains/03-add-messages-to-domains.md +2 -2
- package/dist/docs/development/guides/domains/04-add-resources-to-domains/05-add-entities-to-domains.md +1 -1
- package/dist/docs/development/guides/domains/04-add-resources-to-domains/06-domain-resources-page.md +40 -0
- package/dist/docs/development/guides/domains/06-visualize-domains/01-domain-diagram.md +89 -0
- package/dist/docs/development/guides/domains/06-visualize-domains/_category_.json +1 -2
- package/dist/docs/development/guides/domains/09-reference.md +4 -4
- package/dist/docs/development/guides/owners/automated-teams-and-users/create-your-own-connector.md +0 -2
- package/dist/docs/development/guides/owners/automated-teams-and-users/sync-from-github.md +0 -3
- package/dist/docs/development/guides/owners/automated-teams-and-users/sync-from-microsoft-entra.md +0 -2
- package/dist/docs/development/guides/resources/adrs/03-linking-resources.md +8 -0
- package/dist/docs/development/guides/resources/adrs/06-reference.md +4 -4
- package/dist/docs/development/guides/resources/agents/11-reference.md +1 -1
- package/dist/docs/development/guides/resources/data/04-reference.md +1 -1
- package/dist/docs/development/guides/resources/data-products/07-reference.md +1 -1
- package/dist/docs/development/guides/resources/entities/05-entity-maps.md +1 -1
- package/dist/docs/development/guides/resources/entities/06-reference.md +2 -2
- package/dist/docs/development/guides/resources/flows/01-introduction.md +11 -1
- package/dist/docs/development/guides/resources/flows/03-model-flow-steps.md +65 -6
- package/dist/docs/development/guides/resources/flows/05-explore-flows.md +112 -0
- package/dist/docs/development/guides/resources/flows/06-reference.md +25 -4
- package/dist/docs/development/guides/resources/messages/01-what-are-messages.md +19 -0
- package/dist/docs/development/guides/resources/messages/04-message-channels/01-introduction.md +16 -0
- package/dist/docs/development/guides/resources/messages/04-message-channels/05-reference.md +2 -1
- package/dist/docs/development/guides/resources/messages/10-reference.md +6 -3
- package/dist/docs/development/guides/resources/schemas/04-explore-schemas/01-schema-explorer.md +2 -2
- package/dist/docs/development/guides/resources/schemas/04-explore-schemas/02-fields-explorer.md +0 -9
- package/dist/docs/development/guides/resources/schemas/05-schema-api.md +0 -3
- package/dist/docs/development/guides/resources/schemas/06-schema-mcp.md +11 -3
- package/dist/docs/development/guides/resources/services/04-add-resources-to-services/03-add-entities-to-services.md +1 -1
- package/dist/docs/development/guides/resources/services/05-resources-page.md +50 -0
- package/dist/docs/development/guides/resources/services/08-reference.md +2 -1
- package/dist/docs/development/guides/systems/01-introduction.md +1 -1
- package/dist/docs/development/guides/systems/02-create-system.md +2 -2
- package/dist/docs/development/guides/systems/03-add-resources-to-systems.md +35 -9
- package/dist/docs/development/guides/systems/04-model-relationships-and-actors.md +5 -3
- package/dist/docs/development/guides/systems/05-system-context-maps.md +39 -22
- package/dist/docs/development/guides/systems/06-reference.md +4 -4
- package/dist/docs/development/license/01-license-file.md +151 -0
- package/dist/docs/development/license/_category_.json +12 -0
- package/dist/docs/development/license-keys/_category_.json +3 -3
- package/dist/docs/development/license-keys/integrations.md +7 -37
- package/dist/docs/development/license-keys/license-validation.md +4 -0
- package/dist/docs/development/license-keys/overview.md +5 -1
- package/dist/docs/development/license-keys/plans.md +4 -2
- package/dist/docs/development/studio/01-introduction.md +60 -0
- package/dist/docs/development/studio/02-create-a-canvas.md +103 -0
- package/dist/docs/development/studio/03-set-up-persistent-storage.md +59 -0
- package/dist/docs/development/studio/04-collaborate-on-a-canvas.md +78 -0
- package/dist/docs/development/studio/05-connect-your-agent.md +102 -0
- package/dist/docs/development/studio/06-run-studio-in-production.md +54 -0
- package/dist/docs/development/studio/07-reference.md +294 -0
- package/dist/docs/development/studio/_category_.json +12 -0
- package/dist/docs/development/upgrading/v5.md +345 -0
- package/dist/docs/federation/00-overview.md +2 -8
- package/dist/docs/federation/01-first-federation.md +6 -18
- package/dist/docs/federation/explanation/how-it-works.md +0 -4
- package/dist/docs/federation/explanation/lockfile-and-cache.md +0 -4
- package/dist/docs/federation/explanation/mvp-status-and-feedback.md +0 -4
- package/dist/docs/federation/explanation/ownership-and-references.md +0 -4
- package/dist/docs/federation/how-to/configure-github-sources.md +6 -5
- package/dist/docs/federation/how-to/configure-validation-rules.md +0 -4
- package/dist/docs/federation/how-to/run-in-ci.md +2 -9
- package/dist/docs/federation/how-to/share-assets-and-components.md +0 -4
- package/dist/docs/federation/how-to/use-local-sources.md +0 -4
- package/dist/docs/federation/legacy-federation/00-introduction.md +4 -22
- package/dist/docs/federation/legacy-federation/01-installation.md +7 -58
- package/dist/docs/federation/legacy-federation/02-plugin-configuration.md +3 -21
- package/dist/docs/federation/legacy-federation/03-examples.md +3 -5
- package/dist/docs/federation/legacy-federation/04-configuration.md +3 -8
- package/dist/docs/federation/legacy-federation/05-setup-team-catalog.md +3 -6
- package/dist/docs/federation/reference/cli.md +2 -8
- package/dist/docs/federation/reference/configuration.md +0 -4
- package/dist/docs/federation/reference/diagnostic-rules.md +0 -5
- package/dist/docs/federation/reference/generated-output.md +0 -4
- package/dist/docs/federation/reference/supported-content.md +0 -4
- package/dist/docs/federation/reference/troubleshooting.md +0 -14
- package/dist/docs/plugins/02-generators.md +1 -1
- package/dist/docs/plugins/amazon-apigateway/00-intro.md +3 -28
- package/dist/docs/plugins/amazon-apigateway/01-installation.md +4 -56
- package/dist/docs/plugins/amazon-apigateway/02-plugin-configuration.md +1 -6
- package/dist/docs/plugins/apicurio/00-intro.md +2 -31
- package/dist/docs/plugins/apicurio/01-installation.md +2 -26
- package/dist/docs/plugins/apicurio/02-plugin-configuration.md +5 -13
- package/dist/docs/plugins/apicurio/03-features.md +1 -3
- package/dist/docs/plugins/asyncapi/00-intro.md +2 -28
- package/dist/docs/plugins/asyncapi/01-installation.md +3 -54
- package/dist/docs/plugins/asyncapi/02-plugin-configuration.md +2 -1
- package/dist/docs/plugins/aws-glue-registry/00-intro.md +4 -32
- package/dist/docs/plugins/aws-glue-registry/00a-installation.md +3 -53
- package/dist/docs/plugins/azure-schema-registry/00-intro.md +4 -32
- package/dist/docs/plugins/azure-schema-registry/01-installation.md +6 -33
- package/dist/docs/plugins/azure-schema-registry/02-plugin-configuration.md +1 -14
- package/dist/docs/plugins/azure-schema-registry/04-examples.md +0 -2
- package/dist/docs/plugins/backstage/00-overview.md +1 -7
- package/dist/docs/plugins/backstage/01-get-started.md +6 -15
- package/dist/docs/plugins/backstage/06-configuration.md +2 -8
- package/dist/docs/plugins/confluent-schema-registry/00-intro.md +2 -29
- package/dist/docs/plugins/confluent-schema-registry/01-installation.md +2 -26
- package/dist/docs/plugins/eventbridge/00-intro.md +0 -4
- package/dist/docs/plugins/eventbridge/00a-installation.md +1 -87
- package/dist/docs/plugins/eventbridge/03-api.md +2 -20
- package/dist/docs/plugins/github/00-intro.md +0 -31
- package/dist/docs/plugins/github/01-installation.md +2 -34
- package/dist/docs/plugins/graphql/00-intro.md +0 -30
- package/dist/docs/plugins/graphql/01-installation.md +1 -53
- package/dist/docs/plugins/graphql/02-plugin-configuration.md +0 -1
- package/dist/docs/plugins/hookdeck/01-intro.md +1 -1
- package/dist/docs/plugins/openapi/00-intro.md +0 -30
- package/dist/docs/plugins/openapi/01-installation.md +1 -57
- package/dist/docs/plugins/openapi/02-plugin-configuration.md +0 -5
- package/dist/docs/plugins/openapi/03-features.md +0 -4
- package/dist/eventcatalog-config-file-utils.cjs +34 -41
- package/dist/eventcatalog-config-file-utils.d.cts +56 -58
- package/dist/eventcatalog-config-file-utils.d.ts +56 -58
- package/dist/eventcatalog-config-file-utils.js +1 -1
- package/dist/eventcatalog.cjs +340 -249
- package/dist/eventcatalog.config.d.cts +18 -15
- package/dist/eventcatalog.config.d.ts +18 -15
- package/dist/eventcatalog.js +51 -33
- package/dist/features.cjs +36 -49
- package/dist/features.d.cts +1 -3
- package/dist/features.d.ts +1 -3
- package/dist/features.js +4 -6
- package/dist/federation/federate.cjs +37 -44
- package/dist/federation/federate.js +5 -5
- package/dist/federation/source-provider.js +2 -2
- package/dist/generate.cjs +37 -42
- package/dist/generate.js +4 -4
- package/dist/resolve-catalog-dependencies.cjs +34 -41
- package/dist/resolve-catalog-dependencies.js +2 -2
- package/dist/runtime-dependencies/@eventcatalog__visualiser__studio-fonts.css.mjs +3 -0
- package/dist/runtime-dependencies/@eventcatalog__visualiser__studio.css.mjs +3 -0
- package/dist/runtime-dependencies/@eventcatalog__visualiser__studio.mjs +3 -0
- package/dist/runtime-dependencies/htmlparser2.mjs +3 -0
- package/dist/runtime-dependencies/manifest.json +5 -1
- package/dist/runtime-dependencies/react-syntax-highlighter__dist__cjs__create-element.js.mjs +3 -0
- package/dist/utils/child-process-signals.cjs +53 -0
- package/dist/utils/child-process-signals.d.cts +19 -0
- package/dist/utils/child-process-signals.d.ts +19 -0
- package/dist/utils/child-process-signals.js +6 -0
- package/dist/utils/cli-logger.cjs +1 -1
- package/dist/utils/cli-logger.js +2 -2
- package/dist/utils/license-status.cjs +11 -4
- package/dist/utils/license-status.d.cts +3 -2
- package/dist/utils/license-status.d.ts +3 -2
- package/dist/utils/license-status.js +1 -1
- package/eventcatalog/astro.config.mjs +21 -1
- package/eventcatalog/integrations/config-loader.mjs +56 -58
- package/eventcatalog/integrations/skip-startup-reload.mjs +31 -0
- package/eventcatalog/integrations/stable-dependency-order.mjs +29 -0
- package/eventcatalog/integrations/storage.mjs +20 -0
- package/eventcatalog/integrations/studio-server.mjs +4 -1
- package/eventcatalog/src/components/CatalogGraph/CatalogForceGraph.tsx +39 -0
- package/eventcatalog/src/components/ChatPanel/ChatPanel.tsx +2 -0
- package/eventcatalog/src/components/Homepage/CatalogDashboard.astro +342 -0
- package/eventcatalog/src/components/MDX/NodeGraph/AstroNodeGraph.tsx +60 -4
- package/eventcatalog/src/components/MDX/NodeGraph/NodeGraph.astro +5 -1
- package/eventcatalog/src/components/NavigationProgress.astro +7 -0
- package/eventcatalog/src/components/SchemaExplorer/ApiContentViewer.tsx +2 -0
- package/eventcatalog/src/components/SchemaExplorer/ExamplesViewer.tsx +2 -0
- package/eventcatalog/src/components/SchemaExplorer/SchemaCodeModal.tsx +2 -0
- package/eventcatalog/src/components/SchemaExplorer/SchemaContentViewer.tsx +2 -0
- package/eventcatalog/src/components/Search/SearchDataLoader.astro +16 -28
- package/eventcatalog/src/components/Search/SearchModal.tsx +2 -10
- package/eventcatalog/src/components/Search/search-utils.ts +26 -0
- package/eventcatalog/src/components/Seo.astro +5 -1
- package/eventcatalog/src/components/Settings/AssistantSettingsForm.tsx +53 -48
- package/eventcatalog/src/components/Settings/GeneralSettingsForm.tsx +183 -178
- package/eventcatalog/src/components/Settings/LicenseSettings.tsx +140 -31
- package/eventcatalog/src/components/Settings/LlmAccessSettingsForm.tsx +40 -35
- package/eventcatalog/src/components/Settings/LogoUpload.tsx +1 -1
- package/eventcatalog/src/components/Settings/McpSettingsForm.tsx +9 -8
- package/eventcatalog/src/components/Settings/Row.tsx +7 -4
- package/eventcatalog/src/components/Settings/SettingsShared.tsx +3 -3
- package/eventcatalog/src/components/SideNav/NestedSideBar/index.tsx +37 -76
- package/eventcatalog/src/components/SideNav/NestedSideBar/storage.ts +2 -0
- package/eventcatalog/src/components/SideNav/NestedSideBar/utils.ts +122 -1
- package/eventcatalog/src/components/SideNav/SideNav.astro +9 -1
- package/eventcatalog/src/components/TrialBadge.astro +20 -9
- package/eventcatalog/src/content.config.ts +19 -2
- package/eventcatalog/src/features/auth/error.astro +4 -2
- package/eventcatalog/src/features/auth/login.astro +2 -1
- package/eventcatalog/src/features/auth/middleware/middleware-auth.ts +21 -1
- package/eventcatalog/src/features/auth/unauthorized.astro +1 -1
- package/eventcatalog/src/features/fields/pages/fields.astro +1 -1
- package/eventcatalog/src/features/integrations/eventcatalog-features.ts +13 -7
- package/eventcatalog/src/features/mcp/apps/architecture-diagram/node-chat.tsx +6 -45
- package/eventcatalog/src/features/mcp/apps/canvas/shared.ts +3 -3
- package/eventcatalog/src/features/mcp/apps/canvas/styles.css +3 -1
- package/eventcatalog/src/features/mcp/apps/canvas/view.tsx +7 -5
- package/eventcatalog/src/features/mcp/apps/generated/canvas.html +123 -98
- package/eventcatalog/src/features/mcp/apps/generated/viewer.html +118 -101
- package/eventcatalog/src/features/mcp/mcp-server.ts +20 -4
- package/eventcatalog/src/features/server/start.ts +25 -0
- package/eventcatalog/src/features/storage/database.ts +88 -0
- package/eventcatalog/src/features/storage/migrations.ts +85 -0
- package/eventcatalog/src/features/storage/sql/database.sql +5 -0
- package/eventcatalog/src/features/storage/sql/migrations/0001-studio-canvases.sql +12 -0
- package/eventcatalog/src/features/storage/sql/migrations.sql +15 -0
- package/eventcatalog/src/features/storage/sql.ts +28 -0
- package/eventcatalog/src/features/studio/agent-choreography.ts +13 -10
- package/eventcatalog/src/features/studio/agents.ts +28 -0
- package/eventcatalog/src/features/studio/api/canvases-api.ts +219 -0
- package/eventcatalog/src/features/studio/api/canvases.ts +25 -0
- package/eventcatalog/src/features/studio/api/client.ts +40 -0
- package/eventcatalog/src/features/studio/canvas-actions.ts +160 -34
- package/eventcatalog/src/features/studio/canvas-preview.ts +1 -2
- package/eventcatalog/src/features/studio/catalog.ts +16 -14
- package/eventcatalog/src/features/studio/components/CanvasHeader.tsx +274 -105
- package/eventcatalog/src/features/studio/components/CanvasStatusMenu.tsx +15 -14
- package/eventcatalog/src/features/studio/components/ConnectAgentDialog.tsx +272 -119
- package/eventcatalog/src/features/studio/components/ConnectionsChooser.tsx +1 -2
- package/eventcatalog/src/features/studio/components/DeleteCanvasDialog.tsx +65 -0
- package/eventcatalog/src/features/studio/components/Dialog.tsx +60 -0
- package/eventcatalog/src/features/studio/components/JoinForm.tsx +101 -26
- package/eventcatalog/src/features/studio/components/LeftPanel.tsx +122 -230
- package/eventcatalog/src/features/studio/components/NodeDetails.tsx +183 -0
- package/eventcatalog/src/features/studio/components/OpenInStudioDialog.tsx +79 -0
- package/eventcatalog/src/features/studio/components/Presence.tsx +52 -45
- package/eventcatalog/src/features/studio/components/ShareDialog.tsx +277 -141
- package/eventcatalog/src/features/studio/components/ShortcutsDialog.tsx +41 -0
- package/eventcatalog/src/features/studio/components/StudioDesigner.tsx +1314 -385
- package/eventcatalog/src/features/studio/components/StudioNotice.astro +24 -0
- package/eventcatalog/src/features/studio/document-name.ts +7 -0
- package/eventcatalog/src/features/studio/from-visualiser.ts +138 -0
- package/eventcatalog/src/features/studio/hooks/presence-sender.ts +19 -2
- package/eventcatalog/src/features/studio/hooks/use-canvas-webmcp.ts +9 -3
- package/eventcatalog/src/features/studio/hooks/use-comments.ts +2 -2
- package/eventcatalog/src/features/studio/hooks/use-follow-camera.ts +8 -3
- package/eventcatalog/src/features/studio/hooks/use-studio-flow.ts +244 -32
- package/eventcatalog/src/features/studio/identity.ts +25 -1
- package/eventcatalog/src/features/studio/limits.ts +2 -0
- package/eventcatalog/src/features/studio/pages/[id].astro +42 -16
- package/eventcatalog/src/features/studio/pages/index.astro +596 -373
- package/eventcatalog/src/features/studio/pages/webmcp-relay.ts +12 -15
- package/eventcatalog/src/features/studio/server/canvas-mcp.ts +8 -23
- package/eventcatalog/src/features/studio/server/runtime.ts +150 -46
- package/eventcatalog/src/features/studio/server/sql/storage.sql +18 -0
- package/eventcatalog/src/features/studio/server/storage.ts +53 -73
- package/eventcatalog/src/features/studio/webmcp-relay/LICENSE +21 -0
- package/eventcatalog/src/features/studio/webmcp-relay/README.md +5 -0
- package/eventcatalog/src/features/studio/webmcp-relay/embed.js +2 -0
- package/eventcatalog/src/features/studio/webmcp-relay/widget.html +10 -0
- package/eventcatalog/src/features/studio/webmcp-relay/widget.js +1 -0
- package/eventcatalog/src/features/tools/catalog-tools.ts +3 -1
- package/eventcatalog/src/layouts/BaseLayout.astro +4 -4
- package/eventcatalog/src/layouts/SettingsLayout.astro +2 -2
- package/eventcatalog/src/layouts/VerticalSideBarLayout.astro +3 -2
- package/eventcatalog/src/pages/_index.astro +5 -342
- package/eventcatalog/src/pages/architecture/[type]/[id]/[version]/_index.data.spec.ts +2 -3
- package/eventcatalog/src/pages/architecture/[type]/[id]/[version]/_index.data.ts +0 -7
- package/eventcatalog/src/pages/architecture/[type]/[id]/[version]/index.astro +3 -1
- package/eventcatalog/src/pages/diagrams/[id]/[version]/_index.data.ts +0 -7
- package/eventcatalog/src/pages/diagrams/[id]/[version]/embed.astro +1 -0
- package/eventcatalog/src/pages/diagrams/[id]/[version]/index.astro +1 -0
- package/eventcatalog/src/pages/directory/[type]/_index.data.ts +5 -7
- package/eventcatalog/src/pages/directory/[type]/index.astro +3 -1
- package/eventcatalog/src/pages/discover/[type]/_index.data.ts +5 -7
- package/eventcatalog/src/pages/discover/[type]/index.astro +3 -1
- package/eventcatalog/src/pages/docs/[type]/[id]/[version]/[docType]/[docId]/[docVersion]/_index.data.ts +0 -7
- package/eventcatalog/src/pages/docs/[type]/[id]/[version]/[docType]/[docId]/[docVersion]/index.astro +1 -0
- package/eventcatalog/src/pages/docs/[type]/[id]/[version]/[docType]/[docId]/_index.data.ts +0 -7
- package/eventcatalog/src/pages/docs/[type]/[id]/[version]/[docType]/[docId]/index.astro +1 -0
- package/eventcatalog/src/pages/docs/[type]/[id]/[version]/_index.data.ts +0 -7
- package/eventcatalog/src/pages/docs/[type]/[id]/[version]/asyncapi/[filename].astro +3 -1
- package/eventcatalog/src/pages/docs/[type]/[id]/[version]/asyncapi/_[filename].data.ts +0 -7
- package/eventcatalog/src/pages/docs/[type]/[id]/[version]/changelog/_index.data.ts +0 -7
- package/eventcatalog/src/pages/docs/[type]/[id]/[version]/changelog/index.astro +1 -0
- package/eventcatalog/src/pages/docs/[type]/[id]/[version]/examples/[...filename].astro +1 -0
- package/eventcatalog/src/pages/docs/[type]/[id]/[version]/examples/_[...filename].data.ts +0 -4
- package/eventcatalog/src/pages/docs/[type]/[id]/[version]/field-lineage/_index.data.ts +0 -7
- package/eventcatalog/src/pages/docs/[type]/[id]/[version]/field-lineage/index.astro +1 -0
- package/eventcatalog/src/pages/docs/[type]/[id]/[version]/graphql/[filename].astro +3 -1
- package/eventcatalog/src/pages/docs/[type]/[id]/[version]/graphql/_[filename].data.ts +0 -7
- package/eventcatalog/src/pages/docs/[type]/[id]/[version]/index.astro +1 -0
- package/eventcatalog/src/pages/docs/[type]/[id]/[version]/resources/_index.data.spec.ts +2 -3
- package/eventcatalog/src/pages/docs/[type]/[id]/[version]/resources/_index.data.ts +0 -7
- package/eventcatalog/src/pages/docs/[type]/[id]/[version]/resources/index.astro +1 -0
- package/eventcatalog/src/pages/docs/[type]/[id]/[version]/spec/[filename].astro +1 -0
- package/eventcatalog/src/pages/docs/[type]/[id]/[version]/spec/_[filename].data.ts +0 -7
- package/eventcatalog/src/pages/docs/[type]/[id]/_index.data.ts +0 -7
- package/eventcatalog/src/pages/docs/[type]/[id]/index.astro +2 -1
- package/eventcatalog/src/pages/docs/[type]/[id]/language/[dictionaryId]/_index.data.ts +0 -7
- package/eventcatalog/src/pages/docs/[type]/[id]/language/[dictionaryId]/index.astro +1 -0
- package/eventcatalog/src/pages/docs/[type]/[id]/language/_index.data.ts +0 -7
- package/eventcatalog/src/pages/docs/[type]/[id]/language/index.astro +1 -0
- package/eventcatalog/src/pages/docs/custom/[...path]/_index.data.ts +0 -7
- package/eventcatalog/src/pages/docs/custom/[...path]/index.astro +1 -0
- package/eventcatalog/src/pages/docs/services/[id]/[docType]/[docId]/index.astro +1 -1
- package/eventcatalog/src/pages/docs/services/[id]/[version]/resources/index.astro +1 -0
- package/eventcatalog/src/pages/docs/services/[id]/asyncapi/[filename].astro +1 -1
- package/eventcatalog/src/pages/docs/services/[id]/changelog/index.astro +1 -1
- package/eventcatalog/src/pages/docs/services/[id]/graphql/[filename].astro +1 -1
- package/eventcatalog/src/pages/docs/services/[id]/spec/[filename].astro +1 -1
- package/eventcatalog/src/pages/docs/teams/[id]/_index.data.ts +0 -7
- package/eventcatalog/src/pages/docs/teams/[id]/index.astro +1 -0
- package/eventcatalog/src/pages/docs/users/[id]/_index.data.ts +0 -7
- package/eventcatalog/src/pages/docs/users/[id]/index.astro +1 -0
- package/eventcatalog/src/pages/index.astro +4 -2
- package/eventcatalog/src/pages/schemas/[type]/[id]/[version]/_index.data.ts +0 -7
- package/eventcatalog/src/pages/schemas/[type]/[id]/[version]/index.astro +9 -1
- package/eventcatalog/src/pages/schemas/explorer/_index.data.ts +0 -7
- package/eventcatalog/src/pages/schemas/explorer/index.astro +4 -2
- package/eventcatalog/src/pages/settings/license.astro +9 -1
- package/eventcatalog/src/pages/triggers/[type]/[id]/[version]/_index.data.ts +0 -7
- package/eventcatalog/src/pages/triggers/[type]/[id]/[version]/index.astro +1 -0
- package/eventcatalog/src/pages/visualiser/[type]/[id]/[version]/_index.data.ts +0 -7
- package/eventcatalog/src/pages/visualiser/[type]/[id]/[version]/data/_index.data.ts +0 -7
- package/eventcatalog/src/pages/visualiser/[type]/[id]/[version]/data/index.astro +1 -0
- package/eventcatalog/src/pages/visualiser/[type]/[id]/[version]/entity-map/_index.data.ts +0 -7
- package/eventcatalog/src/pages/visualiser/[type]/[id]/[version]/entity-map/index.astro +1 -0
- package/eventcatalog/src/pages/visualiser/[type]/[id]/[version]/index.astro +1 -0
- package/eventcatalog/src/pages/visualiser/[type]/[id]/_index.data.ts +38 -0
- package/eventcatalog/src/pages/visualiser/[type]/[id]/index.astro +18 -41
- package/eventcatalog/src/pages/visualiser/designs/[id]/_index.data.ts +0 -7
- package/eventcatalog/src/pages/visualiser/designs/[id]/index.astro +2 -1
- package/eventcatalog/src/pages/visualiser/domains/[id]/[version]/entity-map/_index.data.ts +0 -7
- package/eventcatalog/src/pages/visualiser/domains/[id]/[version]/entity-map/index.astro +1 -0
- package/eventcatalog/src/pages/visualiser/domains/[id]/[version]/index.astro +1 -0
- package/eventcatalog/src/pages/visualiser/domains/[id]/[version]/systems-context/_index.data.ts +0 -7
- package/eventcatalog/src/pages/visualiser/domains/[id]/[version]/systems-context/index.astro +1 -0
- package/eventcatalog/src/pages/visualiser/systems/[id]/[version]/context/_index.data.ts +0 -7
- package/eventcatalog/src/pages/visualiser/systems/[id]/[version]/context/index.astro +1 -0
- package/eventcatalog/src/pages/visualiser/systems/[id]/[version]/index.astro +1 -0
- package/eventcatalog/src/types/react-syntax-highlighter.d.ts +10 -0
- package/eventcatalog/src/utils/collections/flows.ts +20 -7
- package/eventcatalog/src/utils/collections/resource-owners.ts +8 -2
- package/eventcatalog/src/utils/collections/resources.ts +2 -0
- package/eventcatalog/src/utils/collections/util.ts +8 -4
- package/eventcatalog/src/utils/git.ts +11 -0
- package/eventcatalog/src/utils/homepage-dashboard.ts +143 -0
- package/eventcatalog/src/utils/license.ts +1 -1
- package/eventcatalog/src/utils/link-validation.ts +25 -19
- package/eventcatalog/src/utils/node-graphs/domain-entity-map.ts +3 -1
- package/eventcatalog/src/utils/node-graphs/domain-levels-node-graph.ts +3 -3
- package/eventcatalog/src/utils/node-graphs/field-node-graph.ts +4 -2
- package/eventcatalog/src/utils/node-graphs/layout-node-graph.ts +20 -1
- package/eventcatalog/src/utils/node-graphs/node-resource.ts +67 -0
- package/eventcatalog/src/utils/node-graphs/systems-node-graph.ts +1 -1
- package/eventcatalog/src/utils/page-loaders/hybrid-page.ts +4 -15
- package/eventcatalog/src/utils/page-title.ts +15 -0
- package/eventcatalog/src/utils/syntax-highlighter-renderer.ts +64 -0
- package/eventcatalog/src/utils/trial.ts +13 -0
- package/package.json +11 -11
- package/dist/docs/development/ask-your-architecture/05-skills/02-installation.md +0 -60
- package/dist/docs/development/ask-your-architecture/05-skills/_category_.json +0 -11
- package/dist/docs/development/ask-your-architecture/06-slack-integration/01-introduction.md +0 -63
- package/dist/docs/development/ask-your-architecture/06-slack-integration/02-slack-app-setup.md +0 -154
- package/dist/docs/development/ask-your-architecture/06-slack-integration/03-installation.md +0 -169
- package/dist/docs/development/ask-your-architecture/06-slack-integration/04-deployment.md +0 -236
- package/dist/docs/development/ask-your-architecture/06-slack-integration/05-usage.md +0 -140
- package/dist/docs/development/ask-your-architecture/06-slack-integration/06-troubleshooting.md +0 -268
- package/dist/docs/development/ask-your-architecture/06-slack-integration/_category_.json +0 -13
- package/dist/docs/development/guides/domains/06-visualize-domains/01-domain-integration-map.md +0 -40
- package/dist/runtime-dependencies/@mcp-b__webmcp-local-relay.mjs +0 -3
- package/eventcatalog/src/features/studio/canvas-doc.ts +0 -409
- package/eventcatalog/src/features/studio/clipboard.ts +0 -97
- package/eventcatalog/src/features/studio/components/CanvasControls.tsx +0 -154
- package/eventcatalog/src/features/studio/components/Comments.tsx +0 -770
- package/eventcatalog/src/features/studio/components/ContainerChooser.tsx +0 -145
- package/eventcatalog/src/features/studio/components/DragGhost.tsx +0 -115
- package/eventcatalog/src/features/studio/components/Picture.tsx +0 -21
- package/eventcatalog/src/features/studio/components/PropertiesPanel.tsx +0 -212
- package/eventcatalog/src/features/studio/components/canvas-nodes.tsx +0 -255
- package/eventcatalog/src/features/studio/components/icons.ts +0 -53
- package/eventcatalog/src/features/studio/components/status.ts +0 -51
- package/eventcatalog/src/features/studio/edges.ts +0 -56
- package/eventcatalog/src/features/studio/flow-state.ts +0 -92
- package/eventcatalog/src/features/studio/grouping.ts +0 -226
- package/eventcatalog/src/features/studio/layout.ts +0 -42
- package/eventcatalog/src/features/studio/levels.ts +0 -282
- package/eventcatalog/src/features/studio/node-types.ts +0 -205
- package/eventcatalog/src/features/studio/pages/new.astro +0 -24
- package/eventcatalog/src/features/studio/pages/wake.ts +0 -15
- package/eventcatalog/src/features/studio/placement.ts +0 -197
- package/eventcatalog/src/features/studio/studio.css +0 -273
- 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
|
-
|
|
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 {
|
|
17
|
-
|
|
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
|
|
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 (`
|
|
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
|
|
82
|
-
|
|
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
|
-
|
|
140
|
-
|
|
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)
|
|
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
|
-
|
|
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(
|
|
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;
|
|
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
|
-
|
|
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 =
|
|
483
|
+
const addResource = (key: string, point: XYPosition) => {
|
|
284
484
|
const resource = catalog.resourcesByKey.get(key);
|
|
285
485
|
if (!resource) return;
|
|
286
|
-
|
|
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,
|
|
289
|
-
addCard(resource,
|
|
290
|
-
}
|
|
489
|
+
if (groups.length > 0) return setConnectionsChooser({ resource, at, groups });
|
|
490
|
+
addCard(resource, at);
|
|
491
|
+
};
|
|
291
492
|
|
|
292
|
-
const addCard = (resource: CatalogResource,
|
|
293
|
-
|
|
294
|
-
|
|
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,
|
|
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 =
|
|
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
|
-
|
|
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.
|
|
330
|
-
else void addContainer(chooser.resource, chooser.
|
|
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,
|
|
338
|
-
if (choice.as === 'card') addCard(resource,
|
|
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
|
|
366
|
-
*
|
|
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
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
const
|
|
372
|
-
if (
|
|
373
|
-
|
|
374
|
-
const
|
|
375
|
-
const
|
|
376
|
-
|
|
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
|
|
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,
|
|
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
|
-
|
|
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,
|
|
801
|
+
const onNodeDrag = useStableCallback((_: unknown, held: Node, dragged: Node[]) => {
|
|
549
802
|
growContainers(dragged);
|
|
550
803
|
moveThreadsWith(dragged);
|
|
551
|
-
const
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
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,
|
|
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
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
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
|
|
585
|
-
|
|
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,
|
|
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
|
-
|
|
594
|
-
|
|
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
|
-
|
|
606
|
-
|
|
607
|
-
(
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
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
|
-
//
|
|
623
|
-
const [
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
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({ ...
|
|
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
|
-
|
|
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
|
|
690
|
-
const
|
|
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
|
|
694
|
-
// envelope
|
|
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 =
|
|
697
|
-
const focused = new Set(selectedKey ? selectedKey.split(',') : []);
|
|
1249
|
+
const edges = levelEdges ?? flow.edges;
|
|
698
1250
|
return edges.map((edge) =>
|
|
699
|
-
edge.type === 'animated' &&
|
|
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
|
-
}, [
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 (!
|
|
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
|
|
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
|
-
|
|
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
|
-
<
|
|
933
|
-
<
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
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
|
-
</
|
|
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
|
);
|