@eventcatalog/core 5.0.0-beta.4 → 5.0.0-beta.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (86) hide show
  1. package/dist/analytics/analytics.cjs +1 -1
  2. package/dist/analytics/analytics.js +2 -2
  3. package/dist/analytics/log-build.cjs +2 -2
  4. package/dist/analytics/log-build.js +4 -4
  5. package/dist/{chunk-TOMV5YJH.js → chunk-56CD2NH6.js} +1 -1
  6. package/dist/{chunk-M7OVJHPV.js → chunk-DE3QHVJP.js} +1 -1
  7. package/dist/{chunk-Q7FBYUCZ.js → chunk-L2IRFOE7.js} +1 -1
  8. package/dist/{chunk-TQLRG35T.js → chunk-PIYJM642.js} +1 -1
  9. package/dist/{chunk-VA77ENFQ.js → chunk-QBP3BBSV.js} +1 -1
  10. package/dist/{chunk-PL3T5TWF.js → chunk-XMPFHLK4.js} +2 -2
  11. package/dist/constants.cjs +1 -1
  12. package/dist/constants.js +1 -1
  13. package/dist/eventcatalog.cjs +2 -2
  14. package/dist/eventcatalog.js +6 -6
  15. package/dist/generate.cjs +1 -1
  16. package/dist/generate.js +3 -3
  17. package/dist/runtime-dependencies/@hocuspocus__provider.mjs +3 -0
  18. package/dist/runtime-dependencies/@hocuspocus__server.mjs +3 -0
  19. package/dist/runtime-dependencies/@mcp-b__webmcp-local-relay.mjs +3 -0
  20. package/dist/runtime-dependencies/@mcp-b__webmcp-polyfill.mjs +3 -0
  21. package/dist/runtime-dependencies/manifest.json +8 -0
  22. package/dist/runtime-dependencies/react-hotkeys-hook.mjs +3 -0
  23. package/dist/runtime-dependencies/y-protocols.mjs +3 -0
  24. package/dist/runtime-dependencies/y-protocols__awareness.mjs +3 -0
  25. package/dist/runtime-dependencies/yjs.mjs +3 -0
  26. package/dist/utils/cli-logger.cjs +1 -1
  27. package/dist/utils/cli-logger.js +2 -2
  28. package/dist/utils/license-status.cjs +1 -1
  29. package/dist/utils/license-status.js +1 -1
  30. package/eventcatalog/astro.config.mjs +2 -0
  31. package/eventcatalog/integrations/studio-server.mjs +17 -0
  32. package/eventcatalog/src/features/integrations/eventcatalog-features.ts +20 -1
  33. package/eventcatalog/src/features/mcp/apps/canvas/shared.ts +39 -0
  34. package/eventcatalog/src/features/mcp/apps/canvas/styles.css +22 -0
  35. package/eventcatalog/src/features/mcp/apps/canvas/view.tsx +269 -0
  36. package/eventcatalog/src/features/mcp/apps/generated/canvas.html +209 -0
  37. package/eventcatalog/src/features/mcp/apps/generated/viewer.html +2 -2
  38. package/eventcatalog/src/features/mcp/apps/shared/app-view.tsx +2 -1
  39. package/eventcatalog/src/features/mcp/mcp-apps.ts +6 -3
  40. package/eventcatalog/src/features/mcp/mcp-server.ts +17 -4
  41. package/eventcatalog/src/features/studio/agent-choreography.ts +378 -0
  42. package/eventcatalog/src/features/studio/canvas-actions.ts +287 -0
  43. package/eventcatalog/src/features/studio/canvas-doc.ts +280 -0
  44. package/eventcatalog/src/features/studio/catalog-resources.ts +152 -0
  45. package/eventcatalog/src/features/studio/catalog.ts +171 -0
  46. package/eventcatalog/src/features/studio/components/CanvasControls.tsx +153 -0
  47. package/eventcatalog/src/features/studio/components/CanvasHeader.tsx +332 -0
  48. package/eventcatalog/src/features/studio/components/Comments.tsx +455 -0
  49. package/eventcatalog/src/features/studio/components/ConnectAgentDialog.tsx +217 -0
  50. package/eventcatalog/src/features/studio/components/ContainerChooser.tsx +145 -0
  51. package/eventcatalog/src/features/studio/components/JoinForm.tsx +58 -0
  52. package/eventcatalog/src/features/studio/components/LeftPanel.tsx +218 -0
  53. package/eventcatalog/src/features/studio/components/Presence.tsx +284 -0
  54. package/eventcatalog/src/features/studio/components/PropertiesPanel.tsx +75 -0
  55. package/eventcatalog/src/features/studio/components/StudioDesigner.tsx +760 -0
  56. package/eventcatalog/src/features/studio/components/canvas-nodes.tsx +79 -0
  57. package/eventcatalog/src/features/studio/components/icons.ts +48 -0
  58. package/eventcatalog/src/features/studio/components/status.ts +22 -0
  59. package/eventcatalog/src/features/studio/edges.ts +54 -0
  60. package/eventcatalog/src/features/studio/flow-state.ts +92 -0
  61. package/eventcatalog/src/features/studio/grouping.ts +133 -0
  62. package/eventcatalog/src/features/studio/hooks/presence-sender.ts +46 -0
  63. package/eventcatalog/src/features/studio/hooks/presence-store.ts +115 -0
  64. package/eventcatalog/src/features/studio/hooks/use-canvas-webmcp.ts +337 -0
  65. package/eventcatalog/src/features/studio/hooks/use-comments.ts +47 -0
  66. package/eventcatalog/src/features/studio/hooks/use-follow-camera.ts +99 -0
  67. package/eventcatalog/src/features/studio/hooks/use-studio-flow.ts +423 -0
  68. package/eventcatalog/src/features/studio/layout.ts +39 -0
  69. package/eventcatalog/src/features/studio/levels.ts +216 -0
  70. package/eventcatalog/src/features/studio/node-types.ts +173 -0
  71. package/eventcatalog/src/features/studio/pages/[id].astro +38 -0
  72. package/eventcatalog/src/features/studio/pages/index.astro +8 -0
  73. package/eventcatalog/src/features/studio/pages/webmcp-relay.ts +27 -0
  74. package/eventcatalog/src/features/studio/placement.ts +197 -0
  75. package/eventcatalog/src/features/studio/server/canvas-mcp.ts +475 -0
  76. package/eventcatalog/src/features/studio/server/runtime.ts +215 -0
  77. package/eventcatalog/src/features/studio/server/tool-icons.ts +30 -0
  78. package/eventcatalog/src/features/studio/studio.css +89 -0
  79. package/eventcatalog/src/features/studio/tool-sync.ts +75 -0
  80. package/eventcatalog/src/layouts/VerticalSideBarLayout.astro +9 -2
  81. package/eventcatalog/src/utils/feature.ts +4 -0
  82. package/eventcatalog/src/utils/trial.ts +1 -1
  83. package/package.json +9 -2
  84. package/eventcatalog/public/studio-bg.png +0 -0
  85. package/eventcatalog/src/components/Studio/StudioPageModal.tsx +0 -150
  86. package/eventcatalog/src/pages/studio.astro +0 -316
@@ -0,0 +1,760 @@
1
+ import { useCallback, useEffect, useMemo, useRef, useState, type DragEvent, type MouseEvent as ReactMouseEvent } from 'react';
2
+ import {
3
+ Background,
4
+ ConnectionLineType,
5
+ ReactFlow,
6
+ ReactFlowProvider,
7
+ useReactFlow,
8
+ type Edge,
9
+ type Node,
10
+ type XYPosition,
11
+ } from '@xyflow/react';
12
+ import { edgeTypes } from '@eventcatalog/visualiser';
13
+ import { useHotkeys } from 'react-hotkeys-hook';
14
+ import { indexCatalog } from '../canvas-actions';
15
+ import type { CatalogRelation, CatalogResource } from '../catalog-resources';
16
+ import {
17
+ buildContainer,
18
+ buildContainerWithContents,
19
+ canBeContainer,
20
+ catalogNodeData,
21
+ getCatalogLink,
22
+ getContents,
23
+ getRelatedEdges,
24
+ } from '../catalog';
25
+ import { findDropTarget, findGroupAtPoint, sortByHierarchy, toRelativePosition } from '../grouping';
26
+ import type { Peer } from '../hooks/presence-store';
27
+ import { useCanvasWebMcp } from '../hooks/use-canvas-webmcp';
28
+ import { useStudioFlow } from '../hooks/use-studio-flow';
29
+ import { useComments, type CommentAnchor, type Thread } from '../hooks/use-comments';
30
+ import { getLayoutPositions, layoutGraph } from '../layout';
31
+ import { getLevelGraph, getLevelUnavailableReason, LEVELS, type Level } from '../levels';
32
+ import { getNodeLabel, getNodeName } from '../node-types';
33
+ import type { ToolSync } from '../tool-sync';
34
+ import CanvasControls, { FIT_VIEW_OPTIONS } from './CanvasControls';
35
+ import CanvasHeader from './CanvasHeader';
36
+ import { DropTargetContext, nodeTypes, UpdateNodeDataContext } from './canvas-nodes';
37
+ import { anchorAt, CanvasContextMenu, CommentLayer, getAnchorPosition, type ContextMenuState } from './Comments';
38
+ import ConnectAgentDialog, { enableWebMcp, isRelayEnabled, isWebMcpEnabled, loadRelay } from './ConnectAgentDialog';
39
+ import ContainerChooser, { type ContainerChoice } from './ContainerChooser';
40
+ import JoinForm, { colorForName, getStoredName, storeName } from './JoinForm';
41
+ import LeftPanel, { CATALOG_DRAG_TYPE, COMPONENT_DRAG_TYPE } from './LeftPanel';
42
+ import { AgentActivity, RemotePresence, ViewportSharer } from './Presence';
43
+ import PropertiesPanel from './PropertiesPanel';
44
+
45
+ type CanvasProps = {
46
+ canvasId: string;
47
+ /** Path of the collaboration socket on this site, or `socketUrl` when embedded elsewhere (e.g. an MCP App) */
48
+ socketPath?: string;
49
+ socketUrl?: string;
50
+ /** Where "New canvas" goes (hidden when not given, e.g. inside a chat) */
51
+ newCanvasUrl?: string;
52
+ /** The canvas's link, for Share (defaults to this page) */
53
+ shareUrl?: string;
54
+ /** The EventCatalog MCP server agents connect to (a path on this site, or a URL) */
55
+ mcpUrl?: string;
56
+ /** The WebMCP local relay's script, when this page can offer it (not inside a chat) */
57
+ relayScriptPath?: string;
58
+ /** Sync through MCP tool calls when the WebSocket can't connect (e.g. inside a chat's sandbox) */
59
+ syncViaTools?: ToolSync;
60
+ /** Follow agents' changes with the camera from the start (e.g. inside a chat, where you watch an agent work) */
61
+ followChanges?: boolean;
62
+ /** Told what's selected (e.g. a chat attaches it to the conversation, so you can ask about it) */
63
+ onSelectionChange?: (selected: SelectedNode[]) => void;
64
+ /** Join with this name instead of asking for one (e.g. inside a chat, where the agent or host knows who it is) */
65
+ defaultName?: string;
66
+ resources: CatalogResource[];
67
+ relations: CatalogRelation[];
68
+ };
69
+ /** A selected node, as told to whoever embeds the canvas */
70
+ export type SelectedNode = { id: string; name: string; type: string; catalogResource?: string };
71
+
72
+ /** How long a level (L1, L2) waits for changes to settle before it's laid out again */
73
+ const LEVEL_RELAYOUT_MS = 300;
74
+ const DELETE_KEYS = ['Backspace', 'Delete'];
75
+ const MULTI_SELECTION_KEYS = ['Meta', 'Shift'];
76
+ /**
77
+ * Like design tools: dragging on the canvas draws a box to select what's inside it, and the canvas pans with
78
+ * space + drag, the middle mouse button, or scrolling (two fingers on a trackpad). Pinch, or cmd / ctrl + scroll,
79
+ * zooms. The right button stays for the context menu.
80
+ */
81
+ const PAN_BUTTONS = [1];
82
+ const ZOOM_KEYS = ['Meta', 'Control'];
83
+ const DEFAULT_SOCKET_PATH = '/_eventcatalog/studio';
84
+
85
+ /** A function that keeps its identity across renders and always calls the latest version (for React Flow's props) */
86
+ const useStableCallback = <Args extends unknown[], Result>(fn: (...args: Args) => Result) => {
87
+ const latest = useRef(fn);
88
+ latest.current = fn;
89
+ return useCallback((...args: Args) => latest.current(...args), []);
90
+ };
91
+
92
+ // A message edge drawn as a plain labelled edge (kept per edge, so React Flow sees the same edge until it changes)
93
+ const staticEdges = new WeakMap<Edge, Edge>();
94
+ const withoutEnvelope = (edge: Edge) => {
95
+ let drawn = staticEdges.get(edge);
96
+ if (!drawn) staticEdges.set(edge, (drawn = { ...edge, type: 'smoothstep' }));
97
+ return drawn;
98
+ };
99
+
100
+ /** Ids, types and containers: what changes when nodes are added, removed or moved in or out of containers */
101
+ const structureOf = (nodes: Node[]) => nodes.map((node) => `${node.id}:${node.type}:${node.parentId ?? ''}`).join('|');
102
+
103
+ export default function StudioDesigner({ defaultName, ...props }: CanvasProps) {
104
+ const [name, setName] = useState(() => getStoredName() ?? defaultName ?? null);
105
+
106
+ const saveName = useCallback((next: string) => {
107
+ setName(next);
108
+ storeName(next);
109
+ }, []);
110
+
111
+ if (!name) return <JoinForm onJoin={saveName} />;
112
+
113
+ return (
114
+ <ReactFlowProvider>
115
+ <Canvas {...props} name={name} onRename={saveName} />
116
+ </ReactFlowProvider>
117
+ );
118
+ }
119
+
120
+ const toSocketUrl = (socketPath = DEFAULT_SOCKET_PATH) =>
121
+ `${window.location.protocol === 'https:' ? 'wss' : 'ws'}://${window.location.host}${socketPath}`;
122
+
123
+ function Canvas({
124
+ canvasId,
125
+ socketPath,
126
+ socketUrl,
127
+ newCanvasUrl,
128
+ shareUrl,
129
+ mcpUrl = '/docs/mcp',
130
+ relayScriptPath,
131
+ syncViaTools,
132
+ followChanges = false,
133
+ onSelectionChange,
134
+ resources,
135
+ relations,
136
+ name,
137
+ onRename,
138
+ }: Omit<CanvasProps, 'defaultName'> & { name: string; onRename: (name: string) => void }) {
139
+ const color = colorForName(name);
140
+ const canvasRef = useRef<HTMLDivElement>(null);
141
+
142
+ // Nodes glide to their new place after a layout (anyone's) or a level change (not while editing: it would make dragging lag)
143
+ const [transitioning, setTransitioning] = useState(false);
144
+ const transitionTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
145
+ const glide = useCallback(() => {
146
+ setTransitioning(true);
147
+ clearTimeout(transitionTimer.current);
148
+ transitionTimer.current = setTimeout(() => setTransitioning(false), 600);
149
+ }, []);
150
+
151
+ const flow = useStudioFlow({
152
+ canvasId,
153
+ socketUrl: socketUrl ?? toSocketUrl(socketPath),
154
+ name,
155
+ color,
156
+ syncViaTools,
157
+ onLayout: glide,
158
+ });
159
+ const author = useMemo(() => ({ name, color }), [name, color]);
160
+ const comments = useComments(flow.doc, author);
161
+ const { screenToFlowPosition, flowToScreenPosition, setCenter, getZoom, fitView } = useReactFlow();
162
+
163
+ const catalog = useMemo(() => indexCatalog(resources, relations), [resources, relations]);
164
+
165
+ const nodesRef = useRef(flow.nodes);
166
+ nodesRef.current = flow.nodes;
167
+ const edgesRef = useRef(flow.edges);
168
+ edgesRef.current = flow.edges;
169
+ const lookupNodes = () => new Map(nodesRef.current.map((node) => [node.id, node]));
170
+
171
+ // Agents in the browser (WebMCP) work on this canvas through the page, as the user's agent. Browsers without
172
+ // WebMCP of their own get it through the polyfill once someone turns it on (Connect agent).
173
+ const [webMcpEnabled, setWebMcpEnabled] = useState(isWebMcpEnabled);
174
+ const turnOnWebMcp = useCallback(() => {
175
+ enableWebMcp();
176
+ setWebMcpEnabled(true);
177
+ }, []);
178
+ const webMcp = useCanvasWebMcp({
179
+ allowPolyfill: webMcpEnabled,
180
+ doc: flow.doc,
181
+ presence: flow.presence,
182
+ getPeers: () => flow.presence?.getPeers() ?? [],
183
+ resources,
184
+ catalog,
185
+ agent: { name: `${name}'s agent`, color, agent: true },
186
+ select: (ids) =>
187
+ flow.onNodesChange(nodesRef.current.map((node) => ({ type: 'select', id: node.id, selected: ids.includes(node.id) }))),
188
+ fitView: (ids) => void fitView({ nodes: ids?.map((id) => ({ id })), duration: 400, padding: 0.3, maxZoom: 1.2 }),
189
+ reorder: () => reorder(),
190
+ });
191
+
192
+ // Recomputed only when nodes are added, removed or change container, not as they move
193
+ const structure = structureOf(flow.nodes);
194
+ const catalogKeys = flow.nodes
195
+ .map((node) => getCatalogLink(node)?.key)
196
+ .filter(Boolean)
197
+ .join('\n');
198
+ const keysOnCanvas = useMemo(() => new Set(catalogKeys ? catalogKeys.split('\n') : []), [catalogKeys]);
199
+
200
+ /** Middle of the visible canvas, spread out so repeated adds don't land on top of each other */
201
+ const canvasCenter = () => {
202
+ const rect = canvasRef.current!.getBoundingClientRect();
203
+ const step = nodesRef.current.length % 6;
204
+ const offset = { x: ((step % 3) - 1) * 240, y: (Math.floor(step / 3) - 0.5) * 160 };
205
+ return screenToFlowPosition({ x: rect.left + rect.width / 2 + offset.x, y: rect.top + rect.height / 2 + offset.y });
206
+ };
207
+
208
+ // ---- Adding things ----
209
+
210
+ // A domain or system dropped on the canvas: asked whether it's a card or a container first
211
+ const [chooser, setChooser] = useState<{ resource: CatalogResource; center: XYPosition } | null>(null);
212
+
213
+ // A catalog resource is added with edges to the related resources already on the canvas
214
+ const addResource = useStableCallback((key: string, center: XYPosition = canvasCenter()) => {
215
+ const resource = catalog.resourcesByKey.get(key);
216
+ if (!resource) return;
217
+ if (canBeContainer(resource)) return setChooser({ resource, center });
218
+ addCard(resource, center);
219
+ });
220
+
221
+ const addCard = (resource: CatalogResource, center: XYPosition) =>
222
+ flow.insertNode(resource.node.type, catalogNodeData(resource), center, (id, existing) =>
223
+ getRelatedEdges(resource, id, existing, catalog.relationsByKey)
224
+ );
225
+
226
+ // Nodes waiting to be shown before the canvas is laid out around them
227
+ const reorderOnceShown = useRef<string[] | null>(null);
228
+ useEffect(() => {
229
+ const waiting = reorderOnceShown.current;
230
+ if (!waiting) return;
231
+ const shown = new Set(flow.nodes.map((node) => node.id));
232
+ if (!waiting.every((id) => shown.has(id))) return;
233
+ reorderOnceShown.current = null;
234
+ // A frame later, so they've been measured
235
+ requestAnimationFrame(() => void reorder());
236
+ }, [flow.nodes]);
237
+
238
+ /** A domain or system as a container, with everything it holds in the catalog inside if asked */
239
+ const addContainer = async (resource: CatalogResource, center: XYPosition, withContents: boolean) => {
240
+ const existing = nodesRef.current;
241
+ const built = withContents
242
+ ? await buildContainerWithContents(resource, center, catalog, existing)
243
+ : { nodes: [buildContainer(resource, center)], edges: [] };
244
+ // Dropped in another container (e.g. a system in its domain): it goes in it
245
+ const outer = findGroupAtPoint(center, existing);
246
+ const nodes = outer
247
+ ? built.nodes.map((node) =>
248
+ node.parentId ? node : { ...node, parentId: outer.id, position: toRelativePosition(node.position, outer.id, existing) }
249
+ )
250
+ : built.nodes;
251
+ // With its contents, the canvas is laid out again to make room for it all
252
+ if (withContents && existing.length > 0) reorderOnceShown.current = nodes.map((node) => node.id);
253
+ flow.insertNodes(nodes, built.edges);
254
+ };
255
+
256
+ const choose = useStableCallback((choice: ContainerChoice) => {
257
+ if (!chooser) return;
258
+ if (choice.as === 'card') addCard(chooser.resource, chooser.center);
259
+ else void addContainer(chooser.resource, chooser.center, choice.withContents);
260
+ setChooser(null);
261
+ });
262
+ const cancelChooser = useCallback(() => setChooser(null), []);
263
+
264
+ // What a domain or system dropped as a container would bring with it
265
+ const chooserContents = useMemo(() => {
266
+ if (!chooser) return { systems: 0, services: 0 };
267
+ const inside = (resource: CatalogResource): CatalogResource[] =>
268
+ getContents(resource, catalog).flatMap((child) => [child, ...(canBeContainer(child) ? inside(child) : [])]);
269
+ const missing = inside(chooser.resource).filter((child) => !keysOnCanvas.has(child.key));
270
+ return {
271
+ systems: missing.filter((child) => child.collection === 'systems').length,
272
+ services: missing.filter((child) => child.collection === 'services').length,
273
+ };
274
+ }, [chooser, catalog, keysOnCanvas]);
275
+
276
+ const addComponent = useStableCallback((type: string) => flow.addNode(type, canvasCenter()));
277
+ const addNote = useStableCallback(() => flow.addNode('note', canvasCenter()));
278
+
279
+ const onDrop = useStableCallback((event: DragEvent) => {
280
+ event.preventDefault();
281
+ const position = screenToFlowPosition({ x: event.clientX, y: event.clientY });
282
+ const type = event.dataTransfer.getData(COMPONENT_DRAG_TYPE);
283
+ const resourceKey = event.dataTransfer.getData(CATALOG_DRAG_TYPE);
284
+ if (type) flow.addNode(type, position);
285
+ if (resourceKey) addResource(resourceKey, position);
286
+ });
287
+
288
+ // ---- Pointer, panning and dragging ----
289
+
290
+ // React Flow's node drag and panning (d3) stop mousemove from propagating, so follow the
291
+ // pointer with pointer events, and with dragover while something is dragged in from the palette
292
+ const trackPointer = useStableCallback((event: { clientX: number; clientY: number }) => {
293
+ flow.setPointer(screenToFlowPosition({ x: event.clientX, y: event.clientY }));
294
+ });
295
+ const clearPointer = useCallback(() => flow.setPointer(null), [flow.setPointer]);
296
+
297
+ const onDragOver = useStableCallback((event: DragEvent) => {
298
+ event.preventDefault();
299
+ event.dataTransfer.dropEffect = 'move';
300
+ trackPointer(event);
301
+ });
302
+
303
+ // While the canvas is panned, zoomed or dragged, message edges stop animating (a class, not state, so nothing re-renders)
304
+ const interactions = useRef({ moving: false, dragging: false });
305
+ const setInteraction = useCallback((kind: 'moving' | 'dragging', on: boolean) => {
306
+ interactions.current[kind] = on;
307
+ canvasRef.current?.classList.toggle('ec-interaction-active', interactions.current.moving || interactions.current.dragging);
308
+ }, []);
309
+
310
+ // The camera follows agents unless people move it themselves (set by AgentActivity)
311
+ const pauseCamera = useRef<() => void>(() => {});
312
+ const onMoveStart = useCallback(
313
+ (event: MouseEvent | TouchEvent | null) => {
314
+ if (event) pauseCamera.current();
315
+ setInteraction('moving', true);
316
+ },
317
+ [setInteraction]
318
+ );
319
+ const onMoveEnd = useCallback(() => setInteraction('moving', false), [setInteraction]);
320
+
321
+ // Dragging something into a container puts it in it, out of one takes it out
322
+ const [dropTargetId, setDropTargetId] = useState<string | null>(null);
323
+ const dragRoots = (dragged: Node[]) => {
324
+ const ids = new Set(dragged.map((node) => node.id));
325
+ return dragged.filter((node) => !node.parentId || !ids.has(node.parentId));
326
+ };
327
+ const onNodeDragStart = useCallback(() => {
328
+ pauseCamera.current();
329
+ setInteraction('dragging', true);
330
+ }, [setInteraction]);
331
+ const onNodeDrag = useStableCallback((_: unknown, __: Node, dragged: Node[]) => {
332
+ const [root] = dragRoots(dragged);
333
+ const current = root && nodesRef.current.find((node) => node.id === root.id);
334
+ const target = root ? findDropTarget(root.id, nodesRef.current) : undefined;
335
+ setDropTargetId(target && target.id !== current?.parentId ? target.id : null);
336
+ });
337
+ const onNodeDragStop = useStableCallback((_: unknown, __: Node, dragged: Node[]) => {
338
+ setInteraction('dragging', false);
339
+ setDropTargetId(null);
340
+ for (const root of dragRoots(dragged)) {
341
+ const current = nodesRef.current.find((node) => node.id === root.id);
342
+ const target = findDropTarget(root.id, nodesRef.current);
343
+ if (current && target?.id !== current.parentId) flow.setParent(root.id, target?.id);
344
+ }
345
+ });
346
+
347
+ // ---- Levels of detail ----
348
+
349
+ // Like EventCatalog's diagrams: L3 is the canvas people edit, L1 and L2 read only views of it
350
+ const [level, setLevel] = useState<Level>(3);
351
+ const levelRef = useRef(level);
352
+ levelRef.current = level;
353
+ const editable = level === 3;
354
+ const [levelGraph, setLevelGraph] = useState<{ level: Level; nodes: Node[]; edges: Edge[] } | null>(null);
355
+
356
+ // What a level shows depends on what's on the canvas and how it's connected, and names (not positions)
357
+ const levelKey =
358
+ level === 3
359
+ ? ''
360
+ : `${flow.nodes.map((node) => `${node.id}:${node.type}:${node.parentId ?? ''}:${getNodeName(node.type, node.data)}`).join('|')}#${flow.edges
361
+ .map((edge) => `${edge.source}>${edge.target}:${String(edge.label ?? '')}`)
362
+ .join('|')}`;
363
+ const shownLevelRef = useRef<Level | undefined>(undefined);
364
+ shownLevelRef.current = levelGraph?.level;
365
+ useEffect(() => {
366
+ if (level === 3) return setLevelGraph(null);
367
+ let cancelled = false;
368
+ // Straight away when switching levels; after changes settle while one is shown
369
+ const timer = setTimeout(
370
+ () => {
371
+ const collapsed = getLevelGraph(nodesRef.current, edgesRef.current, level);
372
+ void layoutGraph(
373
+ collapsed.nodes.map((node) => ({ ...node, selected: false })),
374
+ collapsed.edges
375
+ ).then((laidOut) => !cancelled && setLevelGraph({ level, ...laidOut }));
376
+ },
377
+ shownLevelRef.current === level ? LEVEL_RELAYOUT_MS : 0
378
+ );
379
+ return () => {
380
+ cancelled = true;
381
+ clearTimeout(timer);
382
+ };
383
+ }, [level, levelKey]);
384
+
385
+ // Comments: right click > Add comment, or C for comment mode then click (like Figma)
386
+ const [commentMode, setCommentMode] = useState(false);
387
+ const [draft, setDraft] = useState<CommentAnchor | null>(null);
388
+ const [openThreadId, setOpenThreadId] = useState<string | null>(null);
389
+ const [showResolved, setShowResolved] = useState(false);
390
+ const [contextMenu, setContextMenu] = useState<ContextMenuState>(null);
391
+
392
+ const changeLevel = useStableCallback((next: Level) => {
393
+ if (next === level) return;
394
+ glide();
395
+ setCommentMode(false);
396
+ setDraft(null);
397
+ setOpenThreadId(null);
398
+ setLevel(next);
399
+ });
400
+ const editInL3 = useCallback(() => changeLevel(3), [changeLevel]);
401
+
402
+ // Fit the camera to a level once it's laid out (and to the canvas when going back to L3)
403
+ const shownLevel = level === 3 ? 3 : levelGraph?.level;
404
+ useEffect(() => {
405
+ if (shownLevel === undefined) return;
406
+ const frame = requestAnimationFrame(() => void fitView({ ...FIT_VIEW_OPTIONS, duration: 450 }));
407
+ return () => cancelAnimationFrame(frame);
408
+ }, [shownLevel]);
409
+
410
+ /** Lays the canvas out with the visualiser's layout, for everyone on it (everyone's nodes glide there) */
411
+ const reorder = useStableCallback(async () => {
412
+ if (levelRef.current !== 3) setLevel(3);
413
+ const positions = await getLayoutPositions(nodesRef.current, edgesRef.current);
414
+ flow.applyPositions(positions);
415
+ setTimeout(() => void fitView({ ...FIT_VIEW_OPTIONS, duration: 450 }), 50);
416
+ });
417
+ const onReorder = useCallback(() => void reorder(), [reorder]);
418
+
419
+ const selected = flow.nodes.filter((node) => node.selected);
420
+ const selectedKey = selected.map((node) => node.id).join(',');
421
+
422
+ // React Flow needs containers before what's in them: only re-sorted when the structure changes
423
+ const hierarchyOrder = useMemo(() => {
424
+ const sorted = sortByHierarchy(nodesRef.current);
425
+ return sorted.every((node, index) => node === nodesRef.current[index]) ? null : sorted.map((node) => node.id);
426
+ }, [structure]);
427
+ const sortedNodes = useMemo(() => {
428
+ if (!hierarchyOrder) return flow.nodes;
429
+ const byId = new Map(flow.nodes.map((node) => [node.id, node]));
430
+ return hierarchyOrder.flatMap((id) => byId.get(id) ?? []);
431
+ }, [flow.nodes, hierarchyOrder]);
432
+ const levelShown = level !== 3 && levelGraph?.level === level ? levelGraph : null;
433
+ const shownNodes = levelShown ? levelShown.nodes : sortedNodes;
434
+ // Message edges' envelopes only move on what's selected (the edge, or a node at either end): any moving
435
+ // envelope at all costs a style recalc and layout every frame, which an editing canvas can't afford at idle
436
+ const shownEdges = useMemo(() => {
437
+ const edges = levelShown ? levelShown.edges : flow.edges;
438
+ const focused = new Set(selectedKey ? selectedKey.split(',') : []);
439
+ return edges.map((edge) =>
440
+ edge.type === 'animated' && !edge.selected && !focused.has(edge.source) && !focused.has(edge.target)
441
+ ? withoutEnvelope(edge)
442
+ : edge
443
+ );
444
+ }, [levelShown, flow.edges, selectedKey]);
445
+ const unavailableLevels = useMemo(
446
+ () => ({ 1: getLevelUnavailableReason(nodesRef.current, 1), 2: getLevelUnavailableReason(nodesRef.current, 2) }),
447
+ [structure]
448
+ );
449
+
450
+ // ---- People and agents ----
451
+
452
+ /** Jump to where someone is: their view (and zoom), or for agents, what they last worked on */
453
+ const jumpTo = useStableCallback((person: Peer) => {
454
+ const peer = flow.presence?.getPeers().find((candidate) => candidate.clientId === person.clientId) ?? person;
455
+ if (levelRef.current !== 3) changeLevel(3);
456
+ if (peer.viewport) {
457
+ void setCenter(peer.viewport.x, peer.viewport.y, { zoom: peer.viewport.zoom, duration: 500 });
458
+ } else if (peer.pointer) {
459
+ void setCenter(peer.pointer.x, peer.pointer.y, { zoom: Math.max(getZoom(), 1), duration: 500 });
460
+ }
461
+ });
462
+
463
+ const [following, setFollowing] = useState(followChanges);
464
+ const toggleFollowing = useCallback(() => setFollowing((on) => !on), []);
465
+
466
+ const [connectOpen, setConnectOpen] = useState(false);
467
+ const openConnect = useCallback(() => setConnectOpen(true), []);
468
+ const closeConnect = useCallback(() => setConnectOpen(false), []);
469
+ useEffect(() => {
470
+ if (relayScriptPath && isRelayEnabled()) loadRelay(relayScriptPath);
471
+ }, [relayScriptPath]);
472
+ const agentLink = {
473
+ canvasId,
474
+ title: flow.title,
475
+ canvasUrl: shareUrl ?? window.location.href,
476
+ mcpUrl: new URL(mcpUrl, window.location.origin).href,
477
+ };
478
+
479
+ // ---- Comments ----
480
+
481
+ // Keyboard shortcuts (react-hotkeys-hook: "mod" is ⌘ on a Mac and Ctrl elsewhere; ignored while typing in a field)
482
+ const onCanvas = () => levelRef.current === 3;
483
+ useHotkeys(
484
+ 'mod+a',
485
+ () => {
486
+ if (!onCanvas()) return;
487
+ flow.onNodesChange(nodesRef.current.map((node) => ({ type: 'select', id: node.id, selected: true })));
488
+ flow.onEdgesChange(edgesRef.current.map((edge) => ({ type: 'select', id: edge.id, selected: true })));
489
+ },
490
+ { preventDefault: true }
491
+ );
492
+ useHotkeys('mod+z', () => onCanvas() && flow.undo(), { preventDefault: true });
493
+ useHotkeys(['mod+shift+z', 'mod+y'], () => onCanvas() && flow.redo(), { preventDefault: true });
494
+ useHotkeys('c', () => onCanvas() && setCommentMode((on) => !on));
495
+ useHotkeys('escape', () => {
496
+ setCommentMode(false);
497
+ setDraft(null);
498
+ setOpenThreadId(null);
499
+ if (!onCanvas()) return;
500
+ flow.onNodesChange(
501
+ nodesRef.current.filter((node) => node.selected).map((node) => ({ type: 'select', id: node.id, selected: false }))
502
+ );
503
+ flow.onEdgesChange(
504
+ edgesRef.current.filter((edge) => edge.selected).map((edge) => ({ type: 'select', id: edge.id, selected: false }))
505
+ );
506
+ });
507
+ const toggleCommentMode = useCallback(() => setCommentMode((on) => !on), []);
508
+
509
+ const startComment = (anchor: CommentAnchor) => {
510
+ setOpenThreadId(null);
511
+ setDraft(anchor);
512
+ setCommentMode(false);
513
+ };
514
+
515
+ const flowPositionOf = (event: { clientX: number; clientY: number }) =>
516
+ screenToFlowPosition({ x: event.clientX, y: event.clientY });
517
+
518
+ const onPaneClick = useStableCallback((event: ReactMouseEvent) => {
519
+ if (commentMode && editable) return startComment(anchorAt(flowPositionOf(event)));
520
+ setDraft(null);
521
+ setOpenThreadId(null);
522
+ });
523
+
524
+ const onNodeClick = useStableCallback((event: ReactMouseEvent, node: Node) => {
525
+ if (commentMode && editable) startComment(anchorAt(flowPositionOf(event), node, lookupNodes()));
526
+ });
527
+
528
+ const openContextMenu = useStableCallback((event: ReactMouseEvent | MouseEvent, node?: Node) => {
529
+ event.preventDefault();
530
+ if (!editable) return;
531
+ setContextMenu({ x: event.clientX, y: event.clientY, flowPosition: flowPositionOf(event), node });
532
+ });
533
+ const onPaneContextMenu = useCallback((event: ReactMouseEvent | MouseEvent) => openContextMenu(event), [openContextMenu]);
534
+ const onNodeContextMenu = useCallback((event: ReactMouseEvent, node: Node) => openContextMenu(event, node), [openContextMenu]);
535
+ const closeContextMenu = useCallback(() => setContextMenu(null), []);
536
+ const commentFromMenu = useStableCallback(() => {
537
+ if (contextMenu) startComment(anchorAt(contextMenu.flowPosition, contextMenu.node, lookupNodes()));
538
+ setContextMenu(null);
539
+ });
540
+ const deleteFromMenu = useStableCallback((id: string) => {
541
+ flow.deleteNodes([id]);
542
+ setContextMenu(null);
543
+ });
544
+
545
+ const openThread = useStableCallback((thread: Thread) => {
546
+ const { x, y } = getAnchorPosition(thread, lookupNodes());
547
+ // Leave room for the thread, which opens to the right of its pin
548
+ void setCenter(x + 150, y, { zoom: getZoom(), duration: 300 });
549
+ setDraft(null);
550
+ setOpenThreadId(thread.id);
551
+ });
552
+ const toggleResolved = useCallback(() => setShowResolved((show) => !show), []);
553
+ const openThreadById = useCallback((id: string | null) => {
554
+ setDraft(null);
555
+ setOpenThreadId(id);
556
+ }, []);
557
+ const createThread = useStableCallback((text: string) => {
558
+ if (!draft) return;
559
+ setOpenThreadId(comments.createThread(draft, text) ?? null);
560
+ setDraft(null);
561
+ });
562
+ const cancelDraft = useCallback(() => setDraft(null), []);
563
+ const deleteThread = useStableCallback((id: string) => {
564
+ comments.deleteThread(id);
565
+ setOpenThreadId(null);
566
+ });
567
+
568
+ // ---- Selection ----
569
+
570
+ useEffect(() => {
571
+ onSelectionChange?.(
572
+ selected.map((node) => {
573
+ const link = getCatalogLink(node);
574
+ return {
575
+ id: node.id,
576
+ name: getNodeName(node.type, node.data, node.id),
577
+ type: getNodeLabel(node.type),
578
+ ...(link && { catalogResource: `${link.key.replace(':', '/')} v${link.version}` }),
579
+ };
580
+ })
581
+ );
582
+ }, [selectedKey]);
583
+ const single = editable && selected.length === 1 && selected[0].type !== 'note' ? selected[0] : null;
584
+ const inspected = useMemo(
585
+ () => (single ? { id: single.id, type: single.type, data: single.data } : null),
586
+ [single?.id, single?.type, single?.data]
587
+ );
588
+
589
+ return (
590
+ <UpdateNodeDataContext.Provider value={flow.updateNodeData}>
591
+ <DropTargetContext.Provider value={dropTargetId}>
592
+ <div className="flex h-full min-h-0 text-[rgb(var(--ec-page-text))]">
593
+ <LeftPanel
594
+ resources={resources}
595
+ keysOnCanvas={keysOnCanvas}
596
+ onAddComponent={addComponent}
597
+ onAddResource={addResource}
598
+ threads={comments.threads}
599
+ showResolved={showResolved}
600
+ onToggleResolved={toggleResolved}
601
+ onOpenThread={openThread}
602
+ />
603
+
604
+ <div
605
+ ref={canvasRef}
606
+ className={`eventcatalog-visualizer studio-canvas relative flex-1 min-w-0 ${commentMode ? 'comment-mode' : ''} ${
607
+ transitioning ? 'is-layout-transition' : ''
608
+ }`}
609
+ onPointerMove={trackPointer}
610
+ onPointerLeave={clearPointer}
611
+ >
612
+ <ReactFlow
613
+ nodes={shownNodes}
614
+ edges={shownEdges}
615
+ nodeTypes={nodeTypes}
616
+ edgeTypes={edgeTypes}
617
+ nodesDraggable={editable}
618
+ nodesConnectable={editable}
619
+ elementsSelectable={editable}
620
+ onNodesChange={editable ? flow.onNodesChange : undefined}
621
+ onEdgesChange={editable ? flow.onEdgesChange : undefined}
622
+ onConnect={flow.onConnect}
623
+ onDragOver={onDragOver}
624
+ onMoveStart={onMoveStart}
625
+ onMoveEnd={onMoveEnd}
626
+ onNodeDragStart={onNodeDragStart}
627
+ onNodeDrag={editable ? onNodeDrag : undefined}
628
+ onNodeDragStop={editable ? onNodeDragStop : undefined}
629
+ onDrop={onDrop}
630
+ onPaneClick={onPaneClick}
631
+ onNodeClick={onNodeClick}
632
+ onPaneContextMenu={onPaneContextMenu}
633
+ onNodeContextMenu={onNodeContextMenu}
634
+ connectionLineType={ConnectionLineType.SmoothStep}
635
+ connectionRadius={40}
636
+ deleteKeyCode={DELETE_KEYS}
637
+ multiSelectionKeyCode={MULTI_SELECTION_KEYS}
638
+ selectionOnDrag={editable}
639
+ // Read only levels can't select: dragging pans them
640
+ panOnDrag={editable ? PAN_BUTTONS : true}
641
+ panOnScroll
642
+ zoomActivationKeyCode={ZOOM_KEYS}
643
+ zoomOnDoubleClick={false}
644
+ minZoom={0.1}
645
+ maxZoom={2}
646
+ >
647
+ <Background gap={10} bgColor="rgb(var(--ec-page-bg))" color="rgb(var(--ec-page-border))" />
648
+ <ViewportSharer onChange={flow.setViewport} />
649
+ {/* Pointers, selections and comments are on the canvas people edit (L3) */}
650
+ {editable && <RemotePresence store={flow.presence} clientId={flow.clientId} />}
651
+ {editable && (
652
+ <CommentLayer
653
+ threads={comments.threads}
654
+ author={author}
655
+ openThreadId={openThreadId}
656
+ draft={draft}
657
+ showResolved={showResolved}
658
+ onOpen={openThreadById}
659
+ onCreate={createThread}
660
+ onCancelDraft={cancelDraft}
661
+ onReply={comments.reply}
662
+ onResolve={comments.setResolved}
663
+ onMove={comments.moveThread}
664
+ onMoveDraft={setDraft}
665
+ onDelete={deleteThread}
666
+ />
667
+ )}
668
+ </ReactFlow>
669
+
670
+ <AgentActivity
671
+ store={flow.presence}
672
+ clientId={flow.clientId}
673
+ enabled={editable}
674
+ following={following}
675
+ onToggleFollowing={toggleFollowing}
676
+ pauseRef={pauseCamera}
677
+ />
678
+
679
+ {!editable && (
680
+ <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))]">
681
+ <span>
682
+ <span className="font-semibold">L{level}</span> · {LEVELS.find((entry) => entry.level === level)?.description} ·
683
+ read only
684
+ </span>
685
+ <button onClick={editInL3} className="font-semibold text-[rgb(var(--ec-accent))] hover:underline">
686
+ Edit in L3
687
+ </button>
688
+ </div>
689
+ )}
690
+
691
+ <CanvasControls
692
+ level={level}
693
+ onLevelChange={changeLevel}
694
+ unavailableLevels={unavailableLevels}
695
+ editable={editable}
696
+ onReorder={onReorder}
697
+ canUndo={flow.canUndo}
698
+ canRedo={flow.canRedo}
699
+ onUndo={flow.undo}
700
+ onRedo={flow.redo}
701
+ commentMode={commentMode}
702
+ onToggleCommentMode={toggleCommentMode}
703
+ onAddNote={addNote}
704
+ />
705
+
706
+ <CanvasContextMenu
707
+ menu={contextMenu}
708
+ onAddComment={commentFromMenu}
709
+ onDeleteNode={deleteFromMenu}
710
+ onClose={closeContextMenu}
711
+ />
712
+
713
+ <CanvasHeader
714
+ title={flow.title}
715
+ onRetitle={flow.rename}
716
+ status={flow.status}
717
+ transport={flow.transport}
718
+ presence={flow.presence}
719
+ clientId={flow.clientId}
720
+ onJumpTo={jumpTo}
721
+ onRename={onRename}
722
+ onConnectAgent={openConnect}
723
+ webMcp={webMcp}
724
+ shareUrl={shareUrl}
725
+ newCanvasUrl={newCanvasUrl}
726
+ />
727
+
728
+ {flow.status === 'connected' && flow.nodes.length === 0 && (
729
+ <div className="pointer-events-none absolute inset-0 flex items-center justify-center">
730
+ <p className="text-sm text-[rgb(var(--ec-page-text-muted))]">
731
+ Drag components or catalog resources from the left onto the canvas, then share the link to work on it together.
732
+ </p>
733
+ </div>
734
+ )}
735
+ </div>
736
+
737
+ {chooser && (
738
+ <ContainerChooser
739
+ resource={chooser.resource}
740
+ contents={chooserContents}
741
+ position={flowToScreenPosition(chooser.center)}
742
+ onChoose={choose}
743
+ onCancel={cancelChooser}
744
+ />
745
+ )}
746
+ {connectOpen && (
747
+ <ConnectAgentDialog
748
+ link={agentLink}
749
+ webMcp={webMcp}
750
+ relayScriptPath={relayScriptPath}
751
+ onEnableWebMcp={turnOnWebMcp}
752
+ onClose={closeConnect}
753
+ />
754
+ )}
755
+ {inspected && <PropertiesPanel node={inspected} onChange={flow.updateNodeData} onDelete={flow.deleteNodes} />}
756
+ </div>
757
+ </DropTargetContext.Provider>
758
+ </UpdateNodeDataContext.Provider>
759
+ );
760
+ }