@eventcatalog/core 5.0.0-beta.3 → 5.0.0-beta.30
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 +234 -134
- package/README.md +5 -5
- package/dist/analytics/analytics.cjs +18 -3
- package/dist/analytics/analytics.d.cts +1 -1
- package/dist/analytics/analytics.d.ts +1 -1
- package/dist/analytics/analytics.js +3 -2
- package/dist/analytics/log-build.cjs +160 -77
- package/dist/analytics/log-build.d.cts +2 -2
- package/dist/analytics/log-build.d.ts +2 -2
- package/dist/analytics/log-build.js +7 -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/analytics/telemetry.cjs +42 -0
- package/dist/analytics/telemetry.d.cts +20 -0
- package/dist/analytics/telemetry.d.ts +20 -0
- package/dist/analytics/telemetry.js +8 -0
- package/dist/catalog-runtime.cjs +1 -1
- package/dist/{chunk-RFIXKATC.js → chunk-4G7FSRPN.js} +35 -42
- package/dist/{chunk-6OC46WQJ.js → chunk-55QETQZ3.js} +66 -35
- package/dist/{chunk-TQLRG35T.js → chunk-AD7CL772.js} +78 -45
- package/dist/chunk-CA6WCQ2T.js +54 -0
- package/dist/chunk-CPW6G62V.js +17 -0
- package/dist/{chunk-423S2KHN.js → chunk-FLAZ2JVN.js} +4 -2
- package/dist/{chunk-BH2F5WPN.js → chunk-GQI5UYXZ.js} +1 -1
- package/dist/{chunk-GYIVJXOJ.js → chunk-NA7OTBWW.js} +1 -1
- package/dist/{chunk-MDWB5HLO.js → chunk-NKNOXQ62.js} +1 -1
- package/dist/{chunk-3PKS2IIP.js → chunk-OX6TSVHY.js} +1 -1
- package/dist/{chunk-36RYTYT5.js → chunk-PD7KTSYN.js} +1 -1
- package/dist/chunk-S5GWAUG5.js +29 -0
- package/dist/{chunk-NDJOAITI.js → chunk-T3OROMAU.js} +9 -3
- package/dist/constants.cjs +1 -1
- package/dist/constants.js +1 -1
- 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 +531 -356
- package/dist/eventcatalog.config.d.cts +59 -1
- package/dist/eventcatalog.config.d.ts +59 -1
- package/dist/eventcatalog.js +63 -25
- package/dist/features.cjs +34 -41
- package/dist/features.js +2 -2
- package/dist/federation/federate.cjs +37 -44
- package/dist/federation/federate.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/@hocuspocus__provider.mjs +3 -0
- package/dist/runtime-dependencies/@hocuspocus__server.mjs +3 -0
- package/dist/runtime-dependencies/@mcp-b__webmcp-local-relay.mjs +3 -0
- package/dist/runtime-dependencies/@mcp-b__webmcp-polyfill.mjs +3 -0
- package/dist/runtime-dependencies/htmlparser2.mjs +3 -0
- package/dist/runtime-dependencies/manifest.json +13 -1
- package/dist/runtime-dependencies/react-hotkeys-hook.mjs +3 -0
- package/dist/runtime-dependencies/react-syntax-highlighter__dist__cjs__create-element.js.mjs +3 -0
- package/dist/runtime-dependencies/y-protocols.mjs +3 -0
- package/dist/runtime-dependencies/y-protocols__awareness.mjs +3 -0
- package/dist/runtime-dependencies/yjs.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 +78 -45
- package/dist/utils/license-status.d.cts +15 -5
- package/dist/utils/license-status.d.ts +15 -5
- package/dist/utils/license-status.js +1 -1
- package/eventcatalog/astro.config.mjs +23 -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 +32 -0
- package/eventcatalog/src/components/CatalogGraph/CatalogForceGraph.tsx +39 -0
- package/eventcatalog/src/components/ChatPanel/ChatPanel.tsx +2 -0
- package/eventcatalog/src/components/Header.astro +3 -3
- 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 +53 -83
- package/eventcatalog/src/components/SideNav/NestedSideBar/storage.ts +2 -0
- package/eventcatalog/src/components/SideNav/NestedSideBar/utils.ts +131 -0
- 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 +10 -0
- 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 +34 -1
- package/eventcatalog/src/features/mcp/apps/architecture-diagram/node-chat.tsx +6 -45
- package/eventcatalog/src/features/mcp/apps/canvas/shared.ts +47 -0
- package/eventcatalog/src/features/mcp/apps/canvas/styles.css +24 -0
- package/eventcatalog/src/features/mcp/apps/canvas/view.tsx +278 -0
- package/eventcatalog/src/features/mcp/apps/generated/canvas.html +272 -0
- package/eventcatalog/src/features/mcp/apps/generated/viewer.html +65 -65
- package/eventcatalog/src/features/mcp/apps/shared/app-view.tsx +2 -1
- package/eventcatalog/src/features/mcp/mcp-apps.ts +6 -3
- package/eventcatalog/src/features/mcp/mcp-server.ts +36 -5
- 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 +384 -0
- 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 +540 -0
- package/eventcatalog/src/features/studio/canvas-preview.ts +52 -0
- package/eventcatalog/src/features/studio/catalog-resources.ts +155 -0
- package/eventcatalog/src/features/studio/catalog.ts +173 -0
- package/eventcatalog/src/features/studio/components/CanvasHeader.tsx +518 -0
- package/eventcatalog/src/features/studio/components/CanvasStatusMenu.tsx +118 -0
- package/eventcatalog/src/features/studio/components/ConnectAgentDialog.tsx +434 -0
- package/eventcatalog/src/features/studio/components/ConnectionsChooser.tsx +143 -0
- 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 +109 -0
- package/eventcatalog/src/features/studio/components/LeftPanel.tsx +206 -0
- 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 +293 -0
- package/eventcatalog/src/features/studio/components/ShareDialog.tsx +396 -0
- package/eventcatalog/src/features/studio/components/ShortcutsDialog.tsx +41 -0
- package/eventcatalog/src/features/studio/components/StudioDesigner.tsx +2091 -0
- 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 +63 -0
- package/eventcatalog/src/features/studio/hooks/presence-store.ts +119 -0
- package/eventcatalog/src/features/studio/hooks/use-canvas-webmcp.ts +353 -0
- package/eventcatalog/src/features/studio/hooks/use-comments.ts +54 -0
- package/eventcatalog/src/features/studio/hooks/use-follow-camera.ts +109 -0
- package/eventcatalog/src/features/studio/hooks/use-studio-flow.ts +782 -0
- package/eventcatalog/src/features/studio/identity.ts +59 -0
- package/eventcatalog/src/features/studio/limits.ts +2 -0
- package/eventcatalog/src/features/studio/pages/[id].astro +69 -0
- package/eventcatalog/src/features/studio/pages/index.astro +784 -0
- package/eventcatalog/src/features/studio/pages/webmcp-relay.ts +27 -0
- package/eventcatalog/src/features/studio/server/canvas-mcp.ts +495 -0
- package/eventcatalog/src/features/studio/server/runtime.ts +498 -0
- package/eventcatalog/src/features/studio/server/sign-in.ts +18 -0
- package/eventcatalog/src/features/studio/server/sql/storage.sql +18 -0
- package/eventcatalog/src/features/studio/server/storage.ts +90 -0
- package/eventcatalog/src/features/studio/server/tool-icons.ts +30 -0
- package/eventcatalog/src/features/studio/tool-sync.ts +75 -0
- package/eventcatalog/src/layouts/BaseLayout.astro +4 -4
- package/eventcatalog/src/layouts/SettingsLayout.astro +2 -2
- package/eventcatalog/src/layouts/VerticalSideBarLayout.astro +13 -5
- 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 +1 -0
- 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/util.ts +8 -4
- package/eventcatalog/src/utils/feature.ts +8 -0
- package/eventcatalog/src/utils/git.ts +22 -5
- 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/export-mermaid.ts +0 -2
- 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 +14 -1
- package/package.json +19 -13
- package/dist/runtime-dependencies/shelljs.mjs +0 -3
- package/eventcatalog/public/studio-bg.png +0 -0
- package/eventcatalog/src/components/Studio/StudioPageModal.tsx +0 -150
- package/eventcatalog/src/pages/studio.astro +0 -316
|
@@ -0,0 +1,2091 @@
|
|
|
1
|
+
import { useCallback, useEffect, useMemo, useRef, useState, type DragEvent, type MouseEvent as ReactMouseEvent } from 'react';
|
|
2
|
+
import {
|
|
3
|
+
applyNodeChanges,
|
|
4
|
+
ConnectionLineType,
|
|
5
|
+
ReactFlow,
|
|
6
|
+
ReactFlowProvider,
|
|
7
|
+
useReactFlow,
|
|
8
|
+
type Connection,
|
|
9
|
+
type Edge,
|
|
10
|
+
type Node,
|
|
11
|
+
type FitViewOptions,
|
|
12
|
+
type NodeChange,
|
|
13
|
+
type XYPosition,
|
|
14
|
+
} from '@xyflow/react';
|
|
15
|
+
import { DiagramBackground, edgeTypes, getLegend, LegendPanel, pauseEdgeAnimations } from '@eventcatalog/visualiser';
|
|
16
|
+
import { useHotkeys } from 'react-hotkeys-hook';
|
|
17
|
+
import { getCatalogConnections, indexCatalog, planWithConnections, type ConnectionGroup } from '../canvas-actions';
|
|
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';
|
|
102
|
+
import type { CatalogRelation, CatalogResource } from '../catalog-resources';
|
|
103
|
+
import {
|
|
104
|
+
buildContainer,
|
|
105
|
+
buildContainerWithContents,
|
|
106
|
+
canBeContainer,
|
|
107
|
+
catalogNodeData,
|
|
108
|
+
getCatalogLink,
|
|
109
|
+
getContents,
|
|
110
|
+
getRelatedEdges,
|
|
111
|
+
} from '../catalog';
|
|
112
|
+
import type { Peer } from '../hooks/presence-store';
|
|
113
|
+
import { useCanvasWebMcp } from '../hooks/use-canvas-webmcp';
|
|
114
|
+
import { useStudioFlow } from '../hooks/use-studio-flow';
|
|
115
|
+
import { useComments, type CommentAnchor, type Thread } from '../hooks/use-comments';
|
|
116
|
+
import type { ToolSync } from '../tool-sync';
|
|
117
|
+
import CanvasHeader from './CanvasHeader';
|
|
118
|
+
import ConnectAgentDialog, { enableWebMcp, isRelayEnabled, isWebMcpEnabled, loadRelay } from './ConnectAgentDialog';
|
|
119
|
+
import ConnectionsChooser, { type ConnectionsChoice } from './ConnectionsChooser';
|
|
120
|
+
import JoinForm from './JoinForm';
|
|
121
|
+
import { colorForName, getStoredColor, getStoredName, onStoredNameChange, storeColor, storeName } from '../identity';
|
|
122
|
+
import LeftPanel, { CATALOG_DRAG_TYPE, COMPONENT_DRAG_TYPE, type LeftPanelTab } from './LeftPanel';
|
|
123
|
+
import { AgentActivity, RemotePresence, ViewportSharer } from './Presence';
|
|
124
|
+
import NodeDetails, { type NodeConnection } from './NodeDetails';
|
|
125
|
+
import ShareDialog from './ShareDialog';
|
|
126
|
+
import DeleteCanvasDialog from './DeleteCanvasDialog';
|
|
127
|
+
import { createCanvasThroughApi, deleteCanvasThroughApi } from '../api/client';
|
|
128
|
+
import ShortcutsDialog from './ShortcutsDialog';
|
|
129
|
+
|
|
130
|
+
type CanvasProps = {
|
|
131
|
+
canvasId: string;
|
|
132
|
+
/** Path of the collaboration socket on this site, or `socketUrl` when embedded elsewhere (e.g. an MCP App) */
|
|
133
|
+
socketPath?: string;
|
|
134
|
+
socketUrl?: string;
|
|
135
|
+
/** Where "New canvas" goes (hidden when not given, e.g. inside a chat) */
|
|
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;
|
|
141
|
+
/** The canvas's link, for Share (defaults to this page) */
|
|
142
|
+
shareUrl?: string;
|
|
143
|
+
/** The EventCatalog MCP server agents connect to (a path on this site, or a URL) */
|
|
144
|
+
mcpUrl?: string;
|
|
145
|
+
/** The WebMCP local relay's script, when this page can offer it (not inside a chat) */
|
|
146
|
+
relayScriptPath?: string;
|
|
147
|
+
/** Canvases are only kept in memory, until the server restarts (`storage` is "memory") */
|
|
148
|
+
keptInMemory?: boolean;
|
|
149
|
+
/** Sync through MCP tool calls when the WebSocket can't connect (e.g. inside a chat's sandbox) */
|
|
150
|
+
syncViaTools?: ToolSync;
|
|
151
|
+
/** Follow agents' changes with the camera from the start (e.g. inside a chat, where you watch an agent work) */
|
|
152
|
+
followChanges?: boolean;
|
|
153
|
+
/** Starts with the palette folded away (e.g. in a chat, where there's little room) */
|
|
154
|
+
paletteCollapsed?: boolean;
|
|
155
|
+
/** Told what's selected (e.g. a chat attaches it to the conversation, so you can ask about it) */
|
|
156
|
+
onSelectionChange?: (selected: SelectedNode[]) => void;
|
|
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 };
|
|
159
|
+
/** Signed in with the catalog's sign-in (SSO): you're shown by this name and picture, and can't change them */
|
|
160
|
+
signedInAs?: { name: string; picture?: string };
|
|
161
|
+
resources: CatalogResource[];
|
|
162
|
+
relations: CatalogRelation[];
|
|
163
|
+
};
|
|
164
|
+
/** A selected node, as told to whoever embeds the canvas */
|
|
165
|
+
export type SelectedNode = { id: string; name: string; type: string; catalogResource?: string };
|
|
166
|
+
|
|
167
|
+
/** How long a level (L1, L2) waits for changes to settle before it's laid out again */
|
|
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
|
+
};
|
|
192
|
+
const DELETE_KEYS = ['Backspace', 'Delete'];
|
|
193
|
+
const NO_THREADS: ReadonlySet<string> = new Set();
|
|
194
|
+
// React Flow's warnings, except that a handle has no node: the left panel's drag previews draw nodes off the canvas
|
|
195
|
+
const onFlowError = (code: string, message: string) => {
|
|
196
|
+
if (code !== '010') console.warn(message);
|
|
197
|
+
};
|
|
198
|
+
const MULTI_SELECTION_KEYS = ['Meta', 'Shift'];
|
|
199
|
+
/**
|
|
200
|
+
* Like design tools: dragging on the canvas draws a box to select what's inside it, and the canvas pans with
|
|
201
|
+
* space + drag, the middle mouse button, or scrolling (two fingers on a trackpad). Pinch, or cmd / ctrl + scroll,
|
|
202
|
+
* zooms. The right button stays for the context menu.
|
|
203
|
+
*/
|
|
204
|
+
const PAN_BUTTONS = [1];
|
|
205
|
+
/** How close to a connection's end you grab it to move it */
|
|
206
|
+
const RECONNECT_RADIUS = 14;
|
|
207
|
+
const ZOOM_KEYS = ['Meta', 'Control'];
|
|
208
|
+
const DEFAULT_SOCKET_PATH = '/_eventcatalog/studio';
|
|
209
|
+
|
|
210
|
+
/** A function that keeps its identity across renders and always calls the latest version (for React Flow's props) */
|
|
211
|
+
const useStableCallback = <Args extends unknown[], Result>(fn: (...args: Args) => Result) => {
|
|
212
|
+
const latest = useRef(fn);
|
|
213
|
+
latest.current = fn;
|
|
214
|
+
return useCallback((...args: Args) => latest.current(...args), []);
|
|
215
|
+
};
|
|
216
|
+
|
|
217
|
+
// A message edge drawn as a plain labelled edge (kept per edge, so React Flow sees the same edge until it changes)
|
|
218
|
+
const staticEdges = new WeakMap<Edge, Edge>();
|
|
219
|
+
const withoutEnvelope = (edge: Edge) => {
|
|
220
|
+
let drawn = staticEdges.get(edge);
|
|
221
|
+
if (!drawn) staticEdges.set(edge, (drawn = { ...edge, type: 'smoothstep' }));
|
|
222
|
+
return drawn;
|
|
223
|
+
};
|
|
224
|
+
|
|
225
|
+
/** Ids, types and containers: what changes when nodes are added, removed or moved in or out of containers */
|
|
226
|
+
// Notes on L1 and L2 can be moved and selected there, unlike the rest of the level (made once per note, so a note
|
|
227
|
+
// keeps its identity until it changes)
|
|
228
|
+
const levelNotes = new WeakMap<Node, Node>();
|
|
229
|
+
const asLevelNote = (node: Node) => {
|
|
230
|
+
let note = levelNotes.get(node);
|
|
231
|
+
if (!note) levelNotes.set(node, (note = { ...node, draggable: true, selectable: true }));
|
|
232
|
+
return note;
|
|
233
|
+
};
|
|
234
|
+
|
|
235
|
+
const structureOf = (nodes: Node[]) => nodes.map((node) => `${node.id}:${node.type}:${node.parentId ?? ''}`).join('|');
|
|
236
|
+
|
|
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);
|
|
289
|
+
|
|
290
|
+
const saveName = useCallback((next: string) => {
|
|
291
|
+
setName(next);
|
|
292
|
+
storeName(next);
|
|
293
|
+
}, []);
|
|
294
|
+
// Renamed on the Studio page or another canvas in this browser
|
|
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
|
+
}, []);
|
|
302
|
+
|
|
303
|
+
if (asAgent) {
|
|
304
|
+
return (
|
|
305
|
+
<ReactFlowProvider>
|
|
306
|
+
<Canvas {...props} name={asAgent.name} chosenColor={asAgent.color} agent onRecolor={() => {}} />
|
|
307
|
+
</ReactFlowProvider>
|
|
308
|
+
);
|
|
309
|
+
}
|
|
310
|
+
if (signedInAs) {
|
|
311
|
+
return (
|
|
312
|
+
<ReactFlowProvider>
|
|
313
|
+
<Canvas {...props} name={signedInAs.name} picture={signedInAs.picture} chosenColor={chosenColor} onRecolor={recolor} />
|
|
314
|
+
</ReactFlowProvider>
|
|
315
|
+
);
|
|
316
|
+
}
|
|
317
|
+
if (!name)
|
|
318
|
+
return (
|
|
319
|
+
<JoinForm
|
|
320
|
+
color={chosenColor}
|
|
321
|
+
onJoin={(me) => {
|
|
322
|
+
recolor(me.color);
|
|
323
|
+
saveName(me.name);
|
|
324
|
+
}}
|
|
325
|
+
/>
|
|
326
|
+
);
|
|
327
|
+
|
|
328
|
+
return (
|
|
329
|
+
<ReactFlowProvider>
|
|
330
|
+
<Canvas {...props} name={name} onRename={saveName} chosenColor={chosenColor} onRecolor={recolor} />
|
|
331
|
+
</ReactFlowProvider>
|
|
332
|
+
);
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
const toSocketUrl = (socketPath = DEFAULT_SOCKET_PATH) =>
|
|
336
|
+
`${window.location.protocol === 'https:' ? 'wss' : 'ws'}://${window.location.host}${socketPath}`;
|
|
337
|
+
|
|
338
|
+
function Canvas({
|
|
339
|
+
canvasId,
|
|
340
|
+
socketPath,
|
|
341
|
+
socketUrl,
|
|
342
|
+
newCanvasUrl,
|
|
343
|
+
studioUrl,
|
|
344
|
+
canvasesApiUrl,
|
|
345
|
+
shareUrl,
|
|
346
|
+
mcpUrl = '/docs/mcp',
|
|
347
|
+
relayScriptPath,
|
|
348
|
+
keptInMemory = false,
|
|
349
|
+
syncViaTools,
|
|
350
|
+
followChanges = false,
|
|
351
|
+
paletteCollapsed,
|
|
352
|
+
onSelectionChange,
|
|
353
|
+
resources,
|
|
354
|
+
relations,
|
|
355
|
+
name,
|
|
356
|
+
picture,
|
|
357
|
+
agent,
|
|
358
|
+
onRename,
|
|
359
|
+
chosenColor,
|
|
360
|
+
onRecolor,
|
|
361
|
+
}: Omit<CanvasProps, 'asAgent' | 'signedInAs'> & {
|
|
362
|
+
name: string;
|
|
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;
|
|
366
|
+
/** Not given when you're signed in (your name is the one you signed in with) */
|
|
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;
|
|
371
|
+
}) {
|
|
372
|
+
const color = chosenColor ?? colorForName(name);
|
|
373
|
+
const canvasRef = useRef<HTMLDivElement>(null);
|
|
374
|
+
|
|
375
|
+
// Nodes glide to their new place after a layout (anyone's) or a level change (not while editing: it would make dragging lag)
|
|
376
|
+
const [transitioning, setTransitioning] = useState(false);
|
|
377
|
+
const transitionTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
|
|
378
|
+
const glide = useCallback(() => {
|
|
379
|
+
setTransitioning(true);
|
|
380
|
+
clearTimeout(transitionTimer.current);
|
|
381
|
+
transitionTimer.current = setTimeout(() => setTransitioning(false), 600);
|
|
382
|
+
}, []);
|
|
383
|
+
|
|
384
|
+
// Containers grow smoothly when they're fitted to what's in them (or someone else resizes them). Set on the
|
|
385
|
+
// element rather than in state, so it doesn't re-render the canvas.
|
|
386
|
+
const resizeTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
|
|
387
|
+
const animateContainers = useCallback(() => {
|
|
388
|
+
canvasRef.current?.classList.add('is-container-resize');
|
|
389
|
+
clearTimeout(resizeTimer.current);
|
|
390
|
+
resizeTimer.current = setTimeout(() => canvasRef.current?.classList.remove('is-container-resize'), 400);
|
|
391
|
+
}, []);
|
|
392
|
+
|
|
393
|
+
const flow = useStudioFlow({
|
|
394
|
+
canvasId,
|
|
395
|
+
socketUrl: socketUrl ?? toSocketUrl(socketPath),
|
|
396
|
+
name,
|
|
397
|
+
color,
|
|
398
|
+
picture,
|
|
399
|
+
agent,
|
|
400
|
+
syncViaTools,
|
|
401
|
+
onLayout: glide,
|
|
402
|
+
onContainerResize: animateContainers,
|
|
403
|
+
});
|
|
404
|
+
const author = useMemo(
|
|
405
|
+
() => ({ name, color, ...(picture && { picture }), ...(agent && { agent }) }),
|
|
406
|
+
[name, color, picture, agent]
|
|
407
|
+
);
|
|
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
|
+
);
|
|
418
|
+
const { screenToFlowPosition, flowToScreenPosition, setCenter, getZoom, fitView } = useReactFlow();
|
|
419
|
+
|
|
420
|
+
const catalog = useMemo(() => indexCatalog(resources, relations), [resources, relations]);
|
|
421
|
+
|
|
422
|
+
const nodesRef = useRef(flow.nodes);
|
|
423
|
+
nodesRef.current = flow.nodes;
|
|
424
|
+
const edgesRef = useRef(flow.edges);
|
|
425
|
+
edgesRef.current = flow.edges;
|
|
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
|
+
};
|
|
434
|
+
|
|
435
|
+
// Agents in the browser (WebMCP) work on this canvas through the page, as the user's agent. Browsers without
|
|
436
|
+
// WebMCP of their own get it through the polyfill once someone turns it on (Connect agent).
|
|
437
|
+
const [webMcpEnabled, setWebMcpEnabled] = useState(isWebMcpEnabled);
|
|
438
|
+
const turnOnWebMcp = useCallback(() => {
|
|
439
|
+
enableWebMcp();
|
|
440
|
+
setWebMcpEnabled(true);
|
|
441
|
+
}, []);
|
|
442
|
+
const webMcp = useCanvasWebMcp({
|
|
443
|
+
allowPolyfill: webMcpEnabled,
|
|
444
|
+
doc: flow.doc,
|
|
445
|
+
presence: flow.presence,
|
|
446
|
+
getPeers: () => flow.presence?.getPeers() ?? [],
|
|
447
|
+
resources,
|
|
448
|
+
catalog,
|
|
449
|
+
agent: { name: `${name}'s agent`, color, agent: true },
|
|
450
|
+
select: (ids) => select(new Set(ids)),
|
|
451
|
+
fitView: (ids) => void fitView({ nodes: ids?.map((id) => ({ id })), duration: 400, padding: 0.3, maxZoom: 1.2 }),
|
|
452
|
+
reorder: () => reorder(),
|
|
453
|
+
});
|
|
454
|
+
|
|
455
|
+
// Recomputed only when nodes are added, removed or change container, not as they move
|
|
456
|
+
const structure = structureOf(flow.nodes);
|
|
457
|
+
const catalogKeys = flow.nodes
|
|
458
|
+
.map((node) => getCatalogLink(node)?.key)
|
|
459
|
+
.filter(Boolean)
|
|
460
|
+
.join('\n');
|
|
461
|
+
const keysOnCanvas = useMemo(() => new Set(catalogKeys ? catalogKeys.split('\n') : []), [catalogKeys]);
|
|
462
|
+
|
|
463
|
+
/** Middle of the visible canvas, spread out so repeated adds don't land on top of each other */
|
|
464
|
+
const canvasCenter = () => {
|
|
465
|
+
const rect = canvasRef.current!.getBoundingClientRect();
|
|
466
|
+
const step = nodesRef.current.length % 6;
|
|
467
|
+
const offset = { x: ((step % 3) - 1) * 240, y: (Math.floor(step / 3) - 0.5) * 160 };
|
|
468
|
+
return screenToFlowPosition({ x: rect.left + rect.width / 2 + offset.x, y: rect.top + rect.height / 2 + offset.y });
|
|
469
|
+
};
|
|
470
|
+
|
|
471
|
+
// ---- Adding things ----
|
|
472
|
+
|
|
473
|
+
// A domain or system dropped on the canvas: asked whether it's a card or a container first
|
|
474
|
+
const [chooser, setChooser] = useState<{ resource: CatalogResource; at: Landing } | null>(null);
|
|
475
|
+
// A service or message that connects to things not on the canvas yet: asked whether to bring them too
|
|
476
|
+
const [connectionsChooser, setConnectionsChooser] = useState<{
|
|
477
|
+
resource: CatalogResource;
|
|
478
|
+
at: Landing;
|
|
479
|
+
groups: ConnectionGroup[];
|
|
480
|
+
} | null>(null);
|
|
481
|
+
|
|
482
|
+
// A catalog resource is added with edges to the related resources already on the canvas
|
|
483
|
+
const addResource = (key: string, point: XYPosition) => {
|
|
484
|
+
const resource = catalog.resourcesByKey.get(key);
|
|
485
|
+
if (!resource) return;
|
|
486
|
+
const at = landingAt(point, resource.node.type);
|
|
487
|
+
if (canBeContainer(resource)) return setChooser({ resource, at });
|
|
488
|
+
const groups = getCatalogConnections(resource, catalog, keysOnCanvas);
|
|
489
|
+
if (groups.length > 0) return setConnectionsChooser({ resource, at, groups });
|
|
490
|
+
addCard(resource, at);
|
|
491
|
+
};
|
|
492
|
+
|
|
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
|
+
)
|
|
503
|
+
);
|
|
504
|
+
|
|
505
|
+
// Nodes waiting to be shown before the canvas is laid out around them
|
|
506
|
+
const reorderOnceShown = useRef<string[] | null>(null);
|
|
507
|
+
useEffect(() => {
|
|
508
|
+
const waiting = reorderOnceShown.current;
|
|
509
|
+
if (!waiting) return;
|
|
510
|
+
const shown = new Set(flow.nodes.map((node) => node.id));
|
|
511
|
+
if (!waiting.every((id) => shown.has(id))) return;
|
|
512
|
+
reorderOnceShown.current = null;
|
|
513
|
+
// A frame later, so they've been measured
|
|
514
|
+
requestAnimationFrame(() => void reorder());
|
|
515
|
+
}, [flow.nodes]);
|
|
516
|
+
|
|
517
|
+
/** A domain or system as a container, with everything it holds in the catalog inside if asked */
|
|
518
|
+
const addContainer = async (resource: CatalogResource, at: Landing, withContents: boolean) => {
|
|
519
|
+
const existing = nodesRef.current;
|
|
520
|
+
const built = withContents
|
|
521
|
+
? await buildContainerWithContents(resource, at.center, catalog, existing)
|
|
522
|
+
: { nodes: [buildContainer(resource, at.center)], edges: [] };
|
|
523
|
+
// Dropped in another container (e.g. a system in its domain): it goes in it
|
|
524
|
+
const outer = containerFor(at.center, existing, at.inside);
|
|
525
|
+
const nodes = outer
|
|
526
|
+
? built.nodes.map((node) =>
|
|
527
|
+
node.parentId ? node : { ...node, parentId: outer.id, position: toRelativePosition(node.position, outer.id, existing) }
|
|
528
|
+
)
|
|
529
|
+
: built.nodes;
|
|
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);
|
|
533
|
+
flow.insertNodes(nodes, built.edges);
|
|
534
|
+
keepOnLevel(at, built.nodes.find((node) => !node.parentId)?.id);
|
|
535
|
+
};
|
|
536
|
+
|
|
537
|
+
const choose = useStableCallback((choice: ContainerChoice) => {
|
|
538
|
+
if (!chooser) return;
|
|
539
|
+
if (choice.as === 'card') addCard(chooser.resource, chooser.at);
|
|
540
|
+
else void addContainer(chooser.resource, chooser.at, !!choice.withContents);
|
|
541
|
+
setChooser(null);
|
|
542
|
+
});
|
|
543
|
+
const cancelChooser = useCallback(() => setChooser(null), []);
|
|
544
|
+
|
|
545
|
+
const chooseConnections = useStableCallback((choice: ConnectionsChoice) => {
|
|
546
|
+
if (!connectionsChooser) return;
|
|
547
|
+
const { resource, at } = connectionsChooser;
|
|
548
|
+
if (choice.as === 'card') addCard(resource, at);
|
|
549
|
+
else {
|
|
550
|
+
const { nodes, edges } = planWithConnections(nodesRef.current, resource, at.center, choice.include, catalog, at.inside);
|
|
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);
|
|
554
|
+
}
|
|
555
|
+
setConnectionsChooser(null);
|
|
556
|
+
});
|
|
557
|
+
const cancelConnectionsChooser = useCallback(() => setConnectionsChooser(null), []);
|
|
558
|
+
|
|
559
|
+
// What a domain or system dropped as a container would bring with it
|
|
560
|
+
const chooserContents = useMemo(() => {
|
|
561
|
+
if (!chooser) return { systems: 0, services: 0 };
|
|
562
|
+
const inside = (resource: CatalogResource): CatalogResource[] =>
|
|
563
|
+
getContents(resource, catalog).flatMap((child) => [child, ...(canBeContainer(child) ? inside(child) : [])]);
|
|
564
|
+
const missing = inside(chooser.resource).filter((child) => !keysOnCanvas.has(child.key));
|
|
565
|
+
return {
|
|
566
|
+
systems: missing.filter((child) => child.collection === 'systems').length,
|
|
567
|
+
services: missing.filter((child) => child.collection === 'services').length,
|
|
568
|
+
};
|
|
569
|
+
}, [chooser, catalog, keysOnCanvas]);
|
|
570
|
+
|
|
571
|
+
/** A sticky note on the level shown (it's only shown on that level), written on straight away */
|
|
572
|
+
const addNoteAt = (center: XYPosition) => {
|
|
573
|
+
const data = getNodeDefinition('note')!.createData();
|
|
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);
|
|
585
|
+
};
|
|
586
|
+
/**
|
|
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.
|
|
590
|
+
*/
|
|
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));
|
|
632
|
+
};
|
|
633
|
+
|
|
634
|
+
const addComponent = useStableCallback((type: string) => addComponentAt(type, canvasCenter()));
|
|
635
|
+
const addResourceFromPanel = useStableCallback((key: string) => addResource(key, canvasCenter()));
|
|
636
|
+
|
|
637
|
+
const onDrop = useStableCallback((event: DragEvent) => {
|
|
638
|
+
event.preventDefault();
|
|
639
|
+
const position = screenToFlowPosition({ x: event.clientX, y: event.clientY });
|
|
640
|
+
const type = event.dataTransfer.getData(COMPONENT_DRAG_TYPE);
|
|
641
|
+
const resourceKey = event.dataTransfer.getData(CATALOG_DRAG_TYPE);
|
|
642
|
+
if (type) addComponentAt(type, position);
|
|
643
|
+
if (resourceKey) addResource(resourceKey, position);
|
|
644
|
+
});
|
|
645
|
+
|
|
646
|
+
// ---- Pointer, panning and dragging ----
|
|
647
|
+
|
|
648
|
+
// React Flow's node drag and panning (d3) stop mousemove from propagating, so follow the
|
|
649
|
+
// pointer with pointer events, and with dragover while something is dragged in from the palette
|
|
650
|
+
// Where the pointer is on the canvas (while it's over it), for pasting there
|
|
651
|
+
const pointerAt = useRef<XYPosition | null>(null);
|
|
652
|
+
const trackPointer = useStableCallback((event: { clientX: number; clientY: number }) => {
|
|
653
|
+
pointerAt.current = screenToFlowPosition({ x: event.clientX, y: event.clientY });
|
|
654
|
+
flow.setPointer(pointerAt.current);
|
|
655
|
+
});
|
|
656
|
+
const clearPointer = useCallback(() => {
|
|
657
|
+
pointerAt.current = null;
|
|
658
|
+
flow.setPointer(null);
|
|
659
|
+
}, [flow.setPointer]);
|
|
660
|
+
|
|
661
|
+
// ---- Copy and paste ----
|
|
662
|
+
|
|
663
|
+
// Pasting again without moving the pointer off the canvas cascades the copies, like design tools do
|
|
664
|
+
const pastes = useRef(0);
|
|
665
|
+
const selectedIds = () => nodesRef.current.filter((node) => node.selected).map((node) => node.id);
|
|
666
|
+
// What was last copied here (for "Paste here" in the context menu, which can't read the clipboard without asking)
|
|
667
|
+
const [lastCopied, setLastCopied] = useState<ClipboardContent | null>(null);
|
|
668
|
+
// Nodes to copy instead of the selection (copying from the context menu)
|
|
669
|
+
const copyOnly = useRef<string[] | null>(null);
|
|
670
|
+
|
|
671
|
+
/** Adds copies (as one change, so one undo takes them back) and selects them instead of what was selected */
|
|
672
|
+
const paste = useStableCallback((content: ClipboardContent, place: { at: XYPosition } | { offset: XYPosition }) => {
|
|
673
|
+
const { nodes, edges } = pasteNodes(content, nodesRef.current, place);
|
|
674
|
+
flow.onNodesChange(selectedIds().map((id) => ({ type: 'select', id, selected: false })));
|
|
675
|
+
flow.insertNodes(nodes, edges);
|
|
676
|
+
flow.onNodesChange(nodes.map((node) => ({ type: 'select', id: node.id, selected: true })));
|
|
677
|
+
});
|
|
678
|
+
|
|
679
|
+
useEffect(() => {
|
|
680
|
+
// Copying, cutting and pasting text in a field (or a note being edited) works as usual
|
|
681
|
+
const isTyping = () => {
|
|
682
|
+
const active = document.activeElement;
|
|
683
|
+
return (
|
|
684
|
+
active instanceof HTMLElement && (active.isContentEditable || ['INPUT', 'TEXTAREA', 'SELECT'].includes(active.tagName))
|
|
685
|
+
);
|
|
686
|
+
};
|
|
687
|
+
// Text selected on the page (e.g. in a panel) is copied as text, like anywhere else
|
|
688
|
+
const hasTextSelected = () => !!window.getSelection()?.toString();
|
|
689
|
+
const copy = (event: ClipboardEvent) => {
|
|
690
|
+
const fromMenu = copyOnly.current !== null;
|
|
691
|
+
const ids = copyOnly.current ?? selectedIds();
|
|
692
|
+
copyOnly.current = null;
|
|
693
|
+
if (levelRef.current !== 3 || isTyping() || !event.clipboardData || (!fromMenu && hasTextSelected())) return;
|
|
694
|
+
const content = copyNodes(nodesRef.current, edgesRef.current, ids);
|
|
695
|
+
if (!content) return;
|
|
696
|
+
event.preventDefault();
|
|
697
|
+
event.clipboardData.setData(CLIPBOARD_TYPE, JSON.stringify(content));
|
|
698
|
+
// Other apps get a list of what was copied
|
|
699
|
+
event.clipboardData.setData('text/plain', content.nodes.map((node) => getNodeName(node.type, node.data)).join('\n'));
|
|
700
|
+
pastes.current = 0;
|
|
701
|
+
setLastCopied(content);
|
|
702
|
+
if (event.type === 'cut') flow.deleteNodes(ids);
|
|
703
|
+
};
|
|
704
|
+
const onPaste = (event: ClipboardEvent) => {
|
|
705
|
+
if (levelRef.current !== 3 || isTyping()) return;
|
|
706
|
+
const content =
|
|
707
|
+
readClipboard(event.clipboardData?.getData(CLIPBOARD_TYPE)) ?? readClipboard(event.clipboardData?.getData('text/plain'));
|
|
708
|
+
if (!content) return;
|
|
709
|
+
event.preventDefault();
|
|
710
|
+
pastes.current += 1;
|
|
711
|
+
const step = 40 * pastes.current;
|
|
712
|
+
paste(content, pointerAt.current ? { at: pointerAt.current } : { offset: { x: step, y: step } });
|
|
713
|
+
};
|
|
714
|
+
document.addEventListener('copy', copy);
|
|
715
|
+
document.addEventListener('cut', copy);
|
|
716
|
+
document.addEventListener('paste', onPaste);
|
|
717
|
+
return () => {
|
|
718
|
+
document.removeEventListener('copy', copy);
|
|
719
|
+
document.removeEventListener('cut', copy);
|
|
720
|
+
document.removeEventListener('paste', onPaste);
|
|
721
|
+
};
|
|
722
|
+
}, [paste, flow.deleteNodes]);
|
|
723
|
+
|
|
724
|
+
const onDragOver = useStableCallback((event: DragEvent) => {
|
|
725
|
+
event.preventDefault();
|
|
726
|
+
event.dataTransfer.dropEffect = 'move';
|
|
727
|
+
trackPointer(event);
|
|
728
|
+
});
|
|
729
|
+
|
|
730
|
+
// While the canvas is panned, zoomed or dragged, message edges stop animating (a class, not state, so nothing re-renders)
|
|
731
|
+
const interactions = useRef({ moving: false, dragging: false });
|
|
732
|
+
const setInteraction = useCallback((kind: 'moving' | 'dragging', on: boolean) => {
|
|
733
|
+
const wasActive = interactions.current.moving || interactions.current.dragging;
|
|
734
|
+
interactions.current[kind] = on;
|
|
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);
|
|
740
|
+
}, []);
|
|
741
|
+
|
|
742
|
+
// The camera follows agents unless people move it themselves (set by AgentActivity)
|
|
743
|
+
const pauseCamera = useRef<() => void>(() => {});
|
|
744
|
+
const onMoveStart = useCallback(
|
|
745
|
+
(event: MouseEvent | TouchEvent | null) => {
|
|
746
|
+
if (event) pauseCamera.current();
|
|
747
|
+
setInteraction('moving', true);
|
|
748
|
+
},
|
|
749
|
+
[setInteraction]
|
|
750
|
+
);
|
|
751
|
+
const onMoveEnd = useCallback(() => setInteraction('moving', false), [setInteraction]);
|
|
752
|
+
|
|
753
|
+
// Dragging something into a container puts it in it, out of one takes it out
|
|
754
|
+
const [dropTargetId, setDropTargetId] = useState<string | null>(null);
|
|
755
|
+
// While something's dragged, the containers around it grow to fit it (see previewContainerGrowth): the canvas
|
|
756
|
+
// when the drag started, and those containers, innermost first
|
|
757
|
+
const dragStart = useRef<{ nodes: Map<string, Node>; containers: string[] } | null>(null);
|
|
758
|
+
const onNodeDragStart = useStableCallback((_: unknown, __: Node, dragged: Node[]) => {
|
|
759
|
+
pauseCamera.current();
|
|
760
|
+
setInteraction('dragging', true);
|
|
761
|
+
const lookup = lookupNodes();
|
|
762
|
+
const containers: string[] = [];
|
|
763
|
+
for (const node of dragged) {
|
|
764
|
+
for (let id = lookup.get(node.id)?.parentId; id; id = lookup.get(id)?.parentId) {
|
|
765
|
+
if (!containers.includes(id)) containers.push(id);
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
// Innermost first: deeper containers before the ones they're in
|
|
769
|
+
const depth = (id: string) => {
|
|
770
|
+
let level = 0;
|
|
771
|
+
for (let parent = lookup.get(id)?.parentId; parent; parent = lookup.get(parent)?.parentId) level++;
|
|
772
|
+
return level;
|
|
773
|
+
};
|
|
774
|
+
containers.sort((a, b) => depth(b) - depth(a));
|
|
775
|
+
dragStart.current = containers.length ? { nodes: lookup, containers } : null;
|
|
776
|
+
threadsDrag.current = selectedThreads.size && dragged[0] ? { id: dragged[0].id, from: canvasPositionOf(dragged[0]) } : null;
|
|
777
|
+
});
|
|
778
|
+
// Where a dragged node is on the canvas (its position is relative to its container)
|
|
779
|
+
const canvasPositionOf = (node: Node) => {
|
|
780
|
+
const lookup = lookupNodes();
|
|
781
|
+
lookup.set(node.id, { ...lookup.get(node.id)!, position: node.position });
|
|
782
|
+
return getAbsolutePosition(lookup.get(node.id)!, lookup);
|
|
783
|
+
};
|
|
784
|
+
// Selected comments move with what's dragged: how far it's moved from where it was
|
|
785
|
+
const threadsDrag = useRef<{ id: string; from: XYPosition } | null>(null);
|
|
786
|
+
const moveThreadsWith = (dragged: Node[]) => {
|
|
787
|
+
const drag = threadsDrag.current;
|
|
788
|
+
const node = drag && dragged.find((candidate) => candidate.id === drag.id);
|
|
789
|
+
if (!drag || !node) return;
|
|
790
|
+
const at = canvasPositionOf(node);
|
|
791
|
+
setThreadsOffset({ x: at.x - drag.from.x, y: at.y - drag.from.y });
|
|
792
|
+
};
|
|
793
|
+
const growContainers = (dragged: Node[]) => {
|
|
794
|
+
const started = dragStart.current;
|
|
795
|
+
if (!started) return;
|
|
796
|
+
// Where the dragged nodes are now (the canvas's own nodes can be a frame behind)
|
|
797
|
+
const now = lookupNodes();
|
|
798
|
+
dragged.forEach((node) => now.set(node.id, { ...now.get(node.id)!, position: node.position }));
|
|
799
|
+
flow.showDragPreview(previewContainerGrowth(started.containers, new Set(dragged.map((node) => node.id)), started.nodes, now));
|
|
800
|
+
};
|
|
801
|
+
const onNodeDrag = useStableCallback((_: unknown, held: Node, dragged: Node[]) => {
|
|
802
|
+
growContainers(dragged);
|
|
803
|
+
moveThreadsWith(dragged);
|
|
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);
|
|
810
|
+
});
|
|
811
|
+
const onNodeDragStop = useStableCallback((_: unknown, held: Node, dragged: Node[]) => {
|
|
812
|
+
setInteraction('dragging', false);
|
|
813
|
+
setDropTargetId(null);
|
|
814
|
+
dragStart.current = null;
|
|
815
|
+
// Selected comments not on a node are saved where they moved to
|
|
816
|
+
if (threadsDrag.current && threadsOffset) {
|
|
817
|
+
const offset = threadsOffset;
|
|
818
|
+
comments.moveThreads(
|
|
819
|
+
comments.threads
|
|
820
|
+
.filter((thread) => selectedThreads.has(thread.id) && !thread.nodeId)
|
|
821
|
+
.map((thread) => ({
|
|
822
|
+
threadId: thread.id,
|
|
823
|
+
anchor: { position: { x: thread.position.x + offset.x, y: thread.position.y + offset.y } },
|
|
824
|
+
}))
|
|
825
|
+
);
|
|
826
|
+
}
|
|
827
|
+
threadsDrag.current = null;
|
|
828
|
+
setThreadsOffset(null);
|
|
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) })));
|
|
840
|
+
});
|
|
841
|
+
|
|
842
|
+
// ---- Levels of detail ----
|
|
843
|
+
|
|
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
|
+
});
|
|
855
|
+
const levelRef = useRef(level);
|
|
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)
|
|
858
|
+
const editable = level === 3;
|
|
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
|
+
}, []);
|
|
883
|
+
|
|
884
|
+
// What a level shows depends on what's on the canvas and how it's connected, and names (not positions). Notes
|
|
885
|
+
// aren't laid out with it: adding, moving or writing one doesn't lay the level out again.
|
|
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;
|
|
888
|
+
const shownLevelRef = useRef<Level | undefined>(undefined);
|
|
889
|
+
shownLevelRef.current = levelGraph?.level;
|
|
890
|
+
const levelLayout = level === 3 ? undefined : flow.levelLayouts[level];
|
|
891
|
+
useEffect(() => {
|
|
892
|
+
if (level === 3) return setLevelGraph(null);
|
|
893
|
+
let cancelled = false;
|
|
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);
|
|
926
|
+
return () => {
|
|
927
|
+
cancelled = true;
|
|
928
|
+
clearTimeout(timer);
|
|
929
|
+
};
|
|
930
|
+
}, [level, levelKey, flow.diagramLevels, levelLayout]);
|
|
931
|
+
|
|
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';
|
|
936
|
+
const [draft, setDraft] = useState<CommentAnchor | null>(null);
|
|
937
|
+
const [openThreadId, setOpenThreadId] = useState<string | null>(null);
|
|
938
|
+
const [showResolved, setShowResolved] = useState(false);
|
|
939
|
+
// Comments that are part of the selection (selecting everything selects them too): only here, like what nodes
|
|
940
|
+
// are selected. While what's selected is dragged, the ones not on a node move with it by this much.
|
|
941
|
+
const [selectedThreads, setSelectedThreads] = useState<ReadonlySet<string>>(NO_THREADS);
|
|
942
|
+
const clearSelectedThreads = useCallback(() => setSelectedThreads((current) => (current.size ? NO_THREADS : current)), []);
|
|
943
|
+
const [threadsOffset, setThreadsOffset] = useState<XYPosition | null>(null);
|
|
944
|
+
const threadsShown = () => comments.threads.filter((thread) => showResolved || !thread.resolved);
|
|
945
|
+
const [contextMenu, setContextMenu] = useState<ContextMenuState>(null);
|
|
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
|
+
|
|
968
|
+
const changeLevel = useStableCallback((next: Level) => {
|
|
969
|
+
if (next === level) return;
|
|
970
|
+
glide();
|
|
971
|
+
setTool('select');
|
|
972
|
+
setDraft(null);
|
|
973
|
+
setOpenThreadId(null);
|
|
974
|
+
// What's selected may not be on the next level (e.g. a note added on this one)
|
|
975
|
+
flow.onNodesChange(selectedIds().map((id) => ({ type: 'select', id, selected: false })));
|
|
976
|
+
setLevel(next);
|
|
977
|
+
});
|
|
978
|
+
const editInL3 = useCallback(() => changeLevel(3), [changeLevel]);
|
|
979
|
+
|
|
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.
|
|
982
|
+
const shownLevel = level === 3 ? 3 : levelGraph?.level;
|
|
983
|
+
const levelHasNodes = !!levelGraph?.nodes.length;
|
|
984
|
+
useEffect(() => {
|
|
985
|
+
if (shownLevel === undefined || (shownLevel !== 3 && !levelHasNodes)) return;
|
|
986
|
+
const frame = requestAnimationFrame(() => void fitView({ ...fitOptions, duration: 450 }));
|
|
987
|
+
return () => cancelAnimationFrame(frame);
|
|
988
|
+
}, [shownLevel, levelHasNodes]);
|
|
989
|
+
|
|
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
|
+
*/
|
|
994
|
+
const reorder = useStableCallback(async () => {
|
|
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
|
+
}
|
|
1012
|
+
const positions = await getLayoutPositions(nodesRef.current, edgesRef.current);
|
|
1013
|
+
flow.applyPositions(positions);
|
|
1014
|
+
setTimeout(() => void fitView({ ...fitOptions, duration: 450 }), 50);
|
|
1015
|
+
});
|
|
1016
|
+
const onReorder = useCallback(() => void reorder(), [reorder]);
|
|
1017
|
+
|
|
1018
|
+
const selected = flow.nodes.filter((node) => node.selected);
|
|
1019
|
+
const selectedKey = selected.map((node) => node.id).join(',');
|
|
1020
|
+
|
|
1021
|
+
// React Flow needs containers before what's in them: only re-sorted when the structure changes
|
|
1022
|
+
const hierarchyOrder = useMemo(() => {
|
|
1023
|
+
const sorted = sortByHierarchy(nodesRef.current);
|
|
1024
|
+
return sorted.every((node, index) => node === nodesRef.current[index]) ? null : sorted.map((node) => node.id);
|
|
1025
|
+
}, [structure]);
|
|
1026
|
+
const sortedNodes = useMemo(() => {
|
|
1027
|
+
if (!hierarchyOrder) return flow.nodes;
|
|
1028
|
+
const byId = new Map(flow.nodes.map((node) => [node.id, node]));
|
|
1029
|
+
return hierarchyOrder.flatMap((id) => byId.get(id) ?? []);
|
|
1030
|
+
}, [flow.nodes, hierarchyOrder]);
|
|
1031
|
+
const levelShown = level !== 3 && levelGraph?.level === level ? levelGraph : null;
|
|
1032
|
+
// Sticky notes are shown on the level they were added on: on L1 and L2, over the level's layout
|
|
1033
|
+
const shownNodes = useMemo(() => {
|
|
1034
|
+
if (!levelShown)
|
|
1035
|
+
return sortedNodes.every((node) => isOnLevel(node, 3)) ? sortedNodes : sortedNodes.filter((node) => isOnLevel(node, 3));
|
|
1036
|
+
const notes = sortedNodes.filter((node) => getNoteLevel(node) === levelShown.level);
|
|
1037
|
+
return notes.length ? [...levelShown.nodes, ...notes.map(asLevelNote)] : levelShown.nodes;
|
|
1038
|
+
}, [levelShown, sortedNodes]);
|
|
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
|
+
*/
|
|
1195
|
+
const onLevelNodesChange = useStableCallback((changes: NodeChange[]) => {
|
|
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));
|
|
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);
|
|
1238
|
+
});
|
|
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;
|
|
1248
|
+
const shownEdges = useMemo(() => {
|
|
1249
|
+
const edges = levelEdges ?? flow.edges;
|
|
1250
|
+
return edges.map((edge) =>
|
|
1251
|
+
edge.type === 'animated' && edge.id !== hovered.edge && edge.source !== hovered.node && edge.target !== hovered.node
|
|
1252
|
+
? withoutEnvelope(edge)
|
|
1253
|
+
: edge
|
|
1254
|
+
);
|
|
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]);
|
|
1267
|
+
|
|
1268
|
+
// ---- People and agents ----
|
|
1269
|
+
|
|
1270
|
+
/** Jump to where someone is: their view (and zoom), or for agents, what they last worked on */
|
|
1271
|
+
const jumpTo = useStableCallback((person: Peer) => {
|
|
1272
|
+
const peer = flow.presence?.getPeers().find((candidate) => candidate.clientId === person.clientId) ?? person;
|
|
1273
|
+
if (levelRef.current !== 3) changeLevel(3);
|
|
1274
|
+
if (peer.viewport) {
|
|
1275
|
+
void setCenter(peer.viewport.x, peer.viewport.y, { zoom: peer.viewport.zoom, duration: 500 });
|
|
1276
|
+
} else if (peer.pointer) {
|
|
1277
|
+
void setCenter(peer.pointer.x, peer.pointer.y, { zoom: Math.max(getZoom(), 1), duration: 500 });
|
|
1278
|
+
}
|
|
1279
|
+
});
|
|
1280
|
+
|
|
1281
|
+
const [following, setFollowing] = useState(followChanges);
|
|
1282
|
+
const toggleFollowing = useCallback(() => setFollowing((on) => !on), []);
|
|
1283
|
+
|
|
1284
|
+
const [shareOpen, setShareOpen] = useState<false | 'choose' | 'invite'>(false);
|
|
1285
|
+
const openShare = useCallback(() => setShareOpen('choose'), []);
|
|
1286
|
+
const openLiveCollaboration = useCallback(() => setShareOpen('invite'), []);
|
|
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
|
+
});
|
|
1386
|
+
const [connectOpen, setConnectOpen] = useState(false);
|
|
1387
|
+
const openConnect = useCallback(() => setConnectOpen(true), []);
|
|
1388
|
+
const closeConnect = useCallback(() => setConnectOpen(false), []);
|
|
1389
|
+
useEffect(() => {
|
|
1390
|
+
if (relayScriptPath && isRelayEnabled()) loadRelay(relayScriptPath);
|
|
1391
|
+
}, [relayScriptPath]);
|
|
1392
|
+
const agentLink = {
|
|
1393
|
+
canvasId,
|
|
1394
|
+
title: flow.title,
|
|
1395
|
+
canvasUrl: shareUrl ?? window.location.href,
|
|
1396
|
+
mcpUrl: new URL(mcpUrl, window.location.origin).href,
|
|
1397
|
+
};
|
|
1398
|
+
|
|
1399
|
+
// ---- Comments ----
|
|
1400
|
+
|
|
1401
|
+
// Keyboard shortcuts (react-hotkeys-hook: "mod" is ⌘ on a Mac and Ctrl elsewhere; ignored while typing in a field)
|
|
1402
|
+
const onCanvas = () => levelRef.current === 3;
|
|
1403
|
+
useHotkeys(
|
|
1404
|
+
'mod+a',
|
|
1405
|
+
() => {
|
|
1406
|
+
if (!onCanvas()) return;
|
|
1407
|
+
flow.onNodesChange(
|
|
1408
|
+
nodesRef.current.filter((node) => isOnLevel(node, 3)).map((node) => ({ type: 'select', id: node.id, selected: true }))
|
|
1409
|
+
);
|
|
1410
|
+
flow.onEdgesChange(edgesRef.current.map((edge) => ({ type: 'select', id: edge.id, selected: true })));
|
|
1411
|
+
// ...and the comments shown on it
|
|
1412
|
+
setSelectedThreads(new Set(threadsShown().map((thread) => thread.id)));
|
|
1413
|
+
},
|
|
1414
|
+
{ preventDefault: true }
|
|
1415
|
+
);
|
|
1416
|
+
useHotkeys(
|
|
1417
|
+
'mod+d',
|
|
1418
|
+
() => {
|
|
1419
|
+
const content = onCanvas() && copyNodes(nodesRef.current, edgesRef.current, selectedIds());
|
|
1420
|
+
if (content) paste(content, { offset: { x: 40, y: 40 } });
|
|
1421
|
+
},
|
|
1422
|
+
{ preventDefault: true }
|
|
1423
|
+
);
|
|
1424
|
+
// On every level: notes are changed on L1 and L2 too (and the levels show what's undone on the canvas)
|
|
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 });
|
|
1441
|
+
useHotkeys('escape', () => {
|
|
1442
|
+
setHotspotDraft(null);
|
|
1443
|
+
setOpenHotspot(null);
|
|
1444
|
+
setTool('select');
|
|
1445
|
+
setDraft(null);
|
|
1446
|
+
setOpenThreadId(null);
|
|
1447
|
+
clearSelectedThreads();
|
|
1448
|
+
setEditingId(null);
|
|
1449
|
+
flow.onNodesChange(
|
|
1450
|
+
nodesRef.current.filter((node) => node.selected).map((node) => ({ type: 'select', id: node.id, selected: false }))
|
|
1451
|
+
);
|
|
1452
|
+
flow.onEdgesChange(
|
|
1453
|
+
edgesRef.current.filter((edge) => edge.selected).map((edge) => ({ type: 'select', id: edge.id, selected: false }))
|
|
1454
|
+
);
|
|
1455
|
+
});
|
|
1456
|
+
// Selected comments are deleted with what's selected (React Flow deletes the nodes and edges)
|
|
1457
|
+
useHotkeys(DELETE_KEYS, () => {
|
|
1458
|
+
if (!onCanvas() || selectedThreads.size === 0) return;
|
|
1459
|
+
comments.deleteThreads([...selectedThreads]);
|
|
1460
|
+
clearSelectedThreads();
|
|
1461
|
+
});
|
|
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
|
+
});
|
|
1480
|
+
|
|
1481
|
+
const startComment = (anchor: CommentAnchor) => {
|
|
1482
|
+
setOpenThreadId(null);
|
|
1483
|
+
setDraft(anchor);
|
|
1484
|
+
setTool('select');
|
|
1485
|
+
};
|
|
1486
|
+
|
|
1487
|
+
const flowPositionOf = (event: { clientX: number; clientY: number }) =>
|
|
1488
|
+
screenToFlowPosition({ x: event.clientX, y: event.clientY });
|
|
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 });
|
|
1492
|
+
const onPaneClick = useStableCallback((event: ReactMouseEvent) => {
|
|
1493
|
+
clearSelectedThreads();
|
|
1494
|
+
if (tool === 'hand') return;
|
|
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
|
+
}
|
|
1504
|
+
// A click inside a container (not on something in it) selects it: its body lets the pointer through so you can
|
|
1505
|
+
// drag a selection box in it, so the click lands on the canvas
|
|
1506
|
+
// (after React Flow clears the selection, which it does once this returns)
|
|
1507
|
+
const container = editable ? findGroupAtPoint(flowPositionOf(event), nodesRef.current) : undefined;
|
|
1508
|
+
if (container) queueMicrotask(() => flow.onNodesChange([{ type: 'select', id: container.id, selected: true }]));
|
|
1509
|
+
setDraft(null);
|
|
1510
|
+
setOpenThreadId(null);
|
|
1511
|
+
});
|
|
1512
|
+
|
|
1513
|
+
// Double clicking a node edits its details (notes are edited on the canvas, by double clicking them too)
|
|
1514
|
+
const onNodeDoubleClick = useStableCallback((_: ReactMouseEvent, node: Node) => {
|
|
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);
|
|
1517
|
+
});
|
|
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)
|
|
1520
|
+
const onCanvasDoubleClick = useStableCallback((event: ReactMouseEvent) => {
|
|
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));
|
|
1525
|
+
});
|
|
1526
|
+
|
|
1527
|
+
const onNodeClick = useStableCallback((event: ReactMouseEvent, node: Node) => {
|
|
1528
|
+
// Clicking one node selects just it (with shift or ⌘, it's added to what's selected)
|
|
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) });
|
|
1534
|
+
if (commentMode && editable) startComment(anchorAt(flowPositionOf(event), node, lookupNodes()));
|
|
1535
|
+
});
|
|
1536
|
+
|
|
1537
|
+
const openContextMenu = useStableCallback((event: ReactMouseEvent | MouseEvent, node?: Node) => {
|
|
1538
|
+
event.preventDefault();
|
|
1539
|
+
if (!editable || lockedRef.current) return;
|
|
1540
|
+
setContextMenu({ x: event.clientX, y: event.clientY, flowPosition: flowPositionOf(event), node });
|
|
1541
|
+
});
|
|
1542
|
+
const onPaneContextMenu = useCallback((event: ReactMouseEvent | MouseEvent) => openContextMenu(event), [openContextMenu]);
|
|
1543
|
+
const onNodeContextMenu = useCallback((event: ReactMouseEvent, node: Node) => openContextMenu(event, node), [openContextMenu]);
|
|
1544
|
+
const closeContextMenu = useCallback(() => setContextMenu(null), []);
|
|
1545
|
+
const commentFromMenu = useStableCallback(() => {
|
|
1546
|
+
if (contextMenu) startComment(anchorAt(contextMenu.flowPosition, contextMenu.node, lookupNodes()));
|
|
1547
|
+
setContextMenu(null);
|
|
1548
|
+
});
|
|
1549
|
+
/** The node right clicked, or the whole selection if it's part of it */
|
|
1550
|
+
const menuTargets = () => {
|
|
1551
|
+
const node = contextMenu?.node;
|
|
1552
|
+
if (!node) return [];
|
|
1553
|
+
const selected = selectedIds();
|
|
1554
|
+
return selected.includes(node.id) ? selected : [node.id];
|
|
1555
|
+
};
|
|
1556
|
+
const editFromMenu = useStableCallback(() => {
|
|
1557
|
+
if (contextMenu?.node) setEditingId(contextMenu.node.id);
|
|
1558
|
+
setContextMenu(null);
|
|
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
|
+
});
|
|
1580
|
+
const copyFromMenu = useStableCallback(() => {
|
|
1581
|
+
// Through the copy event, so it's copied like ⌘C (to the system clipboard too)
|
|
1582
|
+
copyOnly.current = menuTargets();
|
|
1583
|
+
document.execCommand('copy');
|
|
1584
|
+
copyOnly.current = null;
|
|
1585
|
+
setContextMenu(null);
|
|
1586
|
+
});
|
|
1587
|
+
const duplicateFromMenu = useStableCallback(() => {
|
|
1588
|
+
const content = copyNodes(nodesRef.current, edgesRef.current, menuTargets());
|
|
1589
|
+
if (content) paste(content, { offset: { x: 40, y: 40 } });
|
|
1590
|
+
setContextMenu(null);
|
|
1591
|
+
});
|
|
1592
|
+
const pasteFromMenu = useStableCallback(() => {
|
|
1593
|
+
if (lastCopied && contextMenu) paste(lastCopied, { at: contextMenu.flowPosition });
|
|
1594
|
+
setContextMenu(null);
|
|
1595
|
+
});
|
|
1596
|
+
const deleteFromMenu = useStableCallback(() => {
|
|
1597
|
+
flow.deleteNodes(menuTargets());
|
|
1598
|
+
setContextMenu(null);
|
|
1599
|
+
});
|
|
1600
|
+
|
|
1601
|
+
const openThread = useStableCallback((thread: Thread) => {
|
|
1602
|
+
setListingComments(false);
|
|
1603
|
+
const { x, y } = getAnchorPosition(thread, lookupNodes());
|
|
1604
|
+
// Leave room for the thread, which opens to the right of its pin
|
|
1605
|
+
void setCenter(x + 150, y, { zoom: getZoom(), duration: 300 });
|
|
1606
|
+
setDraft(null);
|
|
1607
|
+
setOpenThreadId(thread.id);
|
|
1608
|
+
});
|
|
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
|
+
);
|
|
1622
|
+
const openThreadById = useCallback((id: string | null) => {
|
|
1623
|
+
setDraft(null);
|
|
1624
|
+
setOpenThreadId(id);
|
|
1625
|
+
}, []);
|
|
1626
|
+
const createThread = useStableCallback((text: string) => {
|
|
1627
|
+
if (!draft) return;
|
|
1628
|
+
// Posted, it's a pin like the others (not opened): click it to see or reply to it
|
|
1629
|
+
comments.createThread(draft, text);
|
|
1630
|
+
setDraft(null);
|
|
1631
|
+
});
|
|
1632
|
+
const cancelDraft = useCallback(() => setDraft(null), []);
|
|
1633
|
+
const deleteThread = useStableCallback((id: string) => {
|
|
1634
|
+
comments.deleteThread(id);
|
|
1635
|
+
setOpenThreadId(null);
|
|
1636
|
+
});
|
|
1637
|
+
|
|
1638
|
+
// ---- Selection ----
|
|
1639
|
+
|
|
1640
|
+
useEffect(() => {
|
|
1641
|
+
onSelectionChange?.(
|
|
1642
|
+
selected.map((node) => {
|
|
1643
|
+
const link = getCatalogLink(node);
|
|
1644
|
+
return {
|
|
1645
|
+
id: node.id,
|
|
1646
|
+
name: getNodeName(node.type, node.data, node.id),
|
|
1647
|
+
type: getNodeLabel(node.type),
|
|
1648
|
+
...(link && { catalogResource: `${link.key.replace(':', '/')} v${link.version}` }),
|
|
1649
|
+
};
|
|
1650
|
+
})
|
|
1651
|
+
);
|
|
1652
|
+
}, [selectedKey]);
|
|
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.
|
|
1655
|
+
const [editingId, setEditingId] = useState<string | null>(null);
|
|
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)
|
|
1658
|
+
const inspected = useMemo(
|
|
1659
|
+
() => (editing ? { id: editing.id, type: editing.type, data: editing.data } : null),
|
|
1660
|
+
[editing?.id, editing?.type, editing?.data]
|
|
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
|
+
});
|
|
1684
|
+
const closePanel = useCallback(() => setEditingId(null), []);
|
|
1685
|
+
// An accepted or rejected canvas, and when it was decided
|
|
1686
|
+
const { status: canvasStatus, history: statusHistory } = flow.canvasStatus;
|
|
1687
|
+
const decided = isDecided(canvasStatus)
|
|
1688
|
+
? { status: canvasStatus, change: statusHistory.findLast((change) => change.status === canvasStatus) }
|
|
1689
|
+
: null;
|
|
1690
|
+
|
|
1691
|
+
return (
|
|
1692
|
+
<UpdateNodeDataContext.Provider value={flow.updateNodeData}>
|
|
1693
|
+
<DropTargetContext.Provider value={dropTargetId}>
|
|
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
|
+
)}
|
|
2084
|
+
</div>
|
|
2085
|
+
</LockedContext.Provider>
|
|
2086
|
+
</StartWritingContext.Provider>
|
|
2087
|
+
</EditingContext.Provider>
|
|
2088
|
+
</DropTargetContext.Provider>
|
|
2089
|
+
</UpdateNodeDataContext.Provider>
|
|
2090
|
+
);
|
|
2091
|
+
}
|